Compare commits
75
Commits
d2ab8c89b6
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36b86e5b92 | ||
|
|
0c2fc96ff8 | ||
|
|
4ea8f861cc | ||
|
|
7fed3a2e77 | ||
|
|
bce9b82b66 | ||
|
|
03f8786d24 | ||
|
|
bd20482cea | ||
|
|
1ed75ca156 | ||
|
|
3765e6b05d | ||
|
|
fecd8bf33f | ||
|
|
b4431a4dcd | ||
|
|
d6acd885dd | ||
|
|
cb0c55775a | ||
|
|
410b0c42ca | ||
|
|
003b3cb2bf | ||
|
|
5485b40d14 | ||
|
|
9d9e959744 | ||
|
|
8952bd5399 | ||
|
|
4005f566cf | ||
|
|
7eae2fa0aa | ||
|
|
5c5aead1a5 | ||
|
|
548f0a06ea | ||
|
|
8c6bf05269 | ||
|
|
0da84c804c | ||
|
|
8e3d356167 | ||
|
|
8bc8c97504 | ||
|
|
f4dcf3acbf | ||
|
|
64857f9d5e | ||
|
|
35c802c1b8 | ||
|
|
3b08f45618 | ||
|
|
37fc811752 | ||
|
|
40cab6142b | ||
|
|
d792b50343 | ||
|
|
8f924b793a | ||
|
|
ec977c24dd | ||
|
|
3d77e60a02 | ||
|
|
f546579596 | ||
|
|
73c3d41c87 | ||
|
|
3b146f91c2 | ||
|
|
79209da711 | ||
|
|
d80915635f | ||
|
|
e4a0fe72df | ||
|
|
5bd8b1587a | ||
|
|
924b6d09fa | ||
|
|
626403900f | ||
|
|
b4a95b3e6a | ||
|
|
da3700d1d5 | ||
|
|
19f9c7ace7 | ||
|
|
4b307b55b9 | ||
|
|
961abb01e7 | ||
|
|
e45db33297 | ||
|
|
6e0ccf8082 | ||
|
|
bd8a892101 | ||
|
|
2cd912858a | ||
|
|
55c2636db9 | ||
|
|
9f10984c18 | ||
|
|
cb39f94584 | ||
|
|
4cb76725fe | ||
|
|
91d5565141 | ||
|
|
3efee96230 | ||
|
|
533adfd41b | ||
|
|
a083719cf1 | ||
|
|
87ddb01063 | ||
|
|
83aa26634d | ||
|
|
74ca0948c9 | ||
|
|
462f65f66d | ||
|
|
a34554b7ff | ||
|
|
0e866f2465 | ||
|
|
4e1a633dbd | ||
|
|
3add6c3d31 | ||
|
|
61c0e88ad8 | ||
|
|
def473c856 | ||
|
|
3a46124676 | ||
|
|
292a6e95ab | ||
|
|
63f8ec6281 |
No files matched your search
@@ -0,0 +1,10 @@
|
|||||||
|
[target.x86_64-pc-windows-msvc]
|
||||||
|
linker = "rust-lld"
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
debug = 1 # Line tables only: cuts object file & PDB size by ~50%
|
||||||
|
codegen-units = 16 # Maximizes parallel CPU compilation threads
|
||||||
|
incremental = false # Enables 100% sccache object file caching across builds
|
||||||
|
|
||||||
|
[http]
|
||||||
|
check-revoke = false # Prevents SChannel revocation check errors on corporate VPNs
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
/target
|
/target
|
||||||
|
/target-wsl
|
||||||
cargo-llvm-cov.exe
|
cargo-llvm-cov.exe
|
||||||
llvm-cov.zip
|
llvm-cov.zip
|
||||||
|
|
||||||
.fastembed_cache/
|
.fastembed_cache/
|
||||||
*.py
|
*.py
|
||||||
|
!scripts/*.py
|
||||||
temp.json
|
temp.json
|
||||||
*.txt
|
*.txt
|
||||||
summary.md
|
summary.md
|
||||||
|
__pycache__/
|
||||||
@@ -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 (`list_active_tasks`, `list_pinned_files`):** Executed when a session starts. This gives the LLM immediate awareness of the current workflow.
|
|
||||||
* **Context Switching (`save_context_workspace`, `load_context_workspace`):** 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, pins) 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://pinned_files) 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://pinned_files`, 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
+1511
-428
File diff suppressed because it is too large.
Load diff
+3
-3
@@ -2,7 +2,7 @@
|
|||||||
members = [
|
members = [
|
||||||
"server",
|
"server",
|
||||||
"stub",
|
"stub",
|
||||||
"win-nvim",
|
"nvim-core",
|
||||||
"linux-nvim"
|
"mcp-stdio"
|
||||||
, "nvim-core", "mcp-stdio"]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
@@ -1,43 +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. 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:** ␍ead_file_skeleton
|
|
||||||
|
|
||||||
## 2. 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:** get_recent_logs, watch_process_logs
|
|
||||||
|
|
||||||
## 3. 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
|
|
||||||
|
|
||||||
## 4. Clipboard Watch Mode (Research & Triage)
|
|
||||||
When you are doing intense debugging across StackOverflow, logs, and docs, use the clipboard watcher to auto-ingest your breadcrumbs.
|
|
||||||
* **Action:** Ask the LLM to turn it on: "Enable clipboard watch mode."
|
|
||||||
* **Do say:** "I'm going to reproduce the bug and copy some stack traces and IDs. Give me a minute, then read my latest sticky notes to catch up."
|
|
||||||
* **Tool Triggered:** oggle_clipboard_watch_mode, followed by internal Sticky Note reads.
|
|
||||||
|
|
||||||
## 5. Neovim Ghost Text (Live Previews)
|
|
||||||
Keep your workflow entirely within your editor rather than copy-pasting code blocks from the chat.
|
|
||||||
* **Don't say:** "Write the updated function here so I can copy-paste it."
|
|
||||||
* **Do say:** "Push this refactor to my active Neovim buffer as ghost text so I can review it in-line."
|
|
||||||
* **Tool Triggered:**
|
|
||||||
vim_set_preview
|
|
||||||
|
|
||||||
## 6. Graph & Memory Management
|
|
||||||
Actively instruct the LLM to maintain its own memory constraints and organize data.
|
|
||||||
* **Do say:** "Log this architectural decision in the knowledge graph."
|
|
||||||
* **Do say:** "Add a sticky note with the test database credentials for this session."
|
|
||||||
* **Do say:** "Create a milestone for the 'Rich Clipboard' feature and break it down into active tasks."
|
|
||||||
* **Tools Triggered:** create_entities, log_decision, dd_sticky_note, dd_milestone, dd_task
|
|
||||||
|
|
||||||
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,171 +1,176 @@
|
|||||||
# mcp-memory
|
# mcp-memory
|
||||||
A high-performance, persistent Knowledge Graph and Context daemon for Antigravity, implementing the Model Context Protocol (MCP).
|
|
||||||
`
|
A high-performance, persistent Knowledge Graph, Code Intelligence, and Context daemon for Antigravity, implementing the Model Context Protocol (MCP).
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
mcp-memory acts as the persistent "brain" for the agy CLI agents. It tracks entities, relations, background tasks, engineering debt, and architectural decisions across sessions.
|
|
||||||
`
|
|
||||||
To eliminate heavy Cross-OS I/O penalties when using WSL and Windows simultaneously, mcp-memory operates using a **Dual-Transport Leader/Stub Architecture**:
|
|
||||||
* **The Server (mcp-memory-server)**: Runs natively on the Windows host. It binds to .0.0.0:3000, serving standard stdio to the primary Windows agy instance while simultaneously hosting an Axum HTTP server for secondary clients.
|
|
||||||
* **The Stub (mcp-memory-stub)**: An ultra-lightweight proxy binary. WSL agy instances run this native Linux stub, which transparently pipes stdio JSON-RPC traffic over the network to the Windows HTTP server (http://127.0.0.1:3000), completely bypassing WSL NTFS mounts. It features full MPSC queue buffering and a WebSocket reconnect handshake (notifications/tools/list_changed) so that tools automatically refresh seamlessly without disconnecting the CLI if the background server restarts.
|
|
||||||
`
|
|
||||||
## Quick Start & Usage
|
|
||||||
|
|
||||||
### 1. Windows Installation (The Server & Stub)
|
`mcp-memory` acts as the persistent "brain" for `agy` CLI agents and autonomous subagents. It tracks graph entities, relations, background tasks, milestones, engineering debt, architectural decisions, code modifications, terminal activity, and compiler error fixes across sessions.
|
||||||
|
|
||||||
To enforce strict process safety and eliminate file locks on Windows, the build, deploy, and execution lifecycle are entirely decoupled in the justfile.
|
To eliminate cross-OS I/O penalties when developing across WSL and Windows simultaneously, `mcp-memory` operates using a **Dual-Transport Leader/Stub Architecture**:
|
||||||
|
* **The Server (`mcp-memory-server`)**: Runs natively on the Windows host. It binds to `0.0.0.0:3000`, serving standard stdio to the primary Windows `agy` instance while simultaneously hosting Axum HTTP, WebSocket, and Zero-Latency UDP endpoints for secondary clients and UI dashboards.
|
||||||
|
* **The Stub (`mcp-memory-stub`)**: An ultra-lightweight proxy binary. WSL `agy` instances run this native Linux stub, which transparently pipes stdio JSON-RPC traffic over the local network to the Windows HTTP server (`http://127.0.0.1:3000`), completely bypassing WSL NTFS cross-mounts. It features full MPSC queue buffering and a WebSocket reconnect handshake (`notifications/tools/list_changed`) so that tools automatically refresh seamlessly without disconnecting the CLI if the background server restarts.
|
||||||
|
|
||||||
**The Golden Rule:** You must gracefully stop the server before deploying a new binary. Deploy recipes only copy files; they do not kill processes.
|
> [!NOTE]
|
||||||
|
> For in-depth strategy, casing standards, and tool semantics, please refer to the [Strategic Guidelines (`instructions.md`)](./instructions.md) and [Effective Discourse Guide](./EFFECTIVE_DISCOURSE.md).
|
||||||
|
|
||||||
The easiest way to manage this end-to-end (Stop -> Build -> Deploy -> Start) is using the chaining commands:
|
---
|
||||||
``powershell
|
|
||||||
# For the main server:
|
## 🏛️ Architecture Status: 100% ADR Implementation
|
||||||
|
|
||||||
|
All 25 Architectural Decision Records (**ADR-0080 through ADR-0104**) are **100% implemented, verified, and reconciled** in the persistent store:
|
||||||
|
* **ADR-0080 – ADR-0093**: Enterprise persistence, AST intelligence, vector embeddings, cross-OS dual transports, and headless Neovim RPC.
|
||||||
|
* **ADR-0094 – ADR-0101**: Zero-subprocess security invariants, pure native Rust clipboard (`arboard`), and bounded telemetry buffers.
|
||||||
|
* **ADR-0102**: Dynamic Fastembed micro-batching with 16k character budget ceiling.
|
||||||
|
* **ADR-0103**: Real-time Tantivy search reader auto-reloading upon background index commits.
|
||||||
|
* **ADR-0104**: Automated Git post-commit ADR & Task status reconciliation engine (`scripts/git-reconcile.py`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📡 Passive MCP Context Resources (`resources/list`)
|
||||||
|
|
||||||
|
Agents can passively read these 9 MCP resources for instant zero-turn context without incurring tool call latency:
|
||||||
|
|
||||||
|
| Resource URI | Resource Name | Description & Usage |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| `memory://graph/entities` | Graph Entities | All nodes and entities currently stored in the knowledge graph. |
|
||||||
|
| `memory://graph/relations` | Graph Relations | All relationship edges between entities in the knowledge graph. |
|
||||||
|
| `memory://tasks/active` | Active Tasks | List of all currently pending or uncompleted tasks. |
|
||||||
|
| `memory://decisions/active` | Active ADR Decisions | All accepted Architectural Decision Records (ADRs). |
|
||||||
|
| `memory://tech_debt/unresolved` | Unresolved Tech Debt | All currently open engineering debt items. |
|
||||||
|
| `memory://session/delta` | Session Delta | Code modifications, commits, active tasks, and notes created in the last 2 hours. |
|
||||||
|
| `memory://terminal/recent` | Terminal History | Recent terminal commands, shell interpreters (`pwsh`, `bash`, `nu`), working dirs, and exit codes. |
|
||||||
|
| `memory://activity/recent` | Recent Activity | Real-time IDE, editor, and developer activity logs. |
|
||||||
|
| `memory://milestones` | Milestones | Project milestones, deliverables, target dates, and status. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ MCP Workflow Prompts (`prompts/list`)
|
||||||
|
|
||||||
|
The server registers 5 high-signal workflow prompts:
|
||||||
|
* **`context_warmup`**: Warm up session context by reading active tasks, recent deltas, and the git worktree.
|
||||||
|
* **`analyze_tech_debt`**: Inspect open technical debt items and generate a prioritized remediation plan.
|
||||||
|
* **`summarize_architecture`**: Synthesize active ADRs and knowledge graph entities into an architectural overview.
|
||||||
|
* **`handoff_routine`**: Invoke the `DevOpsSRE` subagent at session end to generate a standup report and leave a handoff memo.
|
||||||
|
* **`archive_routine`**: Compress older session summaries into dense milestone retrospectives.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Complete MCP Tool Suite (53 Tools)
|
||||||
|
|
||||||
|
The server exposes 53 tools categorized into 7 functional domains:
|
||||||
|
|
||||||
|
### 1. Consolidated Smart Primary Tools (11 Domain Handlers)
|
||||||
|
* **`tasks`**: Complete task lifecycle management (`add`, `update`, `delete`, `list`, `set_criteria`, `verify`).
|
||||||
|
* **`milestones`**: Project milestone tracking (`add`, `update`, `list`).
|
||||||
|
* **`handoff_memos`**: Cross-session scratchpad and handoff memos (`leave`, `read`, `clear`).
|
||||||
|
* **`snippets`**: Reusable code snippet vault with hybrid BM25 + dense vector search (`store`, `search`, `delete`, `tag`).
|
||||||
|
* **`decisions`**: Architectural Decision Records (ADRs) (`log`, `update`, `query`, `delete`).
|
||||||
|
* **`tech_debt`**: Engineering technical debt backlog (`log`, `resolve`, `list`).
|
||||||
|
* **`environment`**: Infrastructure & tool fingerprints tracking (`update_fingerprint`, `read_fingerprint`, `log_requirement`, `register`, `get_details`).
|
||||||
|
* **`clipboard`**: Pure native Rust OS clipboard interface (`read`, `write`).
|
||||||
|
* **`hypotheses`**: Diagnostic hypothesis memory for root cause analysis (`log`, `query`).
|
||||||
|
* **`agent_signals`**: Real-time inter-agent signal bus (`broadcast`, `query`).
|
||||||
|
* **`process_logs`**: Process and daemon log management (`watch`, `get`, `clear`).
|
||||||
|
|
||||||
|
### 2. Knowledge Graph Core (18 Tools)
|
||||||
|
* `create_entities`, `create_relations`, `add_observations`, `delete_entities`, `delete_relations`, `delete_observations`
|
||||||
|
* `read_graph`, `search_nodes`, `open_nodes`, `visualize_graph`, `condense_entity`, `merge_entities`, `find_orphans`
|
||||||
|
* `get_subgraph` (BFS $N$-hop neighborhood expansion)
|
||||||
|
* `sweep_graph_health` (orphan detection, name similarity, automated merge recommendations)
|
||||||
|
* `resolve_stale_symbols` (workspace AST cross-referencing to eliminate stale graph nodes)
|
||||||
|
* `summarize_subgraph` (concise subgraph synthesis)
|
||||||
|
* `query_graph_path` (BFS shortest path finding)
|
||||||
|
|
||||||
|
### 3. AST & Code Intelligence (8 Tools)
|
||||||
|
* `read_file_skeleton`: Tree-sitter AST structural outline without implementation bodies.
|
||||||
|
* `replace_ast_node`: Precise structural code replacement preserving comments and formatting.
|
||||||
|
* `find_symbol_references`: Cross-file symbol reference lookup across snippets and disk source code.
|
||||||
|
* `get_callers`: Call site and caller identification across the codebase.
|
||||||
|
* `analyze_impact`: Blast-radius impact analysis of modifying a symbol or file.
|
||||||
|
* `read_directory_architecture`: Recursive directory structure analysis capped at depth 10.
|
||||||
|
* `semantic_code_search`: Dense vector semantic code search over indexed source code.
|
||||||
|
* `manage_subagent_namespace`: Isolated memory namespaces for concurrent subagent workflows.
|
||||||
|
|
||||||
|
### 4. Meta, Audit & Intelligence (15 Tools)
|
||||||
|
* `decisions`, `tech_debt`, `log_error_fix`, `search_error_fixes`, `log_code_change`, `query_recent_changes`
|
||||||
|
* `omni_search` (Reciprocal Rank Fusion hybrid BM25 + Vector search)
|
||||||
|
* `get_project_health` (high-level system health dashboard)
|
||||||
|
* `manage_checkpoint` (snapshot freeze and rollback)
|
||||||
|
* `query_lineage` (causal lineage linking tasks, ADRs, commits, and error fixes)
|
||||||
|
* `get_next_actionable_tasks` (topological unblocked task resolver)
|
||||||
|
* `hypotheses`, `get_preflight_context`, `agent_signals`, `auto_session_checkpoint`
|
||||||
|
|
||||||
|
### 5. Task & Milestone Management (2 Tools)
|
||||||
|
* `tasks`, `milestones`
|
||||||
|
|
||||||
|
### 6. Notes, Handoffs & Reporting (4 Tools)
|
||||||
|
* `handoff_memos`, `add_session_summary`, `generate_standup_report`, `promote_to_entity`
|
||||||
|
|
||||||
|
### 7. Git & Worktree Context (2 Tools)
|
||||||
|
* `get_active_worktree_context`, `query_git_diffs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🖥️ Brain Monitor Web UI (`http://127.0.0.1:3000/`)
|
||||||
|
|
||||||
|
The server hosts a live, reactive Single Page Application (SPA) dashboard:
|
||||||
|
* **Interactive Knowledge Graph:** Physics-simulated network graph with node-type coloring, drag-and-drop, and Inspector Panel.
|
||||||
|
* **Universal Search & Filtering:** Instant debounced search across all tabs with keyboard shortcut (`/`) to jump to the active tab's search bar.
|
||||||
|
* **Dynamic Pagination:** Configurable page sizes (`10`, `25`, `50`, `100`, `All`) preserving UI responsiveness across large datasets.
|
||||||
|
* **Descending ADR Ordering:** ADRs are automatically sorted with newest IDs first (e.g., ADR-0104, ADR-0103) on Page 1.
|
||||||
|
* **Live SSE & WebSocket Telemetry:** Real-time updates without manual browser refresh, wired to all domain mutations.
|
||||||
|
* **Kanban Board & Audit Ledger:** Direct task state transitions and chronological code change logs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Security & Concurrency Invariants
|
||||||
|
|
||||||
|
* **Zero Subprocess Policy**: Native system handlers (`clipboard`, `ast`, `search`, `db`) use pure native Rust crates (`arboard`, `tree-sitter`, `tantivy`, `psycopg`). Invoking external shell interpreters (`powershell.exe`, `wl-paste`, `xclip`, `cmd.exe`) is strictly prohibited.
|
||||||
|
* **Automated Post-Commit Reconciliation**: `scripts/git-reconcile.py` (installed via `just install-git-hooks`) reconciles referenced ADR and Task statuses immediately upon commit.
|
||||||
|
* **Atomic Store Write Lock Minimization**: Releases write lock immediately following in-memory mutation, serializing JSON payloads under read guards to prevent blocking concurrent readers.
|
||||||
|
* **Async Mutex Deadlock Elimination**: Converted all shared state and Neovim locks to `tokio::sync::Mutex` to prevent worker thread pool starvation.
|
||||||
|
* **Zero-Latency UDP Telemetry**: Bypasses disk I/O thrashing for high-frequency editor telemetry using deduplicated UDP streams (`MCP_UDP_PORT1`, `MCP_UDP_PORT2`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Quick Start & Lifecycle Management
|
||||||
|
|
||||||
|
### 1. Build and Deploy
|
||||||
|
```powershell
|
||||||
|
# Build and deploy everything across Windows and WSL
|
||||||
|
just all
|
||||||
|
|
||||||
|
# Or deploy Windows server with graceful staged hot-swap
|
||||||
just all-server-win
|
just all-server-win
|
||||||
|
|
||||||
# For the lightweight stubs/nvim servers:
|
# Install Git post-commit reconciliation hook
|
||||||
just all-stub-win
|
just install-git-hooks
|
||||||
just all-nvim-win
|
```
|
||||||
``
|
|
||||||
|
|
||||||
If you want to perform these steps manually, you must follow this exact order to avoid NTFS locks:
|
### 2. Service Management
|
||||||
``powershell
|
```powershell
|
||||||
just stop # 1. Gracefully shut down the background server (TCP 3000)
|
just start # Start background server on port 3000
|
||||||
just build-win # 2. Compile the binaries
|
just stop # Gracefully shut down server
|
||||||
just deploy-win # 3. Move the executables into ~/.local/bin/
|
just restart # Graceful restart with health check verification
|
||||||
just start # 4. Spawns the daemon completely detached in the background
|
just verify # Verify deployment health
|
||||||
just verify # 5. Hits the /ping endpoint to ensure liveness
|
just version # Check running API version and CLI version
|
||||||
``
|
```
|
||||||
|
|
||||||
**Step 1:** To bypass Antigravity's lazy-loading and ensure the server is instantly available for WSL, configure your PowerShell profile to auto-start the background server when you open a terminal:
|
### 3. Testing & Parity
|
||||||
``powershell
|
```powershell
|
||||||
# Add this to your PowerShell profile (ensuring it only fires on initial load, not background threads):
|
just test # Run fast parallel tests via cargo-nextest & type-check UI
|
||||||
if ($host.Name -eq 'ConsoleHost' -and -not (Get-Process mcp-memory-server -ErrorAction SilentlyContinue)) {
|
just test-config # Verify eagerTools configuration parity
|
||||||
Start-Process -FilePath "C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe" -WindowStyle Hidden -ErrorAction SilentlyContinue
|
just test-ui # Verify dashboard UI endpoint and HTML integrity
|
||||||
}
|
```
|
||||||
``
|
|
||||||
|
|
||||||
**Shutting Down & Managing:** If you need to stop, start, or restart the background daemon, NEVER use brute-force OS kill commands (e.g., Stop-Process, pkill). ALWAYS use the justfile wrappers, which trigger a graceful /shutdown over HTTP:
|
### 4. Agent Configuration (`mcp_config.json`)
|
||||||
``powershell
|
```json
|
||||||
just start
|
|
||||||
just stop
|
|
||||||
just restart
|
|
||||||
``
|
|
||||||
Alternatively, you can gracefully shut down the server by invoking the executable with the --exit flag (mcp-memory-server.exe --exit) or hitting the HTTP endpoint (POST http://127.0.0.1:3000/shutdown).
|
|
||||||
`
|
|
||||||
**Step 2:** Update your Windows ~/.gemini/config/mcp_config.json to point the CLI to the ultra-lightweight stub (since the server is already running in the background):
|
|
||||||
`json
|
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"memory": {
|
"mcp-memory": {
|
||||||
"command": "C:\\Users\\reazul.ashraf\\.local\\bin\\mcp-memory-stub.exe",
|
"command": "C:\\Users\\reazul.ashraf\\.gemini\\antigravity-cli\\mcp\\mcp-memory\\mcp-memory.exe",
|
||||||
"args": []
|
"args": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
|
||||||
`
|
|
||||||
### 2. WSL / Linux Installation (The Stub)
|
|
||||||
Compile the ultra-lightweight stub as a native Linux binary directly from WSL (we do not use zigbuild):
|
|
||||||
```powershell
|
|
||||||
just deploy-stub-wsl
|
|
||||||
```
|
```
|
||||||
`
|
|
||||||
Update your WSL ~/.gemini/config/mcp_config.json:
|
|
||||||
`json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"memory": {
|
|
||||||
"command": "/home/riz/.local/bin/mcp-memory-stub",
|
|
||||||
"args": [
|
|
||||||
"--target", "http://127.0.0.1:3000",
|
|
||||||
"--wake-cmd", "powershell.exe -NoProfile -WindowStyle Hidden -Command \"Start-Process -FilePath 'C:\\Users\\reazul.ashraf\\.local\\bin\\mcp-memory-server.exe' -WindowStyle Hidden\""
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
*Note: The --wake-cmd ensures that if you start WSL while Windows is completely asleep, the Linux stub will use WSL interop to silently spin up the Windows daemon in the background before connecting.*
|
|
||||||
`
|
|
||||||
## Push Safety Gates
|
|
||||||
The daemon also operates as a global safety gate for Git. Before pushing code, run:
|
|
||||||
`ash
|
|
||||||
mcp-memory gate verify
|
|
||||||
`
|
|
||||||
This queries the daemon (via HTTP) to confirm if pre-push validation (like running tests via PrePushAuditor) has been cleared by the agent.
|
|
||||||
`
|
|
||||||
## Brain Monitor Dashboard
|
|
||||||
The server hosts a live, real-time SPA dashboard called the **Brain Monitor**.
|
|
||||||
|
|
||||||
To view the dashboard, simply navigate to the root endpoint in your browser while the server is running:
|
|
||||||
http://127.0.0.1:3000/
|
|
||||||
|
|
||||||
### Dashboard Features:
|
|
||||||
* **Interactive Knowledge Graph:** A full physics-simulated network graph with node-type coloring, a drag-to-pan canvas, and an interactive Inspector Panel. Click any node to instantly view its stored observations.
|
|
||||||
* **Actionable Kanban Board:** Visually track all active agent Tasks. You can click 'Complete' directly from the UI to trigger a POST /api/tasks/{id}/complete REST call back to the daemon without needing the CLI.
|
|
||||||
* **📋 Inspect Clipboard (Image Capture):** Visually review OS-level clipboard images directly in the browser via the new /api/clipboard/capture endpoint, allowing the agent to dynamically 'see' screenshot errors or UI wireframes.
|
|
||||||
* **Sticky Notes & Search:** Browse your ephemeral notes and utilize the integrated fuzzy-search bar to locate graph entities instantly.
|
|
||||||
* **Native Dark Mode:** Fully styled for modern development environments.
|
|
||||||
|
|
||||||
## Rich Clipboard Tools
|
|
||||||
The memory server provides powerful, cross-OS native clipboard capabilities allowing the agent to inject and extract rich data. Because of the Leader/Stub architecture, all operations map directly to the Windows Host OS clipboard, regardless of whether the agent is running in Windows or WSL Ubuntu:
|
|
||||||
* **Text & HTML:** Read and write plain text or rich HTML formatting (using clipboard-win).
|
|
||||||
* **File Drops (CF_HDROP):** Read absolute file paths that were copied in Windows Explorer, or place file paths into the clipboard so the user can easily Ctrl+V them into IDEs or Explorer. (Note: Linux paths must be translated to \\wsl.localhost\... UNC paths first).
|
|
||||||
* **Images:** Read screenshots, and write generated images directly into the clipboard (using rboard).
|
|
||||||
|
|
||||||
You can also programmatically query the live backing APIs:
|
|
||||||
`curl http://127.0.0.1:3000/ping`
|
|
||||||
`curl http://127.0.0.1:3000/api/stats`
|
|
||||||
`curl http://127.0.0.1:3000/api/graph`
|
|
||||||
`curl http://127.0.0.1:3000/api/tasks`
|
|
||||||
|
|
||||||
## Further Reading
|
|
||||||
For a deep dive into the architecture, the **Redb LSM-tree** embedded database, `tantivy` indexing, and the HTTP SSE event loop, consult the design.md file in this repository.
|
|
||||||
|
|
||||||
## Neovim Integration
|
|
||||||
The linux-nvim and win-nvim MCP servers provide direct Msgpack-RPC communication with Neovim.
|
|
||||||
For this to work flawlessly across multiple Neovim instances (even split across Windows and WSL), you must load the provided gemini-integration.lua file in your Neovim init.lua:
|
|
||||||
`lua
|
|
||||||
dofile("C:/Users/reazul.ashraf/workspace/rust/mcp-memory/gemini-integration.lua")
|
|
||||||
`
|
|
||||||
|
|
||||||
### The "Last Focused Wins" Architecture
|
|
||||||
When you use the gemini-integration.lua script, Neovim acts as an active telemetry broadcaster.
|
|
||||||
Whenever you alt-tab into a Neovim window (FocusGained) or switch files (BufEnter):
|
|
||||||
1. **Fallback Sync:** Neovim instantly writes its unique Session ID (Named Pipe / Unix Socket) to ~/.gemini/active_nvim.txt.
|
|
||||||
2. **WebSocket Telemetry:** Neovim pushes a JSON payload containing the active filename, cursor row, and column via a connectionless UDP datagram to the Rust server's port 3002 listener.
|
|
||||||
3. **UI Broadcast:** The Rust server updates the global state and broadcasts this over WebSockets (/ws) so that the Brain Monitor Dashboard can animate your active file live in the UI!
|
|
||||||
|
|
||||||
### Interactive UDP UI (New)
|
|
||||||
The UI script (`gemini-ui.lua`) provides a deeply integrated, non-blocking pair-programming experience using zero-latency UDP:
|
|
||||||
* **Interactive Prompts:** The agent can trigger native `vim.ui.select` or `vim.ui.input` dialogs in your editor. Your responses are instantly routed back to the agent via UDP.
|
|
||||||
* **Smart Context (`<leader>ai`):** Highlighting code and pressing `<leader>ai` will package your prompt, file, and exact cursor/selection coordinates into a UDP packet and send it directly to the agent without spawning any subprocesses.
|
|
||||||
* **Ghost Text Diffs (Non-Destructive Review):** Instead of modifying your buffers directly, the agent uses `extmarks` to overlay proposed code changes as grayed-out "Ghost Text".
|
|
||||||
* Press `<leader>aa` (**A**gent **A**ccept) to apply the change and notify the agent.
|
|
||||||
* Press `<leader>ar` (**A**gent **R**eject) to dismiss the change and notify the agent.
|
|
||||||
|
|
||||||
### Neovim MCP Tools
|
|
||||||
The LLM agent interacts with your active Neovim session using a dedicated set of MCP tools. *(Note: /nvim/telemetry is strictly a one-way webhook for Neovim; the LLM uses the tools below to interact).*
|
|
||||||
* **vim_goto_line**: Open files and jump cursors directly from the LLM.
|
|
||||||
* **vim_set_diagnostics**: Push inline code review warnings as virtual text.
|
|
||||||
* **vim_get_active_buffer**: Read live, unsaved buffer contents.
|
|
||||||
* **vim_get_cursor**: Fetch precise line/column coordinates.
|
|
||||||
* **vim_get_visual_selection**: Read highlighted code blocks.
|
|
||||||
|
|
||||||
|
|
||||||
## Enhanced Developer Tools
|
|
||||||
- **AST Skeleton Extractor (
|
|
||||||
ead_file_skeleton)**: Uses ree-sitter to parse large code files (Rust, Python, TS/JS, Java, C, C++, Go) and return an AST structural outline containing only Imports, Structs, Enums, Traits, and Functions, massively saving LLM tokens.
|
|
||||||
- **Git Context (get_active_worktree_context)**: Uses native git2 C-bindings to retrieve the active branch, modified files, and a truncated local patch diff without shell parsing overhead.
|
|
||||||
- **Rolling Log Watcher**: Background background polling endpoints (watch_process_logs / get_recent_logs) to instantly debug daemon crashes.
|
|
||||||
- **Clipboard Watch Mode ( oggle_clipboard_watch_mode)**: Background daemon thread that auto-ingests your Ctrl+C clipboard activity directly into Knowledge Graph StickyNotes while you debug.
|
|
||||||
- **Ghost Text Previews (
|
|
||||||
vim_set_preview)**: Pushes proposed LLM code diffs directly into Neovim buffers as ephemeral virtual text.
|
|
||||||
- [Prompting Guide & Effective Discourse](./PROMPTING_GUIDE.md): Learn how to phrase prompts to get the most out of the agent and memory server.
|
|
||||||
|
|
||||||
## T2R & Token Efficiency Enhancements (V2)
|
|
||||||
- **AST Node Replacer (
|
|
||||||
eplace_ast_node)**: Uses ree-sitter to deterministically edit functions and structs without relying on exact line numbers or regex matching, ensuring zero syntax breaking edits.
|
|
||||||
- **Semantic Code Search (semantic_code_search)**: Integrates local vector embeddings to execute conceptual code searches instead of blind grep regexes, preventing hallucinated token consumption.
|
|
||||||
- **Interactive Terminal Integrations (
|
|
||||||
vim_send_to_terminal)**: Proxies shell execution into visible Neovim splits so human operators can watch agents compile code, debug output, and intervene interactively.
|
|
||||||
- **Bird's Eye Architecture View (
|
|
||||||
ead_directory_architecture)**: Generates a high-level summary of workspace directories using heuristic analysis to prevent LLMs from wasting tokens on reading dozens of files while exploring new repos.
|
|
||||||
@@ -12,7 +12,7 @@ Invoke these dynamically using the `invoke_subagent` tool. Use `send_message` to
|
|||||||
* **Role:** The Knowledge Graph Curator
|
* **Role:** The Knowledge Graph Curator
|
||||||
* **Permissions:** You MUST define this subagent with `enable_mcp_tools: true` and `enable_write_tools: true` so it can autonomously update the graph without hitting permission boundaries.
|
* **Permissions:** You MUST define this subagent with `enable_mcp_tools: true` and `enable_write_tools: true` so it can autonomously update the graph without hitting permission boundaries.
|
||||||
* **Trigger:** After completing a coding task, refactor, or bug fix.
|
* **Trigger:** After completing a coding task, refactor, or bug fix.
|
||||||
* **Action:** Send a message detailing the work (with exact git hashes and branches). The Librarian will use MCP tools (`log_code_change`, `log_error_fix`, `create_entities`, etc.) in the background to organize the knowledge graph.
|
* **Action:** Send a message detailing the work (with exact git hashes and branches). The Librarian will use MCP tools (`log_code_change`, `log_error_fix`, `create_entities`, etc.) in the background to organize the knowledge graph. **CRITICAL:** The Librarian MUST inspect recent git commits and commit messages for any referenced ADRs (e.g. `ADR-\d+`), and automatically reconcile their lifecycle status in `decisions` to `implemented` with the exact `git_commit` and `git_branch`. No ADR whose code has been committed should ever remain in `accepted` status.
|
||||||
|
|
||||||
## 2. PrePushAuditor
|
## 2. PrePushAuditor
|
||||||
* **Role:** Quality Gate Enforcer
|
* **Role:** Quality Gate Enforcer
|
||||||
@@ -30,7 +30,7 @@ Invoke these dynamically using the `invoke_subagent` tool. Use `send_message` to
|
|||||||
* **Role:** Project & Task Orchestrator
|
* **Role:** Project & Task Orchestrator
|
||||||
* **Permissions:** You MUST define this subagent with `enable_mcp_tools: true` and `enable_write_tools: true`.
|
* **Permissions:** You MUST define this subagent with `enable_mcp_tools: true` and `enable_write_tools: true`.
|
||||||
* **Trigger:** When we start a new feature or finish a task.
|
* **Trigger:** When we start a new feature or finish a task.
|
||||||
* **Action:** Passively monitors the `memory://tasks/active` and `memory://milestones` MCP resources. Creates tasks, organizes milestones, and updates statuses when criteria are met using `add_task`, `update_task_status`, `add_milestone`, and `verify_acceptance_criteria`.
|
* **Action:** Passively monitors the `memory://tasks/active` and `memory://milestones` MCP resources. Creates tasks, organizes milestones, and updates statuses when criteria are met using `tasks` and `milestones`.
|
||||||
|
|
||||||
## 5. DevOpsSRE
|
## 5. DevOpsSRE
|
||||||
* **Role:** Environment & Handoff Manager
|
* **Role:** Environment & Handoff Manager
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
name: MCP Protocol & Build Constraints
|
||||||
|
description: Strict constraints for Model Context Protocol stdio transport and project build tooling
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. MCP Stdio Protocol (Strict NDJSON)
|
||||||
|
- **Rule (CRITICAL):** The Model Context Protocol (MCP) `stdio` transport is strictly based on **pure NDJSON** (Newline Delimited JSON).
|
||||||
|
- **Rule:** NEVER inject Language Server Protocol (LSP) style HTTP headers (e.g., `Content-Length: <size>\r\n\r\n`) into MCP `stdio` streams. Payloads must be raw JSON objects delimited by a single newline character (`\n`).
|
||||||
|
|
||||||
|
# 2. Build Tooling Constraints (mcp-memory)
|
||||||
|
- **Rule:** NEVER use `cargo zigbuild` for cross-compilation in the `mcp-memory` project.
|
||||||
|
- **Rule:** ALWAYS use the project's native `justfile` targets (e.g., `just build-wsl`, `just deploy-stub-wsl`, `just deploy-all`) to manage building and deploying binaries across Windows and WSL boundaries.
|
||||||
|
|
||||||
|
# 3. Clipboard & Vision Tooling (Strict MCP Enforcement)
|
||||||
|
- **Rule (CRITICAL):** You MUST ALWAYS execute the `clipboard` MCP tool with action="read" (`mcp-memory`) when reading or inspecting text or images from the OS clipboard.
|
||||||
|
- **Rule:** NEVER run ad-hoc PowerShell (`System.Windows.Forms.Clipboard`), bash, or shell scripts to extract clipboard images or saved files. `clipboard` (read) handles native clipboard image extraction, auto-resizing, and compressed JPEG (`.jpg`) encoding to minimize LLM token usage.
|
||||||
@@ -2,6 +2,6 @@
|
|||||||
|
|
||||||
**CRITICAL RULE:** Under NO CIRCUMSTANCES should any agent or subagent directly manipulate, edit, or write raw JSON/data to the MCP Memory Graph files (e.g., `knowledge_graph_master.json`, `.db` files, or any files inside `~/.gemini/mcp_memory/`).
|
**CRITICAL RULE:** Under NO CIRCUMSTANCES should any agent or subagent directly manipulate, edit, or write raw JSON/data to the MCP Memory Graph files (e.g., `knowledge_graph_master.json`, `.db` files, or any files inside `~/.gemini/mcp_memory/`).
|
||||||
|
|
||||||
All interactions, additions, creations, and updates to the knowledge graph or task tracker MUST go through the officially exposed MCP tools (such as `add_task`, `set_acceptance_criteria`, `verify_acceptance_criteria`, `log_code_change`, `create_entities`, etc.).
|
All interactions, additions, creations, and updates to the knowledge graph or task tracker MUST go through the officially exposed MCP tools (such as `tasks` [add, set_criteria, verify], `log_code_change`, `create_entities`, etc.).
|
||||||
|
|
||||||
Bypassing the MCP API by using file-editing tools (like `replace_file_content` or `write_to_file`) on the database files corrupts the state, bypasses indexing, and destroys the Redb Write-Ahead Log (WAL). If an agent attempts to do this, immediately stop them and route the request correctly through the provided MCP API tools.
|
Bypassing the MCP API by using file-editing tools (like `replace_file_content` or `write_to_file`) on the database files corrupts the state, bypasses indexing, and destroys the Redb Write-Ahead Log (WAL). If an agent attempts to do this, immediately stop them and route the request correctly through the provided MCP API tools.
|
||||||
@@ -10,11 +10,57 @@ description: Strict guidelines for interacting with the mcp-memory server, ensur
|
|||||||
- If a port conflict occurs (e.g., a Rust panic `AddrInUse` during a `git push` gatekeeper check), **STOP** and immediately notify the user. Do not attempt to auto-resolve the conflict by killing the existing memory server process.
|
- If a port conflict occurs (e.g., a Rust panic `AddrInUse` during a `git push` gatekeeper check), **STOP** and immediately notify the user. Do not attempt to auto-resolve the conflict by killing the existing memory server process.
|
||||||
|
|
||||||
## 2. Proactive "Central Brain" Usage
|
## 2. Proactive "Central Brain" Usage
|
||||||
|
|
||||||
|
> [!NOTE] Two-Tier Memory Architecture
|
||||||
|
> 1. **Tier 1 (Static Markdown)**: Repository rules, coding style, tech constraints, and architectural boundaries belong in static git-tracked markdown (`agent-rules/*.md`, `instructions.md`) and system prompts for 0ms latency and deterministic turn-0 enforcement.
|
||||||
|
> 2. **Tier 2 (Structured DB & Telemetry)**: The MCP Memory server specializes in high-volume, dynamic data: file modification ledgers (`audit_ledger`), terminal command history, error resolutions (`log_error_fix`), active tasks, and preflight context aggregation.
|
||||||
|
|
||||||
The MCP Memory server is the central brain. You must be PROACTIVE, not reactive, in using it:
|
The MCP Memory server is the central brain. You must be PROACTIVE, not reactive, in using it:
|
||||||
- **Session Starts & Context Drops**: Always begin by checking `list_active_tasks`, `list_pinned_files`, and `read_sticky_notes`.
|
|
||||||
- **Sticky Notes**: Use sticky notes for transient, session-scoped operational constraints (e.g., "Do not touch file X until Y is done").
|
### Passive Resource Retrieval (Zero-Turn Latency)
|
||||||
- **Error Fixes**: The moment a tricky, undocumented, or environment-specific bug is resolved (e.g., Bitbucket markdown rendering quirks, nuanced framework bugs), IMMEDIATELY call `log_error_fix`. Do not wait for the user to ask.
|
Before making expensive active tool calls, read available MCP resources:
|
||||||
- **Tech Debt**: If you notice an anti-pattern (e.g., nested `if` statements, arrow anti-pattern) but deliberately skip fixing it to focus on a feature, IMMEDIATELY call `log_tech_debt`.
|
- **`memory://tasks/active`**: Currently uncompleted tasks, priorities, and criteria.
|
||||||
|
- **`memory://decisions/active`**: Active architectural decisions in `accepted` status.
|
||||||
|
- **`memory://tech_debt/unresolved`**: Open engineering tech debt items.
|
||||||
|
- **`memory://session/delta`**: Recent changes, active tasks, code edits, and notes created in the last 2 hours.
|
||||||
|
- **`memory://terminal/recent`**: Recent terminal commands, interpreters (`pwsh`, `bash`, `nu`), working dirs, and exit codes.
|
||||||
|
- **`memory://activity/recent`**: Real-time IDE and developer activity event logs.
|
||||||
|
- **`memory://milestones`**: Project milestones and deliverable tracking.
|
||||||
|
- **`memory://graph/entities` & `memory://graph/relations`**: Knowledge graph snapshots.
|
||||||
|
|
||||||
|
### Active Tool Invocations
|
||||||
|
- **Session Starts & Context Drops**: Begin by checking `memory://tasks/active` and `memory://session/delta` (or running `context_warmup` prompt), calling `get_preflight_context` and `omni_search` to regain operational context.
|
||||||
|
- **Context Switching**: When switching tasks or branches, use `manage_checkpoint` (action: "create") to freeze state, and use `manage_checkpoint` (action: "restore") to restore state for the task.
|
||||||
|
- **Error Fixes**: The moment a tricky, undocumented, or environment-specific bug is resolved, IMMEDIATELY call `log_error_fix`. Supply `repo_name`, `error_category`, and `stack_trace` so future searches can perform embedding-based match via `search_error_fixes`.
|
||||||
|
- **Tech Debt**: If you notice an anti-pattern but deliberately skip fixing it to focus on a feature, IMMEDIATELY call `tech_debt` (action: "log") with `description`, `file_path`, `line_range`, `workaround`, `effort_estimate`, and `severity`.
|
||||||
|
- **Architectural Decisions (ADR) & Lifecycle Closure**:
|
||||||
|
- When selecting design patterns, crate choices, or system structure, call `decisions` (action: "log") with `author`, `affected_components`, `alternatives_considered`, `decision`, and `consequence`.
|
||||||
|
- **MANDATORY Definition of Done**: When code implementing an ADR is committed, you MUST IMMEDIATELY call `decisions` (action: "update", id: "ADR-XXXX", status: "implemented", git_commit: <commit_hash>, git_branch: <branch>). NEVER leave an ADR in `accepted` once the implementing code is committed. The repository also executes `scripts/git-reconcile.py` on post-commit hooks (`just install-git-hooks`) to reconcile commit references automatically.
|
||||||
|
- **Task Management**: When creating tasks, supply `priority` ('low'|'medium'|'high'|'urgent'), `assigned_agent` (e.g. subagent role), `verification_command` (automated test command), and `acceptance_criteria`.
|
||||||
|
- **VCS & SVN Agnosticism**: Supply `vcs_type` ('git'|'svn'|'hg'), `vcs_revision` (git hash or svn revision like 'r12345'), and `upstream_url` to `log_code_change` and workspace tools.
|
||||||
|
- **Terminal & Shell Context**: Terminal sessions and commands are automatically tracked in the server over zero-latency UDP. Query `/terminal/history` or `memory://terminal/recent` when analyzing shell execution context.
|
||||||
|
- **Hypotheses & Root Cause Analysis**: When diagnosing complex bugs or race conditions, call `hypotheses` (action: "log" / "query") to record test evidence and maintain reasoning trails across sessions.
|
||||||
|
- **Inter-Agent Coordination**: Autonomous subagents should call `agent_signals` (action: "broadcast" / "query") to publish events and discover peer agent status.
|
||||||
|
- **Process & Daemon Logs**: Query or tail daemon logs with `process_logs` (action: "get", "watch", "clear") instead of dumping log files into context.
|
||||||
|
- **Clipboard & Multimodal Screenshots**:
|
||||||
|
- **User Image & Visual Queries**: When the user says "look at image in clipboard", "see screenshot", "check the clipboard", or refers to an error screenshot, ALWAYS call `clipboard(action: "image")`.
|
||||||
|
- **Decoupled Screenshot History**: Even if the user got sidetracked and copied text/URLs afterwards, `clipboard(action: "image")` reliably retrieves the last captured screenshot from the event-driven cache with exact metadata (`file_path`, `file_path_wsl`, `age`, `dimensions`, `ocr_text`).
|
||||||
|
- **WSL Ubuntu Compatibility**: For WSL sessions, use the returned `file_path_wsl` (e.g. `/mnt/c/...`) directly with `view_file`.
|
||||||
|
- **Text-Only Models & Fast Context**: Use the returned deterministic `ocr_text` to immediately extract error traces, URLs, and code snippets without waiting for multimodal vision tokens.
|
||||||
|
- **Copying Formatted Tables / Data**: `clipboard(action: "read")` automatically strips Win32 `CF_HTML` headers and converts HTML tables directly into clean GFM Markdown.
|
||||||
|
- **End-of-Session Handoff**: When finishing work or logging off, trigger the `handoff_routine` prompt or call `generate_standup_report` and `handoff_memos` (action: "leave").
|
||||||
|
|
||||||
## 3. Delegation
|
## 3. Delegation
|
||||||
Continue to use the `MemoryLibrarian` subagent to log routine code changes (`log_code_change`) in the background to prevent cluttering the main conversation context.
|
Continue to use the `MemoryLibrarian`, `PrePushAuditor`, `BugDiagnostician`, `ScrumMaster`, and `DevOpsSRE` subagents to offload graph curation, pre-push auditing, hypothesis testing, task tracking, and session handoffs.
|
||||||
|
|
||||||
|
## 4. Performance & Batching Rules
|
||||||
|
- **Batch Mutating Operations**: When creating or updating multiple graph entities, code snippets, or observations, always batch items into a single tool call array (e.g. `create_entities` with multiple items) to leverage the server's single-pass transaction flush.
|
||||||
|
- **High-Signal Tool Confirmations**: Tool call execution responses return structured, informative summaries (entity names, types, created counts, and edge paths). Agents DO NOT need to invoke follow-up `open_nodes` calls purely to confirm successful creation.
|
||||||
|
- **Tantivy Search Reader Refresh**: Search queries (`omni_search`, `search_nodes`) automatically reload pending commits prior to executing searches, ensuring immediate visibility of newly created items.
|
||||||
|
- **Bounded Telemetry Buffers**: High-volume telemetry logs (`error_fixes` max 300, `ledger` max 500, `handoff_memos` max 200, `session_summaries` max 200, `agent_signals` max 500) enforce deterministic length caps to guarantee low memory footprints over long sessions.
|
||||||
|
|
||||||
|
## 5. Pure Native Rust Invariants & Security
|
||||||
|
- **Zero External Subprocesses**: Native system handlers (`clipboard`, `ast`, `search`, `db`) MUST use pure native Rust crates (`arboard`, `tree-sitter`, `tantivy`, `psycopg`). Subprocess calls to `powershell.exe`, `wl-paste`, `xclip`, or `cmd.exe` are strictly banned in native handlers.
|
||||||
|
- **Transient Lock Handling**: Transient OS handle collisions (e.g. Win32 OLE `OpenClipboard` locks) must be handled natively with retry loops and backoffs in Rust.
|
||||||
|
- **Atomic Serialization Scope**: All store updates (`Store::modify` / `modify_async`) perform state mutation and JSON serialization inside an atomic write lock scope to guarantee thread-safe `DbWriteQueue` synchronization.
|
||||||
|
- **Path Traversal Guards**: AST and file handler operations enforce path canonicalization (`validate_safe_path`) to prevent directory traversal vulnerabilities (`..`).
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Neovim UX Protocol & Live Editing
|
# 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)
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
# Neovim MCP Architecture (Dual-OS)
|
# Neovim MCP Architecture (Cross-Platform)
|
||||||
|
|
||||||
We use a modular, multi-binary approach for MCP Neovim integration to cleanly separate Windows and Linux concerns, avoiding complex cross-OS `wsl.exe` bridging within the main `mcp-memory-server`.
|
We use a modular, cross-platform approach for MCP Neovim integration to cleanly connect with Neovim instances across Windows and WSL.
|
||||||
|
|
||||||
## The Architecture
|
## The Architecture
|
||||||
1. **`mcp-memory-server`:** The core Windows daemon (handles state, lock-files, and global graph).
|
1. **`mcp-memory-server`:** The core Windows daemon (handles state, lock-files, and global graph).
|
||||||
2. **`mcp-memory-stub`:** The WSL proxy that forwards standard json-rpc to the Windows daemon.
|
2. **`mcp-memory-stub`:** The WSL proxy that forwards standard json-rpc to the Windows daemon.
|
||||||
3. **`mcp-memory-win-nvim`:** A dedicated Windows-native MCP server. Its sole responsibility is finding active Neovim instances running natively on Windows and sending RPC commands to them.
|
3. **`mcp-memory-nvim`:** The unified cross-platform Neovim MCP server binary (`mcp-memory-nvim.exe` on Windows, `mcp-memory-nvim` on Linux). Its sole responsibility is finding active Neovim instances (via named pipes on Windows or domain sockets on Unix/WSL) and sending RPC commands to them.
|
||||||
4. **`mcp-memory-linux-nvim`:** A dedicated Linux-native MCP server running inside WSL. Its sole responsibility is finding active Neovim instances inside WSL (including Tmux sessions) and sending RPC commands to them.
|
|
||||||
|
|
||||||
This isolates editor-control logic to the native OS where the editor is actually running.
|
This isolates editor-control logic natively to whichever OS environment execution is running in.
|
||||||
|
|
||||||
## How the Respective MCP Servers Get Called
|
## How the MCP Server Gets Called
|
||||||
|
|
||||||
The Antigravity CLI (`agy`) acts as the MCP Client and automatically manages the lifecycle of these servers.
|
The Antigravity CLI (`agy`) acts as the MCP Client and automatically manages the lifecycle of these servers.
|
||||||
|
|
||||||
@@ -19,20 +18,21 @@ The Antigravity CLI (`agy`) acts as the MCP Client and automatically manages the
|
|||||||
- Windows: `C:\Users\reazul.ashraf\.gemini\config\mcp_config.json`
|
- Windows: `C:\Users\reazul.ashraf\.gemini\config\mcp_config.json`
|
||||||
|
|
||||||
2. **Execution:**
|
2. **Execution:**
|
||||||
When `agy` starts up, it reads `mcp_config.json`. If it finds `"linux-nvim": { "command": "/home/riz/.local/bin/mcp-memory-linux-nvim" }`, it will spawn that binary as a background subprocess using standard `stdio`.
|
When `agy` starts up, it reads `mcp_config.json`. If it finds `"win-nvim": { "command": "C:\\Users\\reazul.ashraf\\.local\\bin\\mcp-memory-nvim.exe" }`, it will spawn that binary as a background subprocess using standard `stdio`.
|
||||||
|
|
||||||
3. **Communication:**
|
3. **Communication:**
|
||||||
- The LLM requests to use a tool (e.g., `nvim_goto_line`).
|
- The LLM requests to use a consolidated tool (e.g., `nvim_workspace` with action `focus`, or `nvim_exec`).
|
||||||
- The `agy` CLI sends a JSON-RPC request to the `mcp-memory-linux-nvim` subprocess via its `stdin`.
|
- The `agy` CLI sends a JSON-RPC request to the `mcp-memory-nvim` subprocess via its `stdin`.
|
||||||
- The Rust MCP Server receives the request, connects to the Neovim active socket (`~/.gemini/active_nvim.txt`), sends the Msgpack-RPC command, and writes the JSON-RPC response back to `stdout`.
|
- The Rust MCP Server receives the request, connects to the Neovim active socket/pipe (`~/.gemini/active_nvim.txt` or `\\.\pipe\nvim.*`), sends the Msgpack-RPC command, and writes the JSON-RPC response back to `stdout`.
|
||||||
- The `agy` CLI reads the response from `stdout` and returns it to the LLM context.
|
- The `agy` CLI reads the response from `stdout` and returns it to the LLM context.
|
||||||
|
|
||||||
## Capabilities & Requirements
|
## Capabilities & Requirements
|
||||||
To use this architecture, Neovim must run the `gemini-integration.lua` script to broadcast its active socket to `~/.gemini/active_nvim.txt`.
|
To use this architecture, Neovim must run the `gemini-integration.lua` script to broadcast its active socket to `~/.gemini/active_nvim.txt`.
|
||||||
|
|
||||||
The MCP servers provide 5 core tools:
|
The MCP server provides 5 cohesive mega-tools:
|
||||||
1. **`nvim_goto_line`**
|
1. **`nvim_buffer`** (actions: `read`, `replace`, `save`, `undo`, `redo`, `create_scratch`)
|
||||||
2. **`nvim_set_diagnostics`**
|
2. **`nvim_workspace`** (actions: `list_buffers`, `list_windows`, `focus`, `split`, `cwd`)
|
||||||
3. **`nvim_get_active_buffer`**
|
3. **`nvim_intelligence`** (actions: `hover`, `definition`, `references`, `outline`, `query`, `diagnostics`, `rename`, `code_action`)
|
||||||
4. **`nvim_get_cursor`**
|
4. **`nvim_ui`** (actions: `highlight`, `ghost_text`, `clear`)
|
||||||
5. **`nvim_get_visual_selection`**
|
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.
|
description: Route code edits to Neovim buffers instead of disk/console when Neovim is running.
|
||||||
trigger: always_on
|
trigger: always_on
|
||||||
---
|
---
|
||||||
# Neovim Code Editing
|
# Neovim Code Editing (Agnostic & Universal)
|
||||||
|
|
||||||
## Never Spawn Headless Instances (CRITICAL)
|
These rules apply universally to ALL Neovim environments (Windows, WSL, or any remote server). Do not treat them as OS-specific.
|
||||||
**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.
|
|
||||||
|
|
||||||
If the user asks to "open", "show", or "see" a file in Neovim, you MUST use the `linux-nvim` (or `win-nvim`) 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:
|
## 2. Strict Tool Adherence (No Raw Lua RCE)
|
||||||
1. Verify if the win-nvim (on Windows) or linux-nvim (on WSL) MCP server is responsive.
|
You must strictly use the specialized, sandboxed Neovim MCP tools:
|
||||||
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).
|
- `nvim_buffer`: For reading, writing, saving, and creating scratch buffers.
|
||||||
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.
|
- `nvim_workspace`: For creating splits and focusing panes.
|
||||||
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.
|
- `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)
|
## 3. Headless Quarantine
|
||||||
When manipulating buffers via Lua, you MUST provide visual feedback:
|
Headless mode (`nvim --headless`) is strictly banned for interactive edits.
|
||||||
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).
|
**NEVER** run `nvim <filename>` via terminal commands to edit or "open" a file for the user (it spawns a zombie ghost process).
|
||||||
2. **Buffer Focus:** Always switch the active Neovim window to the buffer you just edited (e.g., `vim.cmd('b ' .. bufnr)`).
|
Headless instances are allowed ONLY for non-interactive background batch processing (e.g., project-wide formatting or linting).
|
||||||
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`).
|
|
||||||
|
|
||||||
## Interactive Review & Automatic Wakeup (MANDATORY)
|
## 4. UI Presentation & Chat Console Minimization
|
||||||
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.
|
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.
|
||||||
You MUST spawn a background task to listen for their :w save event:
|
|
||||||
1. Use the ␍un_command tool to execute:
|
## 5. Visual Cues & Auto-Save
|
||||||
curl -s "http://127.0.0.1:3000/events/wait?topic=nvim:save:$((Get-Item $File).FullName.Replace('\', '/'))&session_id=$SessionId"
|
When manipulating buffers via MCP:
|
||||||
2. Set WaitMsBeforeAsync to a low value (e.g., 500ms) so it goes to the background immediately.
|
1. **Focus & Jump:** Always switch the active Neovim window to the edited buffer and jump the cursor to the changed line.
|
||||||
3. Stop calling tools and end your turn.
|
2. **Auto-Save:** Auto-save the buffer by default so linters/compilers see it immediately.
|
||||||
When the user saves the file, the curl command will complete and the system will automatically wake you up to proceed.
|
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`).
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
# Neovim MCP Enforcement Rule
|
# Neovim MCP Enforcement Rule
|
||||||
|
|
||||||
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 `linux-nvim` (WSL/Linux) or `win-nvim` (Windows) MCP servers.
|
When interacting with the user's Neovim editor (e.g., opening a file, moving the cursor, reading the active buffer, setting diagnostics), you MUST ALWAYS use the MCP tools provided by the `win-nvim` (Neovim) MCP server.
|
||||||
|
|
||||||
- You are strictly forbidden from using bash scripts, `nvim --server`, or other raw terminal/shell hacks to remote-control Neovim.
|
- You are strictly forbidden from using bash scripts, `nvim --server`, or other raw terminal/shell hacks to remote-control Neovim.
|
||||||
- You must rely entirely on the MCP tool registry (e.g., `nvim_goto_line`, `nvim_get_active_buffer`, `nvim_get_cursor`, `nvim_get_visual_selection`, `nvim_set_diagnostics`).
|
- You must rely entirely on the consolidated MCP tool registry (`nvim_buffer`, `nvim_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.
|
- 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,10 +1,26 @@
|
|||||||
# Rust Guidelines & Quirks
|
# Rust Guidelines & Quirks
|
||||||
|
|
||||||
## Concurrency & Locking
|
## Concurrency & Async Locking
|
||||||
- **Lock Poisoning Protection:** NEVER use `.unwrap()` when acquiring a `Mutex` or `RwLock` (e.g., `lock.write().unwrap()`). ALWAYS use `.unwrap_or_else(|e| e.into_inner())` to gracefully recover the underlying data from poisoned locks and prevent cascading panics across threads or async tasks.
|
- **Lock Poisoning & Async Safety:** Use `tokio::sync::RwLock` for state shared across Tokio async tasks (such as active WebSocket clients) to avoid blocking worker threads during broadcast fanouts. For synchronous locks, prefer non-poisoning structures or recover cleanly using `.unwrap_or_else(|e| e.into_inner())`.
|
||||||
- **Panic-Free Architecture:** Avoid `.unwrap()` anywhere in production code. Use `.expect()` for startup initialization errors, and `.unwrap_or_else()`, `.unwrap_or_default()`, or proper `Result` propagation for runtime operations.
|
- **Atomic Store Write Lock Minimization:** Minimize write lock duration by executing JSON serialization under read lock guards, keeping write guards strictly to in-memory state mutations.
|
||||||
|
- **Two-Phase Graph Condensation:** When performing summarization or condensation across stores (`condense_graph_worker`), implement a two-phase commit: read non-destructively and synthesize observations first, insert into the knowledge graph, and only prune summarized source records by timestamp/content after successful insertion.
|
||||||
|
- **Watch-Based Non-Destructive Shutdown Channels:** Use `tokio::sync::watch` rather than `tokio::sync::mpsc` for cancellation signaling to allow multiple workers to observe shutdown state without consuming or starving sibling workers.
|
||||||
|
- **Redb Transient Lock Resilience:** Implement retry loops with exponential backoff on table or database lock contention before aborting or panicking.
|
||||||
|
- **Safe RPC Request Tracking:** Clean up pending request maps (`PENDING_REQUESTS.remove(&msgid)`) upon timeouts or channel disconnects to prevent orphan memory leaks.
|
||||||
|
- **Panic-Free Architecture:** Avoid raw `.unwrap()` in production runtime paths. Use `.unwrap_or_default()`, or proper `Result` propagation for runtime operations.
|
||||||
|
- **Background Worker Task Supervision:** Always track `tokio::task::JoinHandle` handles for background workers (`ttl_sweeper_worker`, `index_committer_worker`, `condense_graph_worker`) and log thread exit or panic events cleanly.
|
||||||
|
- **Offload Heavy Index Rebuilds:** In `MemoryState::rebuild_index`, offload full graph cloning and Tantivy document re-indexing into `tokio::task::spawn_blocking` to avoid stalling async worker threads.
|
||||||
|
|
||||||
|
## Embedding & Memory Optimizations
|
||||||
|
- **Dynamic Character Batching:** In embedding generation (`generate_embeddings_async`), dynamically chunk batches based on total character size (e.g. 16,384 chars) rather than static item counts to prevent OOM spikes on large files while maximizing SIMD throughput.
|
||||||
|
- **SIMD Cosine Similarity:** Compute dot-product and norm accumulators in single-pass iterator folds to facilitate vector auto-vectorization across CPU instruction sets (`AVX2`/`NEON`).
|
||||||
|
- **Memory Truncation Bounds:** Enforce a 4KB ceiling on telemetry detail strings (`ActivityRecord`, `TerminalHistory`) before queuing items into ring buffers to bound heap usage.
|
||||||
|
|
||||||
## IDE & Rust-Analyzer Quirks
|
## IDE & Rust-Analyzer Quirks
|
||||||
- **Boolean NOT Operator (E0600):** Avoid using the unary `!` operator on complex boolean expressions inside closures (e.g., `!(a == b && c == d)`). `rust-analyzer` may lose track of the type boundary and falsely report an E0600 error (`cannot apply unary operator ! to type bool`). Rewrite these expressions using De Morgan's laws (e.g., `a != b || c != d`).
|
- **Boolean NOT Operator (E0600):** Avoid using the unary `!` operator on complex boolean expressions inside closures (e.g., `!(a == b && c == d)`). Rewrite these expressions using De Morgan's laws (e.g., `a != b || c != d`).
|
||||||
- **Option::None Shadowing:** If `rust-analyzer` throws a `non_snake_case` warning for `None` during pattern matching (often caused by wildcard imports like `use crate::models::*;` shadowing standard prelude variants), explicitly namespace the variant as `std::option::Option::None` to satisfy the LSP.
|
- **Option::None Shadowing:** If `rust-analyzer` throws a `non_snake_case` warning for `None` during pattern matching (often caused by wildcard imports like `use crate::models::*;`), explicitly namespace as `std::option::Option::None`.
|
||||||
- **Deep Cloning across Thread Boundaries:** When moving large structs (like entities with large text vectors) into a `tokio::task::spawn_blocking` closure for indexing or processing, construct the required primitive payloads or target structs on the main thread *before* the closure to avoid `.clone()`ing the entire massive struct across the `'static` boundary.
|
- **Deep Cloning across Thread Boundaries:** Construct target primitive payloads or target structs on the main thread *before* moving into `tokio::task::spawn_blocking` closures to avoid cloning massive structs across thread boundaries.
|
||||||
|
|
||||||
|
## Windows MSVC & Test Concurrency
|
||||||
|
- **ONNX Runtime / Fastembed Concurrency Resilience:** ONNX Runtime (`ort.dll` via `fastembed`) model initialization is strictly managed via a thread-safe singleton (`OnceLock<Mutex<TextEmbedding>>`) behind a process-wide `INIT_MUTEX`. The historical `0xc0000374 STATUS_HEAP_CORRUPTION` crash under uncoordinated C-ABI initializations is fully resolved. Full parallel test execution (`cargo test --workspace` or `cargo nextest run --workspace`) across all CPU cores without `--test-threads=1` is safe, recommended, and standard across all platforms.
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Antigravity State Management & Git Worktree Rules
|
# Antigravity State Management & Git Worktree Rules
|
||||||
|
|
||||||
## State Tracking & Persistent Memory
|
## State Tracking & Persistent Memory
|
||||||
- **Long-term Knowledge (MCP):** Use the MCP memory graph strictly for long-term, persistent facts such as architectural decisions, environment invariants, SSH mappings, and user preferences.
|
- **Long-term Knowledge (MCP):** Use the MCP memory graph strictly for long-term, persistent facts such as architectural decisions, environment invariants, SSH mappings, and domain entities. Static developer preferences and rules are kept in git-tracked markdown files.
|
||||||
- **Transient State (Git):** Do NOT write transient task progress (e.g., "currently editing line 42") to MCP memory. Continue to use verbose, incremental local `git` commits to track short-term state and maintain rollback safety.
|
- **Transient State (Git):** Do NOT write transient task progress (e.g., "currently editing line 42") to MCP memory. Continue to use verbose, incremental local `git` commits to track short-term state and maintain rollback safety.
|
||||||
|
|
||||||
## Branching Strategy & Workflow (Git Worktrees)
|
## Branching Strategy & Workflow (Git Worktrees)
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -32,3 +32,9 @@ trigger: always_on
|
|||||||
# 8. GCP Infrastructure Provisioning (gcloud & Cloud Armor)
|
# 8. GCP Infrastructure Provisioning (gcloud & Cloud Armor)
|
||||||
- **Rule (Cloud Armor IP Limits):** GCP Cloud Armor security policies strictly enforce a limit of **10 IP ranges per rule** (`--src-ip-ranges`). When allowlisting large services (like Atlassian Bitbucket which has 11+ IP CIDR blocks), you MUST split the ranges across multiple rules (e.g., priority 1000 and 1001) to prevent the `Only a maximum of 10 IP ranges allowed per rule` API error.
|
- **Rule (Cloud Armor IP Limits):** GCP Cloud Armor security policies strictly enforce a limit of **10 IP ranges per rule** (`--src-ip-ranges`). When allowlisting large services (like Atlassian Bitbucket which has 11+ IP CIDR blocks), you MUST split the ranges across multiple rules (e.g., priority 1000 and 1001) to prevent the `Only a maximum of 10 IP ranges allowed per rule` API error.
|
||||||
- **Rule (gcloud Idempotency):** When writing bash scripts to provision GCP infrastructure, NEVER use bare `gcloud ... create` commands. You MUST wrap all creation commands in existence checks (e.g., `if ! gcloud ... describe ... >/dev/null 2>&1; then ... fi`) to ensure the script is fully idempotent and can be safely retried upon failure.
|
- **Rule (gcloud Idempotency):** When writing bash scripts to provision GCP infrastructure, NEVER use bare `gcloud ... create` commands. You MUST wrap all creation commands in existence checks (e.g., `if ! gcloud ... describe ... >/dev/null 2>&1; then ... fi`) to ensure the script is fully idempotent and can be safely retried upon failure.
|
||||||
|
|
||||||
|
# 9. Rust Build System & Toolchain Resilience
|
||||||
|
- **Rule (SChannel VPN Revocation Bypass):** To prevent `CRYPT_E_NO_REVOCATION_CHECK` errors when fetching crates over corporate VPNs, ensure `.cargo/config.toml` specifies `[http] check-revoke = false`.
|
||||||
|
- **Rule (sccache Caching Efficiency):** When using `sccache` as `rustc-wrapper`, set `incremental = false` under `[profile.dev]` in `.cargo/config.toml`. `sccache` cannot cache incremental compilation units.
|
||||||
|
- **Rule (sccache Daemon Recovery):** If `sccache` fails with socket error 10054 (`connection forcibly closed`), restart the daemon using `sccache --stop-server; Start-Sleep -Seconds 1; sccache --start-server` before retrying compilation.
|
||||||
|
- **Rule (cargo-llvm-cov Toolchain Matching):** Always set `LLVM_COV` and `LLVM_PROFDATA` environment variables to the matching `rustup` toolchain LLVM binaries (`.../lib/rustlib/<target>/bin/llvm-cov.exe`) to prevent LLVM profile format version mismatches.
|
||||||
@@ -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,20 +0,0 @@
|
|||||||
use std::process::Command;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let 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());
|
|
||||||
|
|
||||||
let git_date = Command::new("git")
|
|
||||||
.args(&["log", "-1", "--format=%cd", "--date=format:%Y.%m.%d"])
|
|
||||||
.output()
|
|
||||||
.ok()
|
|
||||||
.and_then(|out| String::from_utf8(out.stdout).ok())
|
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
|
||||||
|
|
||||||
let version = format!("{} ({} {})", env!("CARGO_PKG_VERSION"), git_date.trim(), git_hash.trim());
|
|
||||||
println!("cargo:rustc-env=APP_VERSION={}", version);
|
|
||||||
}
|
|
||||||
+2
-2
@@ -33,10 +33,10 @@ $wslBase = "/home/riz/.local/bin"
|
|||||||
$components = @(
|
$components = @(
|
||||||
@{ Name = "Server (Win)"; Path = "$winBase\mcp-memory-server.exe"; Env = "Win" },
|
@{ Name = "Server (Win)"; Path = "$winBase\mcp-memory-server.exe"; Env = "Win" },
|
||||||
@{ Name = "Stub (Win)"; Path = "$winBase\mcp-memory-stub.exe"; Env = "Win" },
|
@{ Name = "Stub (Win)"; Path = "$winBase\mcp-memory-stub.exe"; Env = "Win" },
|
||||||
@{ Name = "Win-Nvim"; Path = "$winBase\mcp-memory-win-nvim.exe"; Env = "Win" },
|
@{ Name = "Nvim (Win)"; Path = "$winBase\mcp-memory-nvim.exe"; Env = "Win" },
|
||||||
@{ Name = "Server (WSL)"; Path = "$wslBase/mcp-memory-server"; Env = "WSL" },
|
@{ Name = "Server (WSL)"; Path = "$wslBase/mcp-memory-server"; Env = "WSL" },
|
||||||
@{ Name = "Stub (WSL)"; Path = "$wslBase/mcp-memory-stub"; Env = "WSL" },
|
@{ Name = "Stub (WSL)"; Path = "$wslBase/mcp-memory-stub"; Env = "WSL" },
|
||||||
@{ Name = "Linux-Nvim"; Path = "$wslBase/mcp-memory-linux-nvim"; Env = "WSL" }
|
@{ Name = "Nvim (WSL)"; Path = "$wslBase/mcp-memory-nvim"; Env = "WSL" }
|
||||||
)
|
)
|
||||||
|
|
||||||
foreach ($comp in $components) {
|
foreach ($comp in $components) {
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 82 KiB |
@@ -1,189 +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/sticky` & `GET /api/search`: Powers the sticky notes tab 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.
|
|
||||||
* **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:** Sticky notes, Session Summaries, Handoff Memos, and Context Workspaces.
|
|
||||||
* **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.
|
|
||||||
* **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,
|
|
||||||
ead_graph, etc.
|
|
||||||
* **Task & Context Tracking:** add_task, add_sticky_note, add_session_summary, etc.
|
|
||||||
* **Engineering & DevOps:** log_code_change, log_error_fix, log_tech_debt, add_pr_checklist_item.
|
|
||||||
* **Environment & Workspaces:**
|
|
||||||
egister_environment, save_context_workspace, pin_file.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
$lines = Get-Content justfile
|
|
||||||
$valid_lines = @()
|
|
||||||
|
|
||||||
foreach ($line in $lines) {
|
|
||||||
if ($line -match "Building Windows Nvim" -or $line -match "cargo build --release -p mcp-memory-win-nvim" -or $line -match "^build-nvim-win:") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
$valid_lines += $line
|
|
||||||
}
|
|
||||||
$valid_lines | Set-Content justfile
|
|
||||||
+110
-14
@@ -1,24 +1,16 @@
|
|||||||
-- gemini-integration.lua
|
-- gemini-integration.lua
|
||||||
-- Registers the active Neovim instance for the Antigravity MCP Server
|
-- Registers the active Neovim instance for the Antigravity MCP Server
|
||||||
|
|
||||||
|
local last_active_task = nil
|
||||||
|
local last_active_gate = nil
|
||||||
|
|
||||||
vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePost"}, {
|
vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePost"}, {
|
||||||
callback = function(ev)
|
callback = function(ev)
|
||||||
-- Only register if Neovim has an active UI attached (ignore headless/embedded instances)
|
-- Only register if Neovim has an active UI attached (ignore headless/embedded instances)
|
||||||
if #vim.api.nvim_list_uis() > 0 then
|
if #vim.api.nvim_list_uis() > 0 then
|
||||||
local server_name = vim.v.servername
|
local server_name = vim.v.servername
|
||||||
if server_name then
|
if server_name then
|
||||||
-- 1. Legacy Disk Write (Fallback)
|
-- 2. V2 Telemetry Push (HTTP with Knowledge Projection + UDP Fast Mirror)
|
||||||
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 (WebSockets & Cross-OS Sync via Rust Server)
|
|
||||||
local file = vim.api.nvim_buf_get_name(0)
|
local file = vim.api.nvim_buf_get_name(0)
|
||||||
local cursor = vim.api.nvim_win_get_cursor(0)
|
local cursor = vim.api.nvim_win_get_cursor(0)
|
||||||
local row, col = cursor[1], cursor[2]
|
local row, col = cursor[1], cursor[2]
|
||||||
@@ -26,17 +18,121 @@ vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePos
|
|||||||
'{"session_id": "%s", "event": "%s", "file": "%s", "line": %d, "col": %d}',
|
'{"session_id": "%s", "event": "%s", "file": "%s", "line": %d, "col": %d}',
|
||||||
server_name:gsub("\\", "\\\\"), ev.event, file:gsub("\\", "\\\\"), row, col
|
server_name:gsub("\\", "\\\\"), ev.event, file:gsub("\\", "\\\\"), row, col
|
||||||
)
|
)
|
||||||
|
|
||||||
|
local current_buf = ev.buf
|
||||||
vim.fn.jobstart({
|
vim.fn.jobstart({
|
||||||
"curl", "-s", "-X", "POST", "http://127.0.0.1:3000/nvim/telemetry",
|
"curl", "-s", "-X", "POST", "http://127.0.0.1:3000/nvim/telemetry",
|
||||||
"-H", "Content-Type: application/json",
|
"-H", "Content-Type: application/json",
|
||||||
"-d", payload
|
"-d", payload
|
||||||
}, { detach = true })
|
}, {
|
||||||
|
stdout_buffered = true,
|
||||||
|
on_stdout = function(_, data)
|
||||||
|
if data and #data > 0 then
|
||||||
|
local raw = table.concat(data, "")
|
||||||
|
if raw ~= "" then
|
||||||
|
local ok, resp = pcall(vim.fn.json_decode, raw)
|
||||||
|
if ok and resp then
|
||||||
|
vim.schedule(function()
|
||||||
|
if (resp.tech_debts or resp.adrs) and _G.gemini and _G.gemini.project_knowledge then
|
||||||
|
_G.gemini.project_knowledge(resp.tech_debts, resp.adrs, current_buf)
|
||||||
|
end
|
||||||
|
if _G.gemini and _G.gemini.update_statusline then
|
||||||
|
last_active_task = resp.active_task
|
||||||
|
last_active_gate = resp.active_gate
|
||||||
|
_G.gemini.update_statusline(resp.active_task, resp.active_gate)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
local uv = vim.uv or vim.loop
|
||||||
|
local client = uv.new_udp()
|
||||||
|
if client then
|
||||||
|
client:send(payload, "127.0.0.1", 3002, function(err)
|
||||||
|
client:close()
|
||||||
|
end)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
desc = "Register active Neovim instance for Gemini CLI",
|
desc = "Register active Neovim instance for Gemini CLI",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
-- 3. Push-driven Server-Sent Events (SSE) subscriber (Zero Polling)
|
||||||
|
local sse_job_id = nil
|
||||||
|
|
||||||
|
local function start_sse_subscriber()
|
||||||
|
if sse_job_id then
|
||||||
|
pcall(vim.fn.jobstop, sse_job_id)
|
||||||
|
sse_job_id = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
sse_job_id = vim.fn.jobstart({
|
||||||
|
"curl", "-s", "-N", "http://127.0.0.1:3000/api/events/stream"
|
||||||
|
}, {
|
||||||
|
on_stdout = function(_, data)
|
||||||
|
if not data then return end
|
||||||
|
for _, line in ipairs(data) do
|
||||||
|
if line:match("^data:%s*") then
|
||||||
|
local raw_json = line:gsub("^data:%s*", "")
|
||||||
|
local ok, ev = pcall(vim.fn.json_decode, raw_json)
|
||||||
|
if ok and ev and ev.payload then
|
||||||
|
local topic = ev.topic
|
||||||
|
local payload = ev.payload
|
||||||
|
local changed = false
|
||||||
|
|
||||||
|
if topic == "task:event" then
|
||||||
|
if payload.active_task ~= nil then
|
||||||
|
last_active_task = payload.active_task
|
||||||
|
changed = true
|
||||||
|
elseif payload.status == "in_progress" or payload.status == "active" then
|
||||||
|
last_active_task = payload.title or payload.task_id
|
||||||
|
changed = true
|
||||||
|
elseif payload.status == "completed" or payload.status == "expired" or payload.status == "failed" then
|
||||||
|
if last_active_task == (payload.title or payload.task_id) then
|
||||||
|
last_active_task = nil
|
||||||
|
changed = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elseif topic == "gate:event" then
|
||||||
|
if payload.active_gate ~= nil then
|
||||||
|
last_active_gate = payload.active_gate
|
||||||
|
changed = true
|
||||||
|
elseif payload.status then
|
||||||
|
last_active_gate = payload.status
|
||||||
|
changed = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if changed and _G.gemini and _G.gemini.update_statusline then
|
||||||
|
vim.schedule(function()
|
||||||
|
_G.gemini.update_statusline(last_active_task, last_active_gate)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
on_exit = function()
|
||||||
|
sse_job_id = nil
|
||||||
|
-- Reconnect after server restart without busy looping
|
||||||
|
vim.defer_fn(function()
|
||||||
|
if #vim.api.nvim_list_uis() > 0 then
|
||||||
|
start_sse_subscriber()
|
||||||
|
end
|
||||||
|
end, 5000)
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Initialize SSE push listener when UI is active
|
||||||
|
if #vim.api.nvim_list_uis() > 0 then
|
||||||
|
start_sse_subscriber()
|
||||||
|
end
|
||||||
|
|
||||||
-- Load UI & Keymaps
|
-- Load UI & Keymaps
|
||||||
local is_win = vim.fn.has("win32") == 1
|
local is_win = vim.fn.has("win32") == 1
|
||||||
local ui_path = is_win and "C:/Users/reazul.ashraf/workspace/rust/mcp-memory/gemini-ui.lua" or "/mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory/gemini-ui.lua"
|
local ui_path = is_win and "C:/Users/reazul.ashraf/workspace/rust/mcp-memory/gemini-ui.lua" or "/mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory/gemini-ui.lua"
|
||||||
|
|||||||
+161
@@ -94,7 +94,123 @@ function M.prompt_user(prompt_text, options, request_id)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local ns_id = vim.api.nvim_create_namespace("gemini_ghost_text")
|
local ns_id = vim.api.nvim_create_namespace("gemini_ghost_text")
|
||||||
|
local knowledge_ns = vim.api.nvim_create_namespace("gemini_knowledge_projection")
|
||||||
local active_diffs = {}
|
local active_diffs = {}
|
||||||
|
local active_knowledge = {}
|
||||||
|
|
||||||
|
function M.project_knowledge(tech_debts, adrs, bufnr)
|
||||||
|
bufnr = (bufnr == 0 or bufnr == nil) and vim.api.nvim_get_current_buf() or bufnr
|
||||||
|
if not vim.api.nvim_buf_is_valid(bufnr) then return end
|
||||||
|
|
||||||
|
vim.api.nvim_buf_clear_namespace(bufnr, knowledge_ns, 0, -1)
|
||||||
|
active_knowledge[bufnr] = {
|
||||||
|
tech_debts = tech_debts or {},
|
||||||
|
adrs = adrs or {},
|
||||||
|
file = vim.api.nvim_buf_get_name(bufnr)
|
||||||
|
}
|
||||||
|
|
||||||
|
local line_count = vim.api.nvim_buf_line_count(bufnr)
|
||||||
|
|
||||||
|
-- 1. Project Tech Debts
|
||||||
|
if tech_debts and #tech_debts > 0 then
|
||||||
|
for _, debt in ipairs(tech_debts) do
|
||||||
|
local target_line = 0
|
||||||
|
if debt.line_range then
|
||||||
|
local num = tonumber(string.match(debt.line_range, "%d+"))
|
||||||
|
if num and num > 0 then
|
||||||
|
target_line = num - 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
target_line = math.max(0, math.min(target_line, line_count - 1))
|
||||||
|
|
||||||
|
local sev = debt.severity or "debt"
|
||||||
|
local hl = (sev == "critical" or sev == "high") and "DiagnosticError" or "DiagnosticWarn"
|
||||||
|
vim.api.nvim_buf_set_extmark(bufnr, knowledge_ns, target_line, 0, {
|
||||||
|
virt_text = {{" ⚡ [" .. sev:upper() .. " DEBT] " .. debt.description, hl}},
|
||||||
|
virt_text_pos = "eol",
|
||||||
|
hl_mode = "combine",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 2. Project ADRs
|
||||||
|
if adrs and #adrs > 0 then
|
||||||
|
for _, adr in ipairs(adrs) do
|
||||||
|
vim.api.nvim_buf_set_extmark(bufnr, knowledge_ns, 0, 0, {
|
||||||
|
virt_text = {{" 📜 [" .. adr.id .. "] " .. adr.title, "DiagnosticInfo"}},
|
||||||
|
virt_text_pos = "eol",
|
||||||
|
hl_mode = "combine",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.show_knowledge()
|
||||||
|
local bufnr = vim.api.nvim_get_current_buf()
|
||||||
|
local data = active_knowledge[bufnr]
|
||||||
|
if not data or ((#data.tech_debts == 0) and (#data.adrs == 0)) then
|
||||||
|
print("No active Tech Debt or ADRs found for this file.")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local lines = {}
|
||||||
|
table.insert(lines, "# Antigravity Knowledge Graph Projection")
|
||||||
|
table.insert(lines, "File: " .. (data.file or vim.api.nvim_buf_get_name(bufnr)))
|
||||||
|
table.insert(lines, string.rep("=", 50))
|
||||||
|
table.insert(lines, "")
|
||||||
|
|
||||||
|
if #data.tech_debts > 0 then
|
||||||
|
table.insert(lines, "## Active Tech Debts (" .. #data.tech_debts .. ")")
|
||||||
|
for _, d in ipairs(data.tech_debts) do
|
||||||
|
table.insert(lines, string.format("- [%s] (%s): %s", d.id, d.severity or "unspecified", d.description))
|
||||||
|
if d.line_range then
|
||||||
|
table.insert(lines, " Lines: " .. d.line_range)
|
||||||
|
end
|
||||||
|
if d.ideal_solution and d.ideal_solution ~= "" then
|
||||||
|
table.insert(lines, " Ideal Solution: " .. d.ideal_solution)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.insert(lines, "")
|
||||||
|
end
|
||||||
|
|
||||||
|
if #data.adrs > 0 then
|
||||||
|
table.insert(lines, "## Architectural Decision Records (ADRs) (" .. #data.adrs .. ")")
|
||||||
|
for _, a in ipairs(data.adrs) do
|
||||||
|
table.insert(lines, string.format("- [%s] %s (Status: %s)", a.id, a.title, a.status))
|
||||||
|
table.insert(lines, " Decision: " .. a.decision)
|
||||||
|
end
|
||||||
|
table.insert(lines, "")
|
||||||
|
end
|
||||||
|
|
||||||
|
local kbuf = vim.api.nvim_create_buf(false, true)
|
||||||
|
vim.api.nvim_buf_set_lines(kbuf, 0, -1, false, lines)
|
||||||
|
vim.bo[kbuf].filetype = "markdown"
|
||||||
|
|
||||||
|
local width = math.min(math.floor(vim.o.columns * 0.7), 90)
|
||||||
|
local height = math.min(#lines + 2, math.floor(vim.o.lines * 0.6))
|
||||||
|
local row = math.floor((vim.o.lines - height) / 2)
|
||||||
|
local col = math.floor((vim.o.columns - width) / 2)
|
||||||
|
|
||||||
|
local kwin = vim.api.nvim_open_win(kbuf, true, {
|
||||||
|
relative = 'editor',
|
||||||
|
width = width,
|
||||||
|
height = height,
|
||||||
|
row = row,
|
||||||
|
col = col,
|
||||||
|
style = 'minimal',
|
||||||
|
border = 'rounded',
|
||||||
|
title = ' Antigravity Knowledge Projection ',
|
||||||
|
title_pos = 'center'
|
||||||
|
})
|
||||||
|
|
||||||
|
local function close_kwin()
|
||||||
|
if kwin and vim.api.nvim_win_is_valid(kwin) then
|
||||||
|
vim.api.nvim_win_close(kwin, true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
vim.keymap.set('n', 'q', close_kwin, { buffer = kbuf, noremap = true, silent = true })
|
||||||
|
vim.keymap.set('n', '<Esc>', close_kwin, { buffer = kbuf, noremap = true, silent = true })
|
||||||
|
end
|
||||||
|
|
||||||
function M.preview_diff(diff_id, bufnr, start_row, end_row, new_lines)
|
function M.preview_diff(diff_id, bufnr, start_row, end_row, new_lines)
|
||||||
bufnr = (bufnr == 0 or bufnr == nil) and vim.api.nvim_get_current_buf() or bufnr
|
bufnr = (bufnr == 0 or bufnr == nil) and vim.api.nvim_get_current_buf() or bufnr
|
||||||
@@ -195,6 +311,7 @@ function M.setup()
|
|||||||
|
|
||||||
vim.keymap.set('n', '<leader>aa', M.accept_diff, { desc = "Accept Agent Diff", noremap = true, silent = true })
|
vim.keymap.set('n', '<leader>aa', M.accept_diff, { desc = "Accept Agent Diff", noremap = true, silent = true })
|
||||||
vim.keymap.set('n', '<leader>ar', M.reject_diff, { desc = "Reject Agent Diff", noremap = true, silent = true })
|
vim.keymap.set('n', '<leader>ar', M.reject_diff, { desc = "Reject Agent Diff", noremap = true, silent = true })
|
||||||
|
vim.keymap.set('n', '<leader>ak', M.show_knowledge, { desc = "Show Knowledge (Tech Debt & ADRs)", noremap = true, silent = true })
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.send_to_terminal(command)
|
function M.send_to_terminal(command)
|
||||||
@@ -231,4 +348,48 @@ function M.send_to_terminal(command)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Antigravity Statusline Integration (Push-driven / Zero-polling)
|
||||||
|
-- ============================================================================
|
||||||
|
local statusline_cache = {
|
||||||
|
text = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function M.update_statusline(active_task, active_gate)
|
||||||
|
local parts = {}
|
||||||
|
|
||||||
|
if active_task and active_task ~= vim.NIL and active_task ~= "" then
|
||||||
|
local title = tostring(active_task)
|
||||||
|
if #title > 24 then
|
||||||
|
title = title:sub(1, 22) .. ".."
|
||||||
|
end
|
||||||
|
table.insert(parts, "📋 " .. title)
|
||||||
|
end
|
||||||
|
|
||||||
|
if active_gate and active_gate ~= vim.NIL and active_gate ~= "" then
|
||||||
|
local status = tostring(active_gate):lower()
|
||||||
|
if status == "authorized" then
|
||||||
|
table.insert(parts, "🟢 PUSH:OK")
|
||||||
|
elseif status == "blocked" then
|
||||||
|
table.insert(parts, "🔴 PUSH:BLOCKED")
|
||||||
|
else
|
||||||
|
table.insert(parts, "🟡 PUSH:WAIT")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if #parts > 0 then
|
||||||
|
statusline_cache.text = " [AGY: " .. table.concat(parts, " | ") .. "] "
|
||||||
|
else
|
||||||
|
statusline_cache.text = ""
|
||||||
|
end
|
||||||
|
|
||||||
|
vim.schedule(function()
|
||||||
|
pcall(vim.cmd, "redrawstatus")
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.statusline()
|
||||||
|
return statusline_cache.text
|
||||||
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
+285
-96
@@ -1,122 +1,311 @@
|
|||||||
# Memory MCP Strategic Guidelines
|
# Memory MCP Strategic Guidelines
|
||||||
|
|
||||||
This document outlines the STRATEGY and SEMANTICS for using the MCP Memory Server.
|
This document outlines the STRATEGY, SEMANTICS, RESOURCE SCHEMAS, and CASING STANDARDS for using the MCP Memory Server.
|
||||||
You do not need to memorize JSON schemas for these tools; they are strictly defined and typed in the ools/list endpoint. Focus purely on WHEN and WHY to use them.
|
You do not need to memorize individual JSON schemas for every tool; they are strictly defined and typed in the `tools/list` endpoint. Focus on WHEN, WHY, and HOW to leverage them effectively.
|
||||||
|
|
||||||
## 7. Snippet & Command Vault
|
---
|
||||||
- **Tools:** `store_snippet`, `search_snippets`, `delete_snippet`
|
|
||||||
- **When to use:** Use the Vault to store exactly multi-line code snippets, Nushell pipelines, or complex commands that the user relies on frequently.
|
|
||||||
- **Behavior:** The vault guarantees precise syntactic preservation of the script (unlike Graph observations). Search snippets when trying to recall an exact query or pipeline.
|
|
||||||
|
|
||||||
## 8. Context Namespaces (Project Scopes)
|
## 1. Casing & Naming Standards (CRITICAL)
|
||||||
- **Feature:** `namespace` optional parameter
|
|
||||||
- **When to use:** When using `read_graph`, `search_nodes`, or `visualize_graph`, you can now pass `namespace` to isolate graph queries to a specific project scope (e.g. "scascanner").
|
|
||||||
- **Behavior:** When calling `create_entities` or `create_relations`, you can inject `namespace: "your_project"` into the entity schema to isolate it from the global scope.
|
|
||||||
|
|
||||||
## 9. Architectural Decision Records (ADRs)
|
To prevent graph fragmentation and ensure seamless LLM context retrieval:
|
||||||
- **Tools:** log_decision, query_decisions
|
* **Entity Types (`entity_type`)**: MUST ALWAYS be **`PascalCase`** (e.g. `DatabaseTable`, `McpTool`, `ArchitectureComponent`, `File`, `DataStructure`).
|
||||||
- **When to use:** Use this whenever you make a non-trivial architectural, environmental, or tooling decision (e.g. choosing a specific framework, a specific deployment flag, bypassing a rule with a workaround).
|
* **Relation Types (`relation_type`)**: MUST ALWAYS be **`snake_case`** (e.g. `depends_on`, `calls`, `implements`, `uses`, `contains`).
|
||||||
- **Behavior:** This permanently stores the context, decision, and consequence of *why* something is done the way it is, preventing future agents from second-guessing or reverting it.
|
* **Field Keys & Properties**: MUST ALWAYS be **`snake_case`** (e.g. `file_path`, `git_commit`, `created_at`).
|
||||||
|
* **Relation Tool Parameters**: `create_relations` supports Serde field aliases (`source` -> `from`, `target` -> `to`, `relationType`/`type` -> `relation_type`) so LLM tool calls succeed seamlessly regardless of parameter naming.
|
||||||
|
|
||||||
## 10. Graph Refactoring & Algorithms
|
> [!NOTE]
|
||||||
- **Tools:** merge_entities, find_orphans, query_graph_path
|
> The server automatically enforces and migrates incoming entity and relation types to these canonical casing rules on every read and write operation.
|
||||||
- **When to use:** Run `find_orphans` periodically or when you notice graph clutter to safely delete unused nodes. Use `merge_entities` when you notice duplicated semantic concepts (e.g. API_Gateway vs APIGateway). Use `query_graph_path` when you need to understand how two completely different architectural components are related (e.g. "How does the Frontend connect to the Database?").
|
> Store operations enforce atomic lock scope for serialization/deserialization and enter Quarantine Mode upon database corruption to prevent data overwrite hazards.
|
||||||
- **Behavior:** `merge_entities` will safely combine their observations and automatically remap all relations pointing to or from the deleted duplicate. `query_graph_path` executes a breadth-first search to find the shortest relational path between nodes.
|
|
||||||
|
|
||||||
## 11. Dynamic Learned Preferences
|
---
|
||||||
- **Tools:** learn_preference,
|
|
||||||
ead_preferences
|
|
||||||
- **When to use:** When the user corrects you on a specific local nuance (e.g. "Actually, use Python 3.10 instead of 3.12 for this repo").
|
|
||||||
- **Behavior:** Use this Key-Value store to record dynamic behavioral preferences to ensure you adapt instantly without modifying global Markdown files.
|
|
||||||
|
|
||||||
## Error Vault / Troubleshooting
|
## 2. The Two-Tier Context Paradigm
|
||||||
When you spend time resolving a tricky environment issue, build error, or logic bug, immediately record the fix to save future time.
|
|
||||||
- **log_error_fix:** Provide the signature (the exact error string or stack trace snippet) and the solution.
|
|
||||||
- **search_error_fixes:** When encountering a weird bug, query this vault first before debugging from scratch.
|
|
||||||
|
|
||||||
## Pinned Workspaces (Hot Files)
|
> [!IMPORTANT]
|
||||||
To maintain focus on the active "working set" of files in large repositories, use pins.
|
> * **Tier 1 (Static Markdown)**: Repository rules, constraints, architectural patterns, and developer preferences are maintained directly in static git-tracked files (`agent-rules/*.md`, `instructions.md`) and system prompts. This guarantees 0ms turn-0 availability without relying on proactive agent tool retrieval.
|
||||||
- **pin_file / unpin_file:** Pin the 3-5 files you are actively modifying to the current
|
> * **Tier 2 (Telemetry & Ephemeral DB)**: High-frequency operational history—the Code Change Ledger (`audit_ledger`), terminal command history, compiler error fixes (`log_error_fix`), active tasks, and preflight context—is handled by the MCP Memory Server and surfaced via the Brain Monitor Web UI and MCP resources.
|
||||||
amespace (project).
|
|
||||||
- **list_pinned_files:** When starting a new session or returning to a project, always list pinned files first to instantly regain context on what was being worked on.
|
|
||||||
|
|
||||||
## Rolling Session Summaries (Project Timeline)
|
---
|
||||||
To maintain a chronological narrative of the project's evolution beyond just code diffs.
|
|
||||||
- **add_session_summary:** At the end of every major coding session, write a 2-sentence summary of what was accomplished and add it to the
|
|
||||||
amespace.
|
|
||||||
- **get_project_timeline:** When rejoining a project after a long time, read the timeline to instantly understand the recent architectural history.
|
|
||||||
|
|
||||||
## Agent Handoffs (The Inbox)
|
## 3. Passive MCP Resources (`resources/list`)
|
||||||
When working in a multi-session or multi-agent environment, agents need to communicate context across time.
|
|
||||||
- **leave_handoff_memo:** Leave a quick message describing current progress, roadblocks, or the literal next step to take.
|
|
||||||
- **read_handoff_memos:** ALWAYS check for memos when waking up in a new project namespace.
|
|
||||||
- **clear_handoff_memos:** Clear the memo after you have successfully read it and absorbed its context.
|
|
||||||
|
|
||||||
## Environment & Blueprint Tracker
|
The server exposes 9 real-time, read-only MCP resources. Agents should read these resources directly to regain context without incurring tool call latency:
|
||||||
Stop wasting tokens rediscovering how to run or configure the project.
|
|
||||||
- **update_env_fingerprint:** Run this when you set up a new project to snapshot the OS, shell, and key language versions (e.g. python,
|
|
||||||
ustc).
|
|
||||||
- **read_env_fingerprint:** Query this to instantly know how the project is run.
|
|
||||||
- **log_env_requirement:** Log required .env variables (e.g. DATABASE_URL) without logging the secret itself.
|
|
||||||
|
|
||||||
## Milestones (Epics)
|
| Resource URI | Resource Name | Description & Usage |
|
||||||
Organize granular tasks into high-level phases.
|
|:---|:---|:---|
|
||||||
- **add_milestone:** Group a large subset of work into a cohesive phase (e.g., "V1 MVP", "CI/CD Setup").
|
| `memory://graph/entities` | Graph Entities | All nodes and entities in the knowledge graph (top 100 with pagination guidance). |
|
||||||
- **update_milestone:** Mark a milestone as active, blocked, or completed.
|
| `memory://graph/relations` | Graph Relations | All relationships between graph entities (top 200 with subgraph guidance). |
|
||||||
- **list_milestones:** Use this to ensure task priorities align with the current active milestone.
|
| `memory://tasks/active` | Active Tasks | Current active tasks with status, priority, and assigned subagents. |
|
||||||
|
| `memory://decisions/active` | Active ADR Decisions | Architectural decisions currently in `accepted` status. |
|
||||||
|
| `memory://tech_debt/unresolved` | Unresolved Tech Debt | All open engineering debt items requiring future refactoring. |
|
||||||
|
| `memory://session/delta` | Session Delta | Code modifications, commits, active tasks, and notes created in the last 2 hours. |
|
||||||
|
| `memory://terminal/recent` | Terminal History | Recent terminal commands, interpreters (`pwsh`, `bash`, `nu`), working dirs, and exit codes. |
|
||||||
|
| `memory://activity/recent` | Recent Activity | Real-time IDE and developer activity event stream. |
|
||||||
|
| `memory://milestones` | Milestones | Project milestones, deliverables, target dates, and progress. |
|
||||||
|
|
||||||
## Standup Reports
|
---
|
||||||
When asked for a progress update or standup report, do not guess or read raw git logs.
|
|
||||||
- **generate_standup_report:** Generates a structured JSON containing all asks updated, code_changes logged, and session_summaries added within the last N hours. Format this cleanly as a markdown report for the user.
|
|
||||||
|
|
||||||
## Infrastructure & Environment Registry
|
## 4. MCP Workflow Prompts (`prompts/list`)
|
||||||
Avoid asking the user for URLs or connection details repeatedly.
|
|
||||||
- **register_environment:** Save connection details for Dev, QA, Staging, or Prod environments.
|
|
||||||
- **get_environment_details:** Query this to know how to connect to databases, APIs, or VPNs.
|
|
||||||
|
|
||||||
## Pre-Push / PR Quality Checklists
|
The server registers 5 high-signal workflow prompts to initiate standardized agent routines:
|
||||||
Ensure high code quality and prevent incomplete pull requests.
|
|
||||||
- **add_pr_checklist_item:** Add recurring repository chores (e.g., "Run cargo fmt", "Update CHANGELOG").
|
|
||||||
- **get_pr_checklist:** Query and explicitly verify EVERY item on this list before triggering git push or merging PRs.
|
|
||||||
- **clear_pr_checklist:** Clear if the project lifecycle changes dramatically.
|
|
||||||
|
|
||||||
## Tech Debt & Refactor Backlog
|
1. **`context_warmup`**: Executed at session startup. Prompts the agent to read `memory://tasks/active` and `memory://session/delta`, inspect the workspace worktree, and assemble immediate working context.
|
||||||
Keep the main task board clean by isolating "hacky" workarounds.
|
2. **`analyze_tech_debt`**: Prompts the agent to review unresolved technical debt from `memory://tech_debt/unresolved` and generate a prioritized remediation plan.
|
||||||
- **log_tech_debt:** Record why a shortcut was taken and what the ideal solution should be.
|
3. **`summarize_architecture`**: Synthesizes active ADRs from `memory://decisions/active` and graph entities from `memory://graph/entities` into an architectural overview.
|
||||||
- **resolve_tech_debt:** Mark a debt as paid off once refactored.
|
4. **`handoff_routine`**: Triggers the `DevOpsSRE` subagent at session end to generate a standup report, audit active tasks, and record a handoff memo for future sessions.
|
||||||
- **list_tech_debt:** Query this before starting a refactoring session.
|
5. **`archive_routine`**: Compresses historical session summaries into a dense milestone retrospective entity and purges pruned entries.
|
||||||
|
|
||||||
## Context Workspaces
|
---
|
||||||
When shifting context rapidly (e.g. from building a feature to fixing a prod bug), save your state.
|
|
||||||
- **save_context_workspace:** Save your active pinned files and task IDs under a named workspace (e.g., "Feature X").
|
|
||||||
- **load_context_workspace:** Retrieve a saved workspace to instantly restore your context when returning to that task.
|
|
||||||
- **list_context_workspaces:** List all saved workspaces in a project.
|
|
||||||
|
|
||||||
## Omni-Search (Hybrid Vector Search)
|
## 5. Consolidated Smart Tools Architecture (11 Primary Tools)
|
||||||
When you remember a vague concept, keyword, or architectural pattern but don't know which specific vault it's stored in.
|
|
||||||
- **omni_search:** Searches across the Knowledge Graph, Tasks, Snippets, ADRs, Tech Debt, Memos, and Error Fixes simultaneously.
|
|
||||||
- **Semantic Superpower:** This tool utilizes **Hybrid Vector Search** (BM25 + Vector Embeddings). You do not need exact keyword matches. You can query conceptually (e.g., "database lock issues" or "how is auth handled") and the engine will return semantically relevant context.
|
|
||||||
|
|
||||||
## Terminal History & Telemetry
|
The server consolidates granular single-purpose tools into domain-named smart tools.
|
||||||
To maintain an audit trail of shell commands and execution contexts.
|
|
||||||
- **Context:** The server actively tracks Terminal History. Commands executed in the user's shell are logged to the memory server and visible in the dashboard UI. While you don't call an MCP tool for this directly, be aware that complex shell actions and their outcomes are persisted for historical auditing and UI visibility.
|
|
||||||
|
|
||||||
## Project Health Dashboard
|
* **`tasks`**: Complete task lifecycle management.
|
||||||
When starting a new session, get a numerical aggregate of the project's current state.
|
- `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`).
|
||||||
- **get_project_health:** Returns a quick digest of active tasks, unread memos, unresolved tech debt, and pending PR checklist items.
|
- `action: "update"`: Update task status (requires `id`, `status: "pending" | "completed" | "cancelled"`).
|
||||||
|
- `action: "delete"`: Delete task and child tasks (requires `id`).
|
||||||
|
- `action: "list"`: List active tasks (optional `git_branch`, `summary_level: "compact" | "detailed" | "full"`, `max_tokens`).
|
||||||
|
- `action: "set_criteria"`: Set acceptance criteria (requires `id`, `criteria: Vec<String>`).
|
||||||
|
- `action: "verify"`: Verify criteria met (requires `id`, optional `proof`).
|
||||||
|
|
||||||
## Git Context Binding (VCS Sync)
|
* **`milestones`**: Milestone tracking.
|
||||||
To maintain absolute traceability, we link memory items directly to the exact git commits they occurred on.
|
- `action: "add"`: Create milestone (requires `title`, optional `namespace`, `target_date`, `description`, `deliverables: Vec<String>`, `repo_name`).
|
||||||
- **Fully Automated (git2):** You no longer need to manually execute `git rev-parse HEAD` or pass `git_branch` / `git_commit` arguments.
|
- `action: "update"`: Update milestone status (requires `id`, `status: "active" | "completed" | "cancelled"`).
|
||||||
- When you call tools like **log_code_change**, **log_error_fix**, or **log_tech_debt**, the Rust server uses the native `git2` crate to automatically discover the repository context of the active working directory, extract the current HEAD commit hash, message, and branch, and permanently bind them to the memory item in the background.
|
- `action: "list"`: List milestones (optional `namespace`).
|
||||||
|
|
||||||
|
* **`handoff_memos`**: Session handoff notes for future agents.
|
||||||
|
- `action: "leave"`: Leave a memo (requires `content`, optional `vcs_revision`, `repo_name`, `git_branch`, `blockers: Vec<String>`, `action_items: Vec<String>`, `expires_at`).
|
||||||
|
- `action: "read"`: Read active handoff memos.
|
||||||
|
- `action: "clear"`: Clear memos.
|
||||||
|
|
||||||
## 15. Neovim Integration & God Mode
|
* **`snippets`**: Reusable code snippet vault.
|
||||||
The project contains two MCP binaries (win-nvim and linux-nvim) that bridge JSON-RPC over stdio directly to the active Neovim instance.
|
- `action: "store"`: Store snippet (requires `query` as name, optional `language`, `code`, `description`, `tags`, `origin_file`, `line_range`, `repo_name`).
|
||||||
- **Interactive UI (UDP):** The active editor connects to the backend via UDP (port 3002). The UI script (`gemini-ui.lua`) supports non-destructive **Ghost Text Diff Reviews** and native `vim.ui` prompts without spawning subprocesses. You can trigger these interactively using the backend server.
|
- `action: "search"`: Search snippet vault (optional `query`, `tags`, `hybrid: true`).
|
||||||
- **Tools:** These binaries expose basic tools (`nvim_get_cursor`, `nvim_get_active_buffer`, `nvim_list_buffers`, etc.).
|
- `action: "delete"`: Delete snippet (requires `id`).
|
||||||
- **God Mode**: They also expose `nvim_execute_lua`. This is the ultimate fallback tool. If you need to access *any* Neovim API that does not have a dedicated Rust tool (e.g., getting LSP diagnostics, evaluating a visual selection block based on modes, setting registers), you MUST write a short Lua script and pass it to `nvim_execute_lua`. Do not attempt to recompile the Rust server to add new basic tools; use the Lua escape hatch dynamically.
|
- `action: "tag"`: Attach classification tags (requires `id`, `tags: Vec<String>`).
|
||||||
|
|
||||||
## 16. Tool Schema Discovery (Lazy Loading)
|
* **`decisions`**: Architectural Decision Records (ADRs).
|
||||||
Antigravity automatically caches all MCP tool schemas to your disk to save tokens. Do **NOT** grep or search the Rust source code to find tool schemas or arguments. To understand a tool`s arguments, directly read `~/.gemini/antigravity-cli/mcp/<server_name>/<tool_name>.json`. Do NOT guess arguments. ALWAYS read the schema if you are unfamiliar with a tool to prevent invalid argument errors.
|
- `action: "log"`: Log ADR (requires `title`, optional `status: "accepted" | "proposed" | "deprecated" | "superseded" | "implemented"`, `context`, `decision`, `consequence`, `author`, `affected_components: Vec<String>`, `alternatives_considered: Vec<String>`, `supersedes`, `repo_name`).
|
||||||
|
- `action: "update"`: Update ADR status and metadata (requires `id`, optional `status`, `git_commit`, `git_branch`).
|
||||||
|
- `action: "query"`: Query ADRs (optional `query`).
|
||||||
|
- `action: "delete"`: Delete ADR (requires `id`).
|
||||||
|
|
||||||
|
* **`tech_debt`**: Engineering debt backlog.
|
||||||
|
- `action: "log"`: Log debt item (requires `description`, optional `ideal_solution`, `file_path`, `line_range`, `workaround`, `effort_estimate`, `severity: "low" | "medium" | "high" | "critical"`, `git_commit`, `git_branch`, `symbol_references`, `repo_name`).
|
||||||
|
- `action: "resolve"`: Resolve debt item (requires `id`).
|
||||||
|
- `action: "list"`: List debt items (optional `include_resolved`).
|
||||||
|
|
||||||
|
* **`environment`**: Infrastructure and requirements tracking.
|
||||||
|
- `action: "update_fingerprint"`: Update tool versions (requires `tool_versions: Map<String, String>`, optional `repo_name`).
|
||||||
|
- `action: "read_fingerprint"`: Read tool versions fingerprint.
|
||||||
|
- `action: "log_requirement"`: Log environment variable requirement (requires `key`, `description`, `is_secret`, optional `default_value`, `validation_regex`, `repo_name`).
|
||||||
|
- `action: "register"`: Register target environment (requires `name`, `url`, optional `description`, `requires_vpn`, `env_type: "dev" | "staging" | "qa" | "prod"`, `healthcheck_endpoint`, `ssh_host`, `repo_name`).
|
||||||
|
- `action: "get_details"`: Read full environment details.
|
||||||
|
|
||||||
|
* **`clipboard`**: OS Clipboard management.
|
||||||
|
- `action: "read"`: Read OS clipboard.
|
||||||
|
- `action: "write"`: Write text/html/files/image to clipboard.
|
||||||
|
|
||||||
|
* **`hypotheses`**: Diagnostic hypothesis memory.
|
||||||
|
- `action: "log"`: Record diagnostic hypothesis (requires `hypothesis`, optional `status: "open" | "verified" | "disproven"`, `evidence: Vec<String>`, `test_command`, `git_branch`, `repo_name`).
|
||||||
|
- `action: "query"`: Query hypotheses (optional `status`, `git_branch`, `repo_name`).
|
||||||
|
|
||||||
|
* **`agent_signals`**: Inter-agent signal bus.
|
||||||
|
- `action: "broadcast"`: Broadcast signal to other agents (requires `signal_type`, `payload`, optional `target_agent`, `ttl_seconds`).
|
||||||
|
- `action: "query"`: Query active signals (optional `signal_type`, `include_expired: bool`).
|
||||||
|
|
||||||
|
* **`process_logs`**: Process and daemon log management.
|
||||||
|
- `action: "watch"`: Register or update log file watcher (requires `label`, `file_path`, optional `description`).
|
||||||
|
- `action: "get"`: Retrieve recent log lines from watched process log (requires `label`, optional `tail_lines: usize`).
|
||||||
|
- `action: "clear"`: Clear or truncate watched process log file (requires `label`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Complete Tool Catalog (All 53 Tools)
|
||||||
|
|
||||||
|
The server exposes 53 specialized and smart MCP tools organized into 7 functional domains:
|
||||||
|
|
||||||
|
### 1. Knowledge Graph Core (18 Tools)
|
||||||
|
1. `create_entities`: Batch-create entities with `name`, `entity_type`, and `observations`.
|
||||||
|
2. `create_relations`: Batch-create relationships (`from`, `to`, `relation_type`).
|
||||||
|
3. `add_observations`: Append observations to existing entities.
|
||||||
|
4. `delete_entities`: Delete entity nodes and cascading relations.
|
||||||
|
5. `delete_relations`: Delete specific relation edges between entities.
|
||||||
|
6. `delete_observations`: Remove specific observations from an entity.
|
||||||
|
7. `read_graph`: Return full or namespace-filtered knowledge graph.
|
||||||
|
8. `search_nodes`: Search entity names and observations using Tantivy BM25.
|
||||||
|
9. `open_nodes`: Inspect full details of specified entity nodes by name.
|
||||||
|
10. `visualize_graph`: Generate Mermaid markdown or SVG diagram of the graph.
|
||||||
|
11. `condense_entity`: Summarize entity observations into dense summaries.
|
||||||
|
12. `merge_entities`: Merge source entity into target entity, re-pointing relations and pruning self-loops.
|
||||||
|
13. `find_orphans`: Detect entities with zero relationships for pruning.
|
||||||
|
14. `get_subgraph`: BFS graph traversal expanding $N$ hops from a root node.
|
||||||
|
15. `sweep_graph_health`: Audit graph for orphans, calculate name similarity, and recommend merges.
|
||||||
|
16. `resolve_stale_symbols`: Cross-reference graph symbols against the workspace AST to remove deleted code nodes.
|
||||||
|
17. `summarize_subgraph`: LLM-ready concise synthesis of a localized subgraph.
|
||||||
|
18. `query_graph_path`: BFS shortest path between two entities in the knowledge graph.
|
||||||
|
|
||||||
|
### 2. Task & Milestone Operations (2 Tools)
|
||||||
|
19. `tasks`: Consolidated task board manager (`add`, `update`, `delete`, `list`, `set_criteria`, `verify`).
|
||||||
|
20. `milestones`: Milestone lifecycle management (`add`, `update`, `list`).
|
||||||
|
|
||||||
|
### 3. Notes, Handoffs & Reporting (4 Tools)
|
||||||
|
21. `handoff_memos`: Cross-session scratchpad and handoff memos (`leave`, `read`, `clear`).
|
||||||
|
22. `add_session_summary`: Record session summary notes and highlights.
|
||||||
|
23. `generate_standup_report`: Synthesize tasks, ledger changes, and session summaries into a standup report.
|
||||||
|
24. `promote_to_entity`: Promote an ephemeral note or memo into a permanent knowledge graph entity.
|
||||||
|
|
||||||
|
### 4. Meta, Audit & Intelligence (15 Tools)
|
||||||
|
25. `decisions`: Consolidated Architectural Decision Records (ADRs) manager (`log`, `update`, `query`, `delete`).
|
||||||
|
26. `tech_debt`: Consolidated technical debt backlog manager (`log`, `resolve`, `list`).
|
||||||
|
27. `log_error_fix`: Record an error resolution with stack trace, root cause, and git commit.
|
||||||
|
28. `search_error_fixes`: Embedding-based and keyword search over past error resolutions.
|
||||||
|
29. `log_code_change`: Record a file modification in the VCS-agnostic audit ledger.
|
||||||
|
30. `query_recent_changes`: Retrieve recent code changes with lookback time filters.
|
||||||
|
31. `omni_search`: Reciprocal Rank Fusion (RRF) search across all graph entities, snippets, ADRs, debt, and fixes.
|
||||||
|
32. `get_project_health`: Health dashboard summarizing task completion, debt backlog, and graph consistency.
|
||||||
|
33. `manage_checkpoint`: Create or restore named memory snapshots for safe rollback.
|
||||||
|
34. `query_lineage`: Causal lineage tracker linking tasks, ADRs, commits, and error fixes.
|
||||||
|
35. `get_next_actionable_tasks`: Topologically resolved list of unblocked tasks ready for execution.
|
||||||
|
36. `hypotheses`: Structured diagnostic hypothesis tracker (`log`, `query`).
|
||||||
|
37. `get_preflight_context`: Aggregated operational context at session start (tasks, debt, recent changes).
|
||||||
|
38. `agent_signals`: Inter-agent signal bus (`broadcast`, `query`).
|
||||||
|
39. `auto_session_checkpoint`: Automatic session boundary checkpointing.
|
||||||
|
|
||||||
|
### 5. System, Environment & Telemetry (4 Tools)
|
||||||
|
40. `environment`: Tool fingerprinting, requirements, and environment registry (`update_fingerprint`, `read_fingerprint`, `log_requirement`, `register`, `get_details`).
|
||||||
|
41. `snippets`: Reusable code snippet vault with hybrid search (`store`, `search`, `delete`, `tag`).
|
||||||
|
42. `clipboard`: Pure native Rust OS clipboard interface (`read`, `write`).
|
||||||
|
43. `process_logs`: Live process and daemon log watcher and tailer (`watch`, `get`, `clear`).
|
||||||
|
|
||||||
|
### 6. Git & Worktree Context (2 Tools)
|
||||||
|
44. `get_active_worktree_context`: Inspect git status, modified files, diff summary, and current branch.
|
||||||
|
45. `query_git_diffs`: Retrieve detailed git diffs for specific files or commit ranges.
|
||||||
|
|
||||||
|
### 7. AST & Code Intelligence (8 Tools)
|
||||||
|
46. `read_file_skeleton`: Tree-sitter AST structural outline of functions, structs, and methods without implementation bodies.
|
||||||
|
47. `replace_ast_node`: Precise AST node replacement preserving indentation and comments.
|
||||||
|
48. `find_symbol_references`: Search for symbol references across snippets and disk source files.
|
||||||
|
49. `get_callers`: Find call sites and callers of a specified function or method across the codebase.
|
||||||
|
50. `analyze_impact`: Blast-radius impact analysis of modifying a symbol or file.
|
||||||
|
51. `read_directory_architecture`: Recursive directory structure analysis capped at depth 10.
|
||||||
|
52. `semantic_code_search`: Dense vector semantic code search over indexed source code.
|
||||||
|
53. `manage_subagent_namespace`: Create, isolate, or merge subagent-scoped memory namespaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. VCS & SVN Agnosticism & Multi-Repo Provenance
|
||||||
|
|
||||||
|
To support diverse enterprise repositories (Git, Subversion / SVN, Mercurial / Hg, Monorepos):
|
||||||
|
* **`vcs_type`**: Designates the VCS engine (`"git"`, `"svn"`, `"hg"`, `"perforce"`, or `"none"`).
|
||||||
|
* **`vcs_revision`**: Agnostic commit hash or SVN revision identifier (e.g., `"r12458"`, `"3e4f7a9"`).
|
||||||
|
* **`upstream_url`**: Canonical remote repository URL (e.g. `https://svn.corp/repo/trunk`, `git@bitbucket.org:org/repo.git`).
|
||||||
|
* **`repo_name`**: Logical project identifier allowing multiple repositories to share or partition memory namespaces cleanly without collision.
|
||||||
|
* **Audit Ledger (`log_code_change`)**: Enriched with `vcs_type`, `vcs_revision`, `upstream_url`, `author`, `diff_summary`, and extensible `metadata: HashMap<String, String>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Terminal & Process Telemetry
|
||||||
|
|
||||||
|
The server ingests and tracks active terminal commands and sessions:
|
||||||
|
* **Active Terminals**: Tracks PIDs, shell interpreters (`pwsh`, `bash`, `nu`, `zsh`), current working directories (`cwd`), command exit codes, and timestamps.
|
||||||
|
* **Terminal History Endpoint & Resource**: `/terminal/history` and `memory://terminal/recent` expose recent shell commands and output streams to dashboard and LLMs to prevent lost shell context.
|
||||||
|
* **Zero-Latency UDP Streams**: Terminal and IDE telemetry stream over UDP (`MCP_UDP_PORT1`, `MCP_UDP_PORT2`) with zero disk I/O bottlenecks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Automated Error Fix Auto-Matcher
|
||||||
|
- **Tools:** `log_error_fix`, `search_error_fixes` (and alias `suggest_error_fix`)
|
||||||
|
- **When to use:** When encountering a build error, test failure, or stack trace. Call `search_error_fixes` with either a text `query` or `stack_trace` before attempting a fix from scratch.
|
||||||
|
- **Behavior:** Computes cosine similarity between error trace embeddings and past resolution logs when `stack_trace` is provided, or keyword filtering when `query` is provided, returning top matched solutions, modified files, and git commits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Memory State Checkpointing & Rollbacks
|
||||||
|
- **Tool:** `manage_checkpoint` (action: `"create"` | `"restore"`)
|
||||||
|
- **When to use:** Before initiating a large refactor, running experimental subagent tasks, or executing destructive batch operations.
|
||||||
|
- **Behavior:** Saves or restores a point-in-time snapshot of graph entities, active tasks, and tech debt backlogs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Self-Healing Graph Health Sweeper
|
||||||
|
- **Tool:** `sweep_graph_health`
|
||||||
|
- **When to use:** Periodically or before committing major graph changes to audit entity consistency.
|
||||||
|
- **Behavior:** Detects orphaned nodes (0 relations), computes name similarity using pre-computed lowercase keys to identify near-duplicates (e.g., `APIGateway` vs `ApiGateway`), and provides structured `merge_entities` recommendations or auto-prunes orphans.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Causal Lineage & Provenance Tracker
|
||||||
|
- **Tool:** `query_lineage`
|
||||||
|
- **When to use:** When asking *"Why was this component modified?"* or *"What task or ADR led to this code change?"*
|
||||||
|
- **Behavior:** Searches across tasks, ADRs, audit ledger entries, and error fixes to assemble a unified chronological timeline explaining the provenance behind any file, symbol, or commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. ADR Lifecycle & Automated Git Post-Commit Reconciliation
|
||||||
|
|
||||||
|
- **The Golden ADR Rule**: When code implementing an ADR is committed, you MUST IMMEDIATELY update the ADR status to `implemented`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "update",
|
||||||
|
"id": "ADR-XXXX",
|
||||||
|
"status": "implemented",
|
||||||
|
"git_commit": "<commit_hash>",
|
||||||
|
"git_branch": "<branch>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Automated Post-Commit Hook**: The repository provides an automated reconciliation script (`scripts/git-reconcile.py`) installed via `just install-git-hooks`. Upon every `git commit`, the hook scans the commit message for `ADR-XXXX` or task identifiers and reconciles their status in the persistent store.
|
||||||
|
- **Current Architecture Status**: 100% of defined ADRs (ADR-0080 through ADR-0104) are fully implemented and reconciled in the store.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Native Rust Invariants & Subprocess Prohibition (CRITICAL)
|
||||||
|
|
||||||
|
To maintain maximum security, speed, and cross-platform reliability:
|
||||||
|
* **Zero Subprocess Fallbacks**: System and server tools (`clipboard`, `ast`, `search`, `db`) MUST strictly use pure native Rust crates (`arboard`, `tree-sitter`, `tantivy`, `psycopg`). Invocations of external shell commands (`powershell.exe`, `wl-paste`, `xclip`, `cmd.exe`) are strictly prohibited in native handlers.
|
||||||
|
* **Transient Lock Recovery**: Transient OS handle collisions (such as Win32 OLE `OpenClipboard` lock contention) must be handled using native retry loops with backoffs directly in Rust.
|
||||||
|
* **Automated Static Regression Gates**: Automated AST/source audit tests (`test_no_subprocess_clipboard_regression`) verify at test time that forbidden subprocess patterns are absent from handler implementations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. High-Performance Concurrency & Resilience Guarantees
|
||||||
|
|
||||||
|
* **Explicit Fail-Fast Persistence Safety**: Replaced silent fallback to temporary databases (`/tmp/mcp_store_fallback_*`) with an explicit open retry and fail-fast panic unless `MCP_ALLOW_TMP_FALLBACK=1` is explicitly set, preventing silent data loss.
|
||||||
|
* **Async Mutex Deadlock Elimination**: Converted shared state and Neovim connection locks (`shutdown_tx`, `NVIM_CONN`, `ACTIVE_SOCKET`, `HEADLESS_PROC`) to `tokio::sync::Mutex` to prevent worker thread pool starvation across `.await` points.
|
||||||
|
* **Telemetry Session Deduplication & Channel Pruning**: Added `LAST_SESSION` in-memory state deduplication for UDP telemetry writes (eliminating disk I/O thrashing) and distinguished WebSocket `TrySendError::Full` backpressure vs `TrySendError::Closed` client pruning.
|
||||||
|
* **Graph Adjacency Indexing**: Leverages `KnowledgeGraph::build_adjacency_map` to build $O(1)$ lookup adjacency lists for fast BFS shortest path graph queries.
|
||||||
|
* **Atomic Store Write Lock Minimization**: `Store::modify` and `Store::modify_async` release write guards immediately after in-memory state mutations, serializing JSON payloads under read locks to allow non-blocking concurrent readers.
|
||||||
|
* **Async Commit Index Reader Auto-Reload**: `MemoryIndex::commit()` automatically triggers `reader.reload()` upon completing background commits, guaranteeing immediate visibility of newly indexed document terms.
|
||||||
|
* **Zero-Allocation NDJSON Reader**: `mcp-stdio` reclaims line buffers via `std::mem::take` and in-place trimming to eliminate heap allocations during high-frequency NDJSON message parsing.
|
||||||
|
* **Dynamic Character Micro-Batched Fastembed Inference**: `generate_embeddings_async` dynamically batches text arrays using a 16,000 character budget ceiling inside `spawn_blocking`, preventing heap spikes during vector indexing while keeping ONNX SIMD execution saturated.
|
||||||
|
* **Bounded Telemetry Detail Records**: Activity and terminal telemetry buffers enforce a 4,000 character truncation ceiling on log details (`ActivityRecord`, `TerminalHistory`) to prevent unbounded RAM growth under high RPC throughput.
|
||||||
|
* **Zero-Allocation Stream Formatting**: Graph condensation loops (`condense_graph_worker`) format node/relation subgraphs into stream buffers via `std::fmt::Write`, eliminating intermediate String allocation overhead.
|
||||||
|
* **Single-Handle OS Clipboard Retries & Image Downsampling**: `WriteClipboardHandler` initializes OS clipboard handles once per operation and downsamples images exceeding $2048 \times 2048$ resolution before writing raw RGBA bytes.
|
||||||
|
* **Zero Transaction Drop Persistence Guarantee**: `Store::modify` automatically spawns an async task to execute `push_async` with channel backpressure if `push` encounters queue saturation, ensuring zero data loss under spike write loads.
|
||||||
|
* **Token-Budgeted Query Projections**: Decision queries (`query_decisions`) support `limit` caps and compact `include_body: false` projections for token budget optimization.
|
||||||
|
* **Serde Parameter & Enum Ergonomics**: Action enums (`SnippetSearchMode`, `Relation`) support case-insensitive variants and common synonyms (`create`/`add`, `remove`/`delete`, `list`/`read`, `source`/`from`, `target`/`to`, `relationType`/`relation_type`) ensuring seamless LLM tool execution.
|
||||||
|
* **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.
|
||||||
|
* **Non-Blocking Tantivy Search Queries**: `MemoryIndex::search` queries current index searcher snapshots without executing synchronous disk commit locks, preventing query stalls during heavy background indexing.
|
||||||
|
* **Zero-Allocation HashSet<&str> Snippet Deduplication**: `indexer.rs` utilizes borrowed `HashSet<&str>` name lookups during snippet batch modifications, eliminating heap string re-allocations inside the store write lock.
|
||||||
|
* **AST Recursion Depth Safeguard & Zero-Copy Borrowing**: Tree-sitter AST traversal caps recursion depth at 100 to prevent thread stack overflows and borrows string slices (`&str`) during AST node walking.
|
||||||
|
* **Strongly-Typed SearchResult & Pre-Allocated Search Vectors**: `search.rs` uses a strongly-typed `SearchResult` struct with named fields and pre-allocates result vector capacity (`Vec::with_capacity(top_docs.len())`).
|
||||||
|
* **BFS Graph Traversal Pre-allocation & Visited Node Upper Bound**: `GraphQueryBuilder::find_shortest_path` pre-allocates adjacency map capacity (`HashMap::with_capacity(relations.len() * 2)`) and enforces a visited node upper bound (10,000 max) to guarantee deterministic BFS runtime.
|
||||||
|
* **Filesystem Event Debouncing & Proactive State Refresh**: `spawn_watcher` implements a sliding 250ms debouncing window per file path, ignores `.git`, `target`, `.gemini`, and `node_modules`, and broadcasts activity events to `MemoryState`.
|
||||||
|
* **Buffered Line-by-Line AST Workspace Symbol Scanning**: `scan_workspace_for_symbol` reads workspace files via `BufReader` line streams instead of loading entire files into heap strings, preventing memory spikes when traversing source trees.
|
||||||
|
* **AST Node Type Aliasing & Skeleton Preallocation**: `replace_ast_node` documents friendly node aliases (`function`, `fn`, `method`, `struct`, `class`, `enum`, `trait`, `type`), and `read_file_skeleton` preallocates string buffer capacity (`code.len() / 2`).
|
||||||
|
* **Batch Vector Indexing & Similarity Score Guidance**: `VectorDB` provides `index_documents_batch` for single-request multi-point vector upserts and explicit score calibration notes ($\ge 0.75$ high confidence match).
|
||||||
|
* **Compact JSON MCP Resources & UTF-8 Activity Truncation**: MCP resources serialize using compact JSON (`to_string`), `TerminalHistoryResource` / `MilestonesResource` enforce output bounds, and `format_tool_activity_description` uses `floor_char_boundary` for guaranteed UTF-8 safety.
|
||||||
|
* **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 (`process_logs`, action: "get") 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.
|
||||||
|
* **Task Summary UTF-8 Truncation Safety**: `tasks` tool (`action = "list"`) truncates serialized task text strictly along UTF-8 character boundaries using `floor_char_boundary` when enforcing `max_tokens`.
|
||||||
|
* **Directory Tree Depth Safeguard**: `ReadDirectoryArchitectureHandler` caps directory recursion at depth 10 to prevent stack overflow on deep or cyclic directory structures.
|
||||||
|
* **Deterministic Total-Order Score Ranking**: `OmniSearchHandler` uses `f64::total_cmp` for Reciprocal Rank Fusion (RRF) score sorting, guaranteeing deterministic NaN-safe search result ordering.
|
||||||
|
* **RPC Timeout Memory Hygiene**: `nvim-core` maintains request hygiene by removing pending request entries from static RPC maps upon timeout or channel drop, eliminating orphan memory leaks.
|
||||||
|
* **Path Traversal Security Guards**: `validate_safe_path` enforces path canonicalization and rejects relative parent traversal components (`..`) across file and process log handlers (`process_logs` / `ProcessLogsTool`).
|
||||||
|
* **Watcher Map Memory Eviction**: Proactive daemon file watcher in `watcher.rs` caps `last_processed` map size at 1,000 entries and purges entries older than 10 minutes to prevent monotonic memory leakage.
|
||||||
|
* **Comprehensive Serde Casing Aliases**: All 11 consolidated tool action enums (TaskAction, MilestoneAction, SnippetAction, DecisionAction, TechDebtAction, EnvAction, ClipboardAction, HandoffMemoAction, HypothesisAction, AgentSignalAction, ProcessLogAction) include serde alias attributes supporting `snake_case`, `camelCase`, `PascalCase`, and uppercase variants for maximum LLM casing resilience.
|
||||||
|
* **Two-Phase Graph Condensation**: `condense_graph_worker` uses a 2-phase commit (non-destructive `read_with` -> graph insert -> prune by timestamp/content) to prevent data loss if summarization or graph insertion fails.
|
||||||
|
* **Redb Database Lock Retry Backoff**: `init_db` retries transient Redb lock contention with exponential backoff (3 attempts, 150ms delay) before falling back.
|
||||||
|
* **Offloaded Background Index Rebuilds**: `MemoryState::rebuild_index` offloads graph snapshot cloning and Tantivy document re-indexing into `tokio::task::spawn_blocking` to avoid stalling async event loops.
|
||||||
|
* **Broadcast Watch-Based Shutdown Channels**: Background workers utilize `tokio::sync::watch` for broadcast shutdown notifications without consuming cancellation signals.
|
||||||
@@ -1,147 +1,198 @@
|
|||||||
set shell := ["pwsh.exe", "-NoProfile", "-c"]
|
set shell := ["pwsh.exe", "-NoProfile", "-c"]
|
||||||
|
|
||||||
|
# Display available commands
|
||||||
default:
|
default:
|
||||||
@just --list
|
@just --list
|
||||||
|
|
||||||
# Build and deploy everything across Windows and WSL
|
# Build and deploy everything across Windows and WSL
|
||||||
all: build-all deploy-all
|
all: build-all deploy-all
|
||||||
|
|
||||||
# Build everything across Windows and WSL
|
# Build all binaries across Windows and WSL
|
||||||
build-all: build-win build-wsl
|
build-all: build-win build-wsl
|
||||||
@Write-Host "Build complete across both OS boundaries." -ForegroundColor Green
|
@Write-Host "Build complete across both OS boundaries." -ForegroundColor Green
|
||||||
|
|
||||||
# Deploy everything across Windows and WSL
|
# Deploy all binaries and agent rules across Windows and WSL
|
||||||
deploy-all: deploy-win deploy-wsl
|
deploy-all: deploy-win deploy-wsl
|
||||||
@Write-Host "Deployment complete across both OS boundaries." -ForegroundColor Green
|
@Write-Host "Deployment complete across both OS boundaries." -ForegroundColor Green
|
||||||
|
|
||||||
# Gracefully shut down the running server
|
|
||||||
shutdown-server:
|
|
||||||
@Write-Host "Shutting down MCP server gracefully..." -ForegroundColor Cyan
|
|
||||||
-$token = Get-Content "$env:USERPROFILE\.gemini\mcp_memory\admin.token" -ErrorAction SilentlyContinue; if ($token) { Invoke-RestMethod -Uri "http://127.0.0.1:3000/shutdown" -Method Post -Headers @{ Authorization = "Bearer $token" } -ErrorAction SilentlyContinue } else { Invoke-RestMethod -Uri "http://127.0.0.1:3000/shutdown" -Method Post -ErrorAction SilentlyContinue }
|
|
||||||
Start-Sleep -Seconds 1
|
|
||||||
-if (Get-Process mcp-memory-server, mcp-memory-stub, mcp-memory-win-nvim -ErrorAction SilentlyContinue) { Stop-Process -Name mcp-memory-server, mcp-memory-stub, mcp-memory-win-nvim -Force -ErrorAction SilentlyContinue }
|
|
||||||
Start-Sleep -Seconds 2
|
|
||||||
|
|
||||||
# Build Windows-native binaries
|
# Build Windows-native binaries
|
||||||
build-win: build-server build-stub-win build-nvim-win
|
build-win: build-ui build-server build-stub-win build-nvim-win
|
||||||
|
|
||||||
# Deploy Windows-native binaries
|
# Deploy Windows-native binaries and rules
|
||||||
deploy-win: deploy-server deploy-stub-win deploy-nvim-win deploy-rules-win
|
deploy-win: deploy-server deploy-stub-win deploy-nvim-win deploy-rules-win
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# 1. LIFECYCLE (Start / Stop / Restart)
|
# 1. LIFECYCLE (Start / Stop / Restart / Verify / Version)
|
||||||
# =========================================================
|
# =========================================================
|
||||||
|
|
||||||
# Stop the background server
|
# Stop the background server gracefully and verify port release
|
||||||
stop port="3000":
|
stop port="3000":
|
||||||
@Write-Host "Shutting down MCP server gracefully on port {{port}}..." -ForegroundColor Cyan
|
@Write-Host "Shutting down MCP server gracefully on port {{port}}..." -ForegroundColor Cyan
|
||||||
-$token = Get-Content "$env:USERPROFILE\.gemini\mcp_memory\admin.token" -ErrorAction SilentlyContinue; if ($token) { Invoke-RestMethod -Uri "http://127.0.0.1:{{port}}/shutdown" -Method Post -Headers @{ Authorization = "Bearer $token" } -ErrorAction SilentlyContinue } else { Invoke-RestMethod -Uri "http://127.0.0.1:{{port}}/shutdown" -Method Post -ErrorAction SilentlyContinue }
|
-& { $token = Get-Content "$env:USERPROFILE\.gemini\mcp_memory\admin.token" -ErrorAction SilentlyContinue; if ($token) { $hdr = @{ Authorization = "Bearer $($token.Trim())" }; $res = Invoke-RestMethod -Uri "http://127.0.0.1:{{port}}/shutdown" -Method Post -Headers $hdr -ErrorAction SilentlyContinue } else { $res = Invoke-RestMethod -Uri "http://127.0.0.1:{{port}}/shutdown" -Method Post -ErrorAction SilentlyContinue }; if ($res) { Write-Host "✅ Shutdown acknowledged with version details:" -ForegroundColor Green; $res | ConvertTo-Json -Depth 5 }; $procs = @(Get-Process mcp-memory-server -ErrorAction SilentlyContinue); if ($procs.Count -gt 0) { foreach ($p in $procs) { $exited = $p.WaitForExit(10000); if (-not $exited) { Write-Host "⚠️ Process $($p.Id) did not exit within 10s. Force killing..." -ForegroundColor Yellow; $p | Stop-Process -Force -ErrorAction SilentlyContinue } } }; $conns = Get-NetTCPConnection -LocalPort {{port}} -State Listen -ErrorAction SilentlyContinue; if ($conns) { $conns | Select-Object -ExpandProperty OwningProcess | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue } } }
|
||||||
Start-Sleep -Seconds 1
|
@Write-Host "✅ Shutdown complete. Port {{port}} and process are free." -ForegroundColor Green
|
||||||
-Get-NetTCPConnection -LocalPort {{port}} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue }
|
|
||||||
Start-Sleep -Seconds 2
|
|
||||||
|
|
||||||
# Start the server in the background (nohup equivalent)
|
# Start the server in the background and verify /health
|
||||||
start port="3000":
|
start port="3000":
|
||||||
@if (Get-NetTCPConnection -LocalPort {{port}} -State Listen -ErrorAction SilentlyContinue) { Write-Host '⚠️ Server is already running on port {{port}}. Skipping start.' -ForegroundColor Yellow; exit 0 }; Write-Host 'Starting MCP server in background on port {{port}}...' -ForegroundColor Cyan; $env:MCP_PORT='{{port}}'; $env:MCP_UDP_PORT1=$([int]{{port}} + 1); $env:MCP_UDP_PORT2=$([int]{{port}} + 2); .\start_server.ps1 -Port {{port}}; Write-Host 'Waiting up to 40 seconds for /ping endpoint...' -ForegroundColor Cyan; for ($i = 1; $i -le 40; $i++) { try { $res = Invoke-RestMethod -Uri 'http://127.0.0.1:{{port}}/ping' -ErrorAction Stop; Write-Host '✅ Server is UP!' -ForegroundColor Green; exit 0 } catch { Start-Sleep -Seconds 1 } }; Write-Error '❌ Server failed to start on port {{port}} within 40 seconds.'; exit 1
|
@if (Get-NetTCPConnection -LocalPort {{port}} -State Listen -ErrorAction SilentlyContinue) { Write-Host '⚠️ Server is already running on port {{port}}. Skipping start.' -ForegroundColor Yellow; exit 0 }; Write-Host 'Starting MCP server in background on port {{port}}...' -ForegroundColor Cyan; $env:MCP_PORT='{{port}}'; $env:MCP_UDP_PORT1=$([int]{{port}} + 1); $env:MCP_UDP_PORT2=$([int]{{port}} + 2); .\start_server.ps1 -Port {{port}}; Write-Host 'Waiting up to 40 seconds for /health endpoint...' -ForegroundColor Cyan; for ($i = 1; $i -le 40; $i++) { try { $h = Invoke-RestMethod -Uri 'http://127.0.0.1:{{port}}/health' -ErrorAction Stop; Write-Host "✅ Server is UP! Running Version: $($h.version.app_version) [$($h.version.target_os)/$($h.version.target_arch)]" -ForegroundColor Green; exit 0 } catch { Start-Sleep -Seconds 1 } }; Write-Error '❌ Server failed to start on port {{port}} within 40 seconds.'; exit 1
|
||||||
|
|
||||||
# Verify the deployment is healthy and serving correct content
|
# Verify deployment health and UI payload response
|
||||||
verify port="3000":
|
verify port="3000":
|
||||||
.\verify_deployment.ps1 -Port {{port}}
|
.\verify_deployment.ps1 -Port {{port}}
|
||||||
|
|
||||||
# Restart the server and verify
|
# Display active running server version and deployed binary CLI version
|
||||||
|
version port="3000":
|
||||||
|
@Write-Host "--- Running Server API Version ---" -ForegroundColor Cyan
|
||||||
|
-& { try { $h = Invoke-RestMethod -Uri "http://127.0.0.1:{{port}}/health" -ErrorAction Stop; Write-Host "HTTP /health: $($h.version.app_version) [$($h.version.target_os)/$($h.version.target_arch)]" -ForegroundColor Green } catch { Write-Host "HTTP /health: Server not reachable on port {{port}}" -ForegroundColor Yellow } }
|
||||||
|
@Write-Host "--- Deployed Binary CLI Version ---" -ForegroundColor Cyan
|
||||||
|
-mcp-memory-server --version
|
||||||
|
|
||||||
|
# Gracefully stop, start, and verify the background server
|
||||||
restart port="3000": (stop port) (start port) (verify port)
|
restart port="3000": (stop port) (start port) (verify port)
|
||||||
|
|
||||||
|
# Run server directly in foreground for debugging
|
||||||
|
run-server:
|
||||||
|
cargo run -p mcp-memory-server
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# 2. BUILD (Compile Binaries)
|
# 2. BUILD (Compile Binaries)
|
||||||
# =========================================================
|
# =========================================================
|
||||||
|
|
||||||
build-server:
|
# Transpile Dashboard TypeScript (server/src/dashboard.ts) into JavaScript
|
||||||
|
build-ui:
|
||||||
|
@Write-Host "Building Dashboard JavaScript from server/src/dashboard.ts..." -ForegroundColor Cyan
|
||||||
|
bun build server/src/dashboard.ts --outfile=server/src/dashboard.js --target=browser
|
||||||
|
|
||||||
|
# Type-check Dashboard TypeScript source code
|
||||||
|
check-ui:
|
||||||
|
@Write-Host "Type-checking Dashboard TypeScript..." -ForegroundColor Cyan
|
||||||
|
deno check server/src/dashboard.ts
|
||||||
|
|
||||||
|
# Build Windows-native server binary in release mode (ensuring fresh UI build)
|
||||||
|
build-server: build-ui
|
||||||
@Write-Host "Building Windows Server..." -ForegroundColor Cyan
|
@Write-Host "Building Windows Server..." -ForegroundColor Cyan
|
||||||
cargo build --release -p mcp-memory-server
|
cargo build --release -p mcp-memory-server
|
||||||
|
|
||||||
|
# Build Windows-native stub binary in release mode
|
||||||
build-stub-win:
|
build-stub-win:
|
||||||
@Write-Host "Building Windows Stub..." -ForegroundColor Cyan
|
@Write-Host "Building Windows Stub..." -ForegroundColor Cyan
|
||||||
cargo build --release -p mcp-memory-stub
|
cargo build --release -p mcp-memory-stub
|
||||||
|
|
||||||
|
# Build Windows-native Neovim plugin binary
|
||||||
|
build-nvim-win:
|
||||||
|
@Write-Host "Building Windows Nvim..." -ForegroundColor Cyan
|
||||||
|
cargo build --release -p nvim-core --bin mcp-memory-nvim
|
||||||
|
|
||||||
# =========================================================
|
# Build all WSL-native binaries
|
||||||
# 3. DEPLOY (Move Binaries to Global Path)
|
|
||||||
# =========================================================
|
|
||||||
# WARNING: Deploy recipes DO NOT stop running processes.
|
|
||||||
# You MUST stop the server/process before deploying to avoid file lock errors.
|
|
||||||
|
|
||||||
deploy-server:
|
|
||||||
@Write-Host "Deploying Windows Server..." -ForegroundColor Cyan
|
|
||||||
Copy-Item -Force target\release\mcp-memory-server.exe "C:\Users\reazul.ashraf\.local\bin\"
|
|
||||||
|
|
||||||
|
|
||||||
deploy-stub-win:
|
|
||||||
@Write-Host "Deploying Windows Stub..." -ForegroundColor Cyan
|
|
||||||
-Stop-Process -Name mcp-memory-stub -Force -ErrorAction SilentlyContinue
|
|
||||||
Copy-Item -Force target\release\mcp-memory-stub.exe "C:\Users\reazul.ashraf\.local\bin\"
|
|
||||||
|
|
||||||
|
|
||||||
deploy-nvim-win:
|
|
||||||
@Write-Host "Deploying Windows Nvim..." -ForegroundColor Cyan
|
|
||||||
-Stop-Process -Name mcp-memory-win-nvim -Force -ErrorAction SilentlyContinue
|
|
||||||
Copy-Item -Force target\release\mcp-memory-win-nvim.exe "C:\Users\reazul.ashraf\.local\bin\"
|
|
||||||
|
|
||||||
deploy-rules-win:
|
|
||||||
@Write-Host "Deploying global Agent rules (Windows)..." -ForegroundColor Cyan
|
|
||||||
if (!(Test-Path "C:\Users\reazul.ashraf\.gemini\config\rules")) { New-Item -ItemType Directory -Force -Path "C:\Users\reazul.ashraf\.gemini\config\rules" | Out-Null }
|
|
||||||
Copy-Item -Force -Recurse agent-rules\* "C:\Users\reazul.ashraf\.gemini\config\rules\"
|
|
||||||
|
|
||||||
# Build WSL-native binaries
|
|
||||||
build-wsl: build-stub build-nvim
|
build-wsl: build-stub build-nvim
|
||||||
|
|
||||||
# Deploy WSL-native binaries
|
# Build WSL-native stub binary in release mode
|
||||||
deploy-wsl: deploy-stub deploy-nvim deploy-rules-wsl
|
|
||||||
|
|
||||||
build-stub:
|
build-stub:
|
||||||
@Write-Host "Building WSL Stub natively..." -ForegroundColor Cyan
|
@Write-Host "Building WSL Stub natively..." -ForegroundColor Cyan
|
||||||
wsl.exe -d Ubuntu -e bash -c 'export PATH="$PATH:/home/riz/.cargo/bin" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && cargo build --release -p mcp-memory-stub'
|
wsl.exe -d Ubuntu -e bash -c 'export PATH="/home/riz/.cargo/bin:$PATH" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && cargo build --release -p mcp-memory-stub'
|
||||||
|
|
||||||
deploy-stub:
|
|
||||||
@Write-Host "Deploying WSL Stub natively..." -ForegroundColor Cyan
|
|
||||||
wsl.exe -d Ubuntu -e bash -c 'export PATH="$PATH:/home/riz/.cargo/bin" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && cp target/release/mcp-memory-stub /home/riz/.local/bin/'
|
|
||||||
|
|
||||||
|
# Build WSL-native Neovim plugin binary in release mode
|
||||||
build-nvim:
|
build-nvim:
|
||||||
@Write-Host "Building WSL Nvim natively..." -ForegroundColor Cyan
|
@Write-Host "Building WSL Nvim natively..." -ForegroundColor Cyan
|
||||||
wsl.exe -d Ubuntu -e bash -c 'export PATH="$PATH:/home/riz/.cargo/bin" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && cargo build --release -p mcp-memory-linux-nvim'
|
wsl.exe -d Ubuntu -e bash -c 'export PATH="/home/riz/.cargo/bin:$PATH" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && cargo build --release -p nvim-core --bin mcp-memory-nvim'
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# 3. DEPLOY (Move Binaries and Rules to Global Paths)
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
# Stage updated server binary, perform graceful shutdown, and auto-restart (skips if binary is unchanged)
|
||||||
|
deploy-server:
|
||||||
|
@Write-Host "Deploying Windows Server (Staged Hot-Swap)..." -ForegroundColor Cyan
|
||||||
|
-& { $src = "target\release\mcp-memory-server.exe"; $targets = @("$env:USERPROFILE\.local\bin\mcp-memory-server.exe", "$env:USERPROFILE\.cargo\bin\mcp-memory-server.exe", "$env:USERPROFILE\.gemini\antigravity-cli\mcp\mcp-memory\mcp-memory.exe", "$env:USERPROFILE\scoop\apps\rustup\current\.cargo\bin\mcp-memory-server.exe"); $srcHash = if (Test-Path $src) { (Get-FileHash $src -Algorithm SHA256).Hash } else { $null }; $updated = $false; foreach ($target in $targets) { $parent = Split-Path $target; if (Test-Path $parent) { if ((Test-Path $target) -and $srcHash -and ((Get-FileHash $target -Algorithm SHA256).Hash -eq $srcHash)) { Write-Host "Skipping copy to $target (identical checksum)." -ForegroundColor Yellow } else { if (Test-Path $target) { $bak = "$target.old.$([guid]::NewGuid().ToString().Substring(0,8))"; Move-Item -Force $target $bak -ErrorAction SilentlyContinue }; Copy-Item -Force $src $target; Get-ChildItem "$parent\mcp-memory-server.exe.old.*" -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue; Write-Host "Copied updated binary to $target" -ForegroundColor Green; $updated = $true } } }; if ($updated -or -not (Get-NetTCPConnection -LocalPort 3000 -State Listen -ErrorAction SilentlyContinue)) { just stop; just start } else { Write-Host "Server binary unchanged and server is running. Skipping restart." -ForegroundColor Green } }
|
||||||
|
|
||||||
|
# Deploy Windows-native stub binary (skips if identical checksum)
|
||||||
|
deploy-stub-win:
|
||||||
|
@Write-Host "Deploying Windows Stub..." -ForegroundColor Cyan
|
||||||
|
-& { $src = "target\release\mcp-memory-stub.exe"; $dest = "C:\Users\reazul.ashraf\.local\bin\mcp-memory-stub.exe"; if ((Test-Path $src) -and (Test-Path $dest) -and ((Get-FileHash $src -Algorithm SHA256).Hash -eq (Get-FileHash $dest -Algorithm SHA256).Hash)) { Write-Host "Skipping $dest (identical checksum)." -ForegroundColor Yellow } else { Stop-Process -Name mcp-memory-stub -Force -ErrorAction SilentlyContinue; if (Test-Path $dest) { Move-Item -Force $dest "$dest.old" -ErrorAction SilentlyContinue; Remove-Item -Force "$dest.old" -ErrorAction SilentlyContinue }; Copy-Item -Force $src $dest; Write-Host "Copied updated $src to $dest" -ForegroundColor Green } }
|
||||||
|
|
||||||
|
# Deploy Windows-native Neovim plugin binary (skips if identical checksum)
|
||||||
|
deploy-nvim-win:
|
||||||
|
@Write-Host "Deploying Windows Nvim..." -ForegroundColor Cyan
|
||||||
|
-& { $src = "target\release\mcp-memory-nvim.exe"; $dest = "C:\Users\reazul.ashraf\.local\bin\mcp-memory-nvim.exe"; if ((Test-Path $src) -and (Test-Path $dest) -and ((Get-FileHash $src -Algorithm SHA256).Hash -eq (Get-FileHash $dest -Algorithm SHA256).Hash)) { Write-Host "Skipping $dest (identical checksum)." -ForegroundColor Yellow } else { Stop-Process -Name mcp-memory-nvim -Force -ErrorAction SilentlyContinue; if (Test-Path $dest) { Move-Item -Force $dest "$dest.old" -ErrorAction SilentlyContinue; Remove-Item -Force "$dest.old" -ErrorAction SilentlyContinue }; Copy-Item -Force $src $dest; Write-Host "Copied updated $src to $dest" -ForegroundColor Green } }
|
||||||
|
|
||||||
|
# Deploy global Agent rules to Windows config directory (skips identical files)
|
||||||
|
deploy-rules-win:
|
||||||
|
@Write-Host "Deploying global Agent rules (Windows)..." -ForegroundColor Cyan
|
||||||
|
-& { $destDir = "C:\Users\reazul.ashraf\.gemini\config\rules"; if (!(Test-Path $destDir)) { New-Item -ItemType Directory -Force -Path $destDir | Out-Null }; $srcBase = (Get-Item "agent-rules").FullName; $updatedCount = 0; $skippedCount = 0; Get-ChildItem -Recurse agent-rules -File | ForEach-Object { $rel = $_.FullName.Substring($srcBase.Length + 1); $target = Join-Path $destDir $rel; $targetDir = Split-Path $target; if (!(Test-Path $targetDir)) { New-Item -ItemType Directory -Force -Path $targetDir | Out-Null }; if ((Test-Path $target) -and ((Get-FileHash $_.FullName -Algorithm SHA256).Hash -eq (Get-FileHash $target -Algorithm SHA256).Hash)) { $skippedCount++ } else { Copy-Item -Force $_.FullName $target; Write-Host "Updated rule: $rel" -ForegroundColor Green; $updatedCount++ } }; Write-Host "Agent rules deployment complete ($updatedCount updated, $skippedCount skipped identical)." -ForegroundColor Cyan }
|
||||||
|
|
||||||
|
# Deploy all WSL-native binaries and agent rules
|
||||||
|
deploy-wsl: deploy-stub deploy-nvim deploy-rules-wsl
|
||||||
|
|
||||||
|
# Deploy WSL-native stub binary (skips if identical checksum)
|
||||||
|
deploy-stub:
|
||||||
|
@Write-Host "Deploying WSL Stub natively..." -ForegroundColor Cyan
|
||||||
|
wsl.exe -d Ubuntu -e bash -c 'export PATH="/home/riz/.cargo/bin:$PATH" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && src="target/release/mcp-memory-stub" && dest="/home/riz/.local/bin/mcp-memory-stub" && if [ -f "$dest" ] && cmp -s "$src" "$dest"; then echo -e "\033[33mSkipping $dest (identical checksum).\033[0m"; else cp --remove-destination "$src" "$dest" && echo -e "\033[32mCopied updated $src to $dest\033[0m"; fi'
|
||||||
|
|
||||||
|
# Deploy WSL-native Neovim plugin binary (skips if identical checksum)
|
||||||
deploy-nvim:
|
deploy-nvim:
|
||||||
@Write-Host "Deploying WSL Nvim natively..." -ForegroundColor Cyan
|
@Write-Host "Deploying WSL Nvim natively..." -ForegroundColor Cyan
|
||||||
wsl.exe -d Ubuntu -e bash -c 'export PATH="$PATH:/home/riz/.cargo/bin" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && cp target/release/mcp-memory-linux-nvim /home/riz/.local/bin/'
|
wsl.exe -d Ubuntu -e bash -c 'export PATH="/home/riz/.cargo/bin:$PATH" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && src="target/release/mcp-memory-nvim" && dest="/home/riz/.local/bin/mcp-memory-nvim" && if [ -f "$dest" ] && cmp -s "$src" "$dest"; then echo -e "\033[33mSkipping $dest (identical checksum).\033[0m"; else cp --remove-destination "$src" "$dest" && echo -e "\033[32mCopied updated $src to $dest\033[0m"; fi'
|
||||||
|
|
||||||
|
# Deploy global Agent rules to WSL config directory (skips identical files)
|
||||||
deploy-rules-wsl:
|
deploy-rules-wsl:
|
||||||
@Write-Host "Deploying global Agent rules (WSL)..." -ForegroundColor Cyan
|
@Write-Host "Deploying global Agent rules (WSL)..." -ForegroundColor Cyan
|
||||||
wsl.exe -d Ubuntu -e bash -c 'mkdir -p /home/riz/.gemini/config/rules && cp -r /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory/agent-rules/* /home/riz/.gemini/config/rules/'
|
wsl.exe -d Ubuntu -e bash -c 'mkdir -p /home/riz/.gemini/config/rules && src_dir="/mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory/agent-rules" && dest_dir="/home/riz/.gemini/config/rules" && updated=0 && skipped=0 && for src in $(find "$src_dir" -type f); do rel="${src#$src_dir/}"; dest="$dest_dir/$rel"; mkdir -p "$(dirname "$dest")"; if [ -f "$dest" ] && cmp -s "$src" "$dest"; then ((skipped++)); else cp "$src" "$dest" && echo -e "\033[32mUpdated rule: $rel\033[0m" && ((updated++)); fi; done && echo -e "\033[36mAgent rules WSL deployment complete ($updated updated, $skipped skipped identical).\033[0m"'
|
||||||
|
|
||||||
# Run configuration tests to ensure eagerTools parity
|
# =========================================================
|
||||||
|
# 4. TESTING & COVERAGE
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
# Run fast parallel unit tests via cargo-nextest (and type-check UI)
|
||||||
|
test: check-ui
|
||||||
|
@Write-Host "Running fast parallel tests via cargo-nextest..." -ForegroundColor Cyan
|
||||||
|
cargo nextest run --workspace
|
||||||
|
|
||||||
|
# Run dashboard UI endpoint and integrity unit tests
|
||||||
|
test-ui:
|
||||||
|
@Write-Host "Running dashboard UI endpoint and integrity unit tests..." -ForegroundColor Cyan
|
||||||
|
cargo test -p mcp-memory-server --lib api::setup::tests::test_dashboard_endpoint_and_html_integrity -- --nocapture
|
||||||
|
|
||||||
|
# Run standard sequential cargo unit tests
|
||||||
|
test-cargo:
|
||||||
|
@Write-Host "Running standard unit tests across workspace..." -ForegroundColor Cyan
|
||||||
|
cargo test --workspace
|
||||||
|
|
||||||
|
# Run configuration parity tests for eagerTools
|
||||||
test-config:
|
test-config:
|
||||||
cargo test --release -p mcp-memory-server --test parity_test
|
cargo test --release -p mcp-memory-server --test parity_test
|
||||||
|
|
||||||
# Run tests and generate code coverage report
|
# Run workspace unit tests and generate code coverage report
|
||||||
test-coverage:
|
test-coverage:
|
||||||
@Write-Host "Running tests with code coverage..." -ForegroundColor Cyan
|
@Write-Host "Running tests with code coverage..." -ForegroundColor Cyan
|
||||||
rustup run stable cargo llvm-cov --workspace
|
cargo llvm-cov --workspace --ignore-filename-regex "stub" -- --test-threads=1
|
||||||
|
|
||||||
# Start the server directly
|
# Auto-generate and verify unit tests using local Ollama model
|
||||||
startup-server:
|
improve-tests file="server/src/ollama.rs":
|
||||||
cargo run -p mcp-memory-server
|
improve-test {{file}}
|
||||||
|
|
||||||
# --- Explicit Lifecycle Chaining Commands ---
|
# =========================================================
|
||||||
# all-* commands handle the FULL end-to-end process: Stop -> Build -> Deploy -> Start
|
# 5. EXPLICIT LIFECYCLE CHAINING
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
# Complete lifecycle: stop, build, deploy, start, and verify Windows server
|
||||||
all-server-win port="3000": (stop port) build-server deploy-server (start port) (verify port)
|
all-server-win port="3000": (stop port) build-server deploy-server (start port) (verify port)
|
||||||
|
|
||||||
|
# Complete lifecycle: build and deploy Windows stub
|
||||||
all-stub-win: build-stub-win deploy-stub-win
|
all-stub-win: build-stub-win deploy-stub-win
|
||||||
|
|
||||||
|
# Complete lifecycle: build and deploy Windows Neovim plugin
|
||||||
all-nvim-win: build-nvim-win deploy-nvim-win
|
all-nvim-win: build-nvim-win deploy-nvim-win
|
||||||
|
|
||||||
|
# Complete lifecycle: build and deploy WSL stub
|
||||||
all-stub-wsl: build-stub deploy-stub
|
all-stub-wsl: build-stub deploy-stub
|
||||||
|
|
||||||
|
# Complete lifecycle: build and deploy WSL Neovim plugin
|
||||||
all-nvim-wsl: build-nvim deploy-nvim
|
all-nvim-wsl: build-nvim deploy-nvim
|
||||||
|
|
||||||
|
# Complete lifecycle: stop, build, deploy, start, and verify WSL server
|
||||||
all-server-wsl port="3000": (stop port) build-server deploy-server (start port) (verify port)
|
all-server-wsl port="3000": (stop port) build-server deploy-server (start port) (verify port)
|
||||||
|
|
||||||
|
# Install git hooks (post-commit reconciliation and pre-push gatekeeper)
|
||||||
|
install-git-hooks:
|
||||||
|
@Write-Host "Installing git post-commit and pre-push hooks..." -ForegroundColor Cyan
|
||||||
|
-& { $postCommitContent = "#!/bin/sh`npython3 scripts/git-reconcile.py 2>/dev/null || python scripts/git-reconcile.py 2>/dev/null || true`n"; [System.IO.File]::WriteAllText(".git\hooks\post-commit", $postCommitContent); $prePushContent = "#!/bin/sh`nif command -v python3 >/dev/null 2>&1; then`n exec python3 scripts/git-pre-push.py `"`$@`"`nelse`n exec python scripts/git-pre-push.py `"`$@`"`nfi`n"; [System.IO.File]::WriteAllText(".git\hooks\pre-push", $prePushContent); Write-Host "✅ Git post-commit and pre-push hooks installed." -ForegroundColor Green }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
build-nvim-win:
|
|
||||||
@Write-Host 'Building Windows Nvim...' -ForegroundColor Cyan
|
|
||||||
cargo build --release -p mcp-memory-win-nvim
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "mcp-memory-linux-nvim"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
serde_json = "1.0"
|
|
||||||
tokio = { version = "1.37", features = ["full"] }
|
|
||||||
|
|
||||||
nvim-core = { path = "../nvim-core" }
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
use std::process::Command;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let 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());
|
|
||||||
|
|
||||||
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())
|
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
|
||||||
|
|
||||||
let version = format!("{} ({})", git_date.trim(), git_hash.trim());
|
|
||||||
println!("cargo:rustc-env=APP_VERSION={version}");
|
|
||||||
println!("cargo:rerun-if-changed=../.git/HEAD");
|
|
||||||
println!("cargo:rerun-if-changed=../.git/index");
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
#[cfg(unix)]
|
|
||||||
fn main() {
|
|
||||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
||||||
rt.block_on(async {
|
|
||||||
nvim_core::run_mcp_loop("mcp-memory-linux-nvim", env!("APP_VERSION")).await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
fn main() {
|
|
||||||
println!("mcp-memory-linux-nvim is only supported on Unix platforms.");
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
#![cfg(unix)]
|
|
||||||
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
use std::io::{BufRead, BufReader, Read, Write};
|
|
||||||
use std::process::{Command, Stdio};
|
|
||||||
|
|
||||||
fn send_message(stdin: &mut std::process::ChildStdin, msg: Value) {
|
|
||||||
let s = serde_json::to_string(&msg).unwrap();
|
|
||||||
let payload = format!("Content-Length: {}\r\n\r\n{}", s.len(), s);
|
|
||||||
stdin.write_all(payload.as_bytes()).unwrap();
|
|
||||||
stdin.flush().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_message(stdout: &mut std::process::ChildStdout) -> Option<Value> {
|
|
||||||
let mut reader = BufReader::new(stdout);
|
|
||||||
let mut length = 0;
|
|
||||||
|
|
||||||
// Read headers
|
|
||||||
loop {
|
|
||||||
let mut line = String::new();
|
|
||||||
if reader.read_line(&mut line).unwrap_or(0) == 0 {
|
|
||||||
return None; // EOF
|
|
||||||
}
|
|
||||||
let line = line.trim_end();
|
|
||||||
if line.is_empty() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if let Some(len_str) = line.strip_prefix("Content-Length: ") {
|
|
||||||
length = len_str.parse().unwrap_or(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if length == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read body
|
|
||||||
let mut buf = vec![0u8; length];
|
|
||||||
reader.read_exact(&mut buf).unwrap();
|
|
||||||
let body_str = String::from_utf8_lossy(&buf);
|
|
||||||
|
|
||||||
Some(serde_json::from_str(&body_str).unwrap())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn test_mcp_initialization_and_tools_list() {
|
|
||||||
let mut nvim_exe = std::env::current_exe().unwrap();
|
|
||||||
nvim_exe.pop();
|
|
||||||
nvim_exe.pop();
|
|
||||||
nvim_exe.push(format!(
|
|
||||||
"mcp-memory-linux-nvim{}",
|
|
||||||
std::env::consts::EXE_SUFFIX
|
|
||||||
));
|
|
||||||
|
|
||||||
let mut child = Command::new(&nvim_exe)
|
|
||||||
.stdin(Stdio::piped())
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.spawn()
|
|
||||||
.expect("Failed to start mcp-memory-linux-nvim");
|
|
||||||
|
|
||||||
let mut stdin = child.stdin.take().expect("Failed to open stdin");
|
|
||||||
let mut stdout = child.stdout.take().expect("Failed to open stdout");
|
|
||||||
|
|
||||||
// 0. Test server/discover (probe)
|
|
||||||
let discover_req = json!({
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"method": "server/discover",
|
|
||||||
"params": {},
|
|
||||||
"id": 0
|
|
||||||
});
|
|
||||||
send_message(&mut stdin, discover_req);
|
|
||||||
let discover_resp = read_message(&mut stdout).expect("Failed to read server/discover response");
|
|
||||||
assert_eq!(discover_resp["error"]["code"], -32601);
|
|
||||||
|
|
||||||
// 1. Test Initialize
|
|
||||||
let init_req = json!({
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"method": "initialize",
|
|
||||||
"params": {
|
|
||||||
"protocolVersion": "2024-11-05",
|
|
||||||
"capabilities": {},
|
|
||||||
"clientInfo": {
|
|
||||||
"name": "test-client",
|
|
||||||
"version": "1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"id": 1
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send initialize using JSONL format!
|
|
||||||
let s = serde_json::to_string(&init_req).unwrap();
|
|
||||||
stdin.write_all(format!("{}\n", s).as_bytes()).unwrap();
|
|
||||||
stdin.flush().unwrap();
|
|
||||||
|
|
||||||
let init_resp = read_message(&mut stdout).expect("Failed to read initialize response");
|
|
||||||
|
|
||||||
assert_eq!(init_resp["jsonrpc"], "2.0");
|
|
||||||
assert_eq!(init_resp["id"], 1);
|
|
||||||
|
|
||||||
// Verify capabilities
|
|
||||||
let capabilities = &init_resp["result"]["capabilities"];
|
|
||||||
assert_eq!(capabilities["tools"], serde_json::json!({}));
|
|
||||||
|
|
||||||
// 2. Test tools/list
|
|
||||||
let tools_req = json!({
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"method": "tools/list",
|
|
||||||
"params": {},
|
|
||||||
"id": 2
|
|
||||||
});
|
|
||||||
|
|
||||||
send_message(&mut stdin, tools_req);
|
|
||||||
|
|
||||||
let tools_resp = read_message(&mut stdout).expect("Failed to read tools/list response");
|
|
||||||
|
|
||||||
assert_eq!(tools_resp["jsonrpc"], "2.0");
|
|
||||||
assert_eq!(tools_resp["id"], 2);
|
|
||||||
|
|
||||||
let tools = tools_resp["result"]["tools"]
|
|
||||||
.as_array()
|
|
||||||
.expect("result.tools must be an array");
|
|
||||||
assert!(!tools.is_empty(), "Server must expose at least one tool");
|
|
||||||
|
|
||||||
let has_get_active_buffer = tools.iter().any(|t| t["name"] == "nvim_get_active_buffer");
|
|
||||||
assert!(has_get_active_buffer, "Missing nvim_get_active_buffer tool");
|
|
||||||
|
|
||||||
child.kill().expect("Failed to kill child");
|
|
||||||
child.wait().expect("Failed to wait on child");
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mcp-stdio"
|
name = "mcp-stdio"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1.53.1", features = ["io-util"] }
|
tokio = { version = "1.53.1", features = ["io-util", "macros"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { version = "1.53.1", features = ["rt", "macros"] }
|
||||||
+89
-11
@@ -13,30 +13,108 @@ pub async fn read_mcp_message<R: tokio::io::AsyncRead + Unpin>(
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
if line.starts_with('{') {
|
let trimmed = line.trim();
|
||||||
let len = line.trim_end().len();
|
if trimmed.starts_with('{') || trimmed.starts_with('[') {
|
||||||
line.truncate(len);
|
return Some(trimmed.to_string());
|
||||||
return Some(line);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let line = line.trim_end();
|
let trimmed_line = line.trim_end();
|
||||||
if line.is_empty() {
|
if trimmed_line.is_empty() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if line.len() >= 15 && line[..15].eq_ignore_ascii_case("content-length:") {
|
if trimmed_line.len() >= 15
|
||||||
length = line[15..].trim().parse().unwrap_or(0);
|
&& trimmed_line.as_bytes()[..15].eq_ignore_ascii_case(b"content-length:")
|
||||||
|
{
|
||||||
|
length = trimmed_line[15..].trim().parse().unwrap_or(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if length == 0 {
|
const MAX_MESSAGE_BYTES: usize = 50 * 1024 * 1024; // 50MB safety cap
|
||||||
|
|
||||||
|
if length == 0 || length > MAX_MESSAGE_BYTES {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut buffer = vec![0; length];
|
let mut buffer = Vec::with_capacity(length.min(64 * 1024));
|
||||||
if stdin.read_exact(&mut buffer).await.is_err() {
|
if stdin
|
||||||
|
.take(length as u64)
|
||||||
|
.read_to_end(&mut buffer)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
|| buffer.len() != length
|
||||||
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
String::from_utf8(buffer).ok()
|
String::from_utf8(buffer).ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_read_batch_ndjson_message() {
|
||||||
|
let input = "[{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}]\n";
|
||||||
|
let mut reader = BufReader::new(Cursor::new(input));
|
||||||
|
let msg = read_mcp_message(&mut reader).await;
|
||||||
|
assert_eq!(
|
||||||
|
msg,
|
||||||
|
Some("[{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}]".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_read_ndjson_message() {
|
||||||
|
let input = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}\n";
|
||||||
|
let mut reader = BufReader::new(Cursor::new(input));
|
||||||
|
let msg = read_mcp_message(&mut reader).await;
|
||||||
|
assert_eq!(
|
||||||
|
msg,
|
||||||
|
Some("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_read_content_length_message() {
|
||||||
|
let payload = "{\"jsonrpc\":\"2.0\",\"id\":2}";
|
||||||
|
let input = format!("Content-Length: {}\r\n\r\n{}", payload.len(), payload);
|
||||||
|
let mut reader = BufReader::new(Cursor::new(input));
|
||||||
|
let msg = read_mcp_message(&mut reader).await;
|
||||||
|
assert_eq!(msg, Some(payload.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_read_mcp_message_eof() {
|
||||||
|
let input = "";
|
||||||
|
let mut reader = BufReader::new(Cursor::new(input));
|
||||||
|
let msg = read_mcp_message(&mut reader).await;
|
||||||
|
assert_eq!(msg, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_read_mcp_message_zero_content_length() {
|
||||||
|
let input = "Content-Length: 0\r\n\r\n";
|
||||||
|
let mut reader = BufReader::new(Cursor::new(input));
|
||||||
|
let msg = read_mcp_message(&mut reader).await;
|
||||||
|
assert_eq!(msg, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_read_mcp_message_exceeds_max_bytes() {
|
||||||
|
let input = "Content-Length: 60000000\r\n\r\n";
|
||||||
|
let mut reader = BufReader::new(Cursor::new(input));
|
||||||
|
let msg = read_mcp_message(&mut reader).await;
|
||||||
|
assert_eq!(msg, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_read_mcp_message_read_exact_error() {
|
||||||
|
let input = "Content-Length: 1000\r\n\r\nshort";
|
||||||
|
let mut reader = BufReader::new(Cursor::new(input));
|
||||||
|
let msg = read_mcp_message(&mut reader).await;
|
||||||
|
assert_eq!(msg, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "nvim-core"
|
name = "nvim-core"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -13,7 +13,10 @@ tracing-appender = "0.2.5"
|
|||||||
tracing-subscriber = "0.3.23"
|
tracing-subscriber = "0.3.23"
|
||||||
dirs = "7.0.0"
|
dirs = "7.0.0"
|
||||||
rmcp = { version = "3.4.0", features = ["server"] }
|
rmcp = { version = "3.4.0", features = ["server"] }
|
||||||
mcp-stdio = { version = "0.1.0", path = "../mcp-stdio" }
|
mcp-stdio = { version = "1.0.0", path = "../mcp-stdio" }
|
||||||
bytes = "1.12.1"
|
bytes = "1.12.1"
|
||||||
dashmap = "6.2.1"
|
dashmap = "6.2.1"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
chrono = "0.4.45"
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
nvim_core::run_mcp_loop("mcp-memory-win-nvim", env!("APP_VERSION")).await;
|
nvim_core::run_mcp_loop("mcp-memory-nvim", env!("APP_VERSION")).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
+111
-13
@@ -1,11 +1,37 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
use tokio::io::AsyncBufReadExt;
|
||||||
|
|
||||||
pub static ACTIVE_SOCKET: LazyLock<Arc<std::sync::Mutex<Option<String>>>> =
|
pub static ACTIVE_SOCKET: LazyLock<Arc<tokio::sync::Mutex<Option<String>>>> =
|
||||||
LazyLock::new(|| Arc::new(std::sync::Mutex::new(None)));
|
LazyLock::new(|| Arc::new(tokio::sync::Mutex::new(None)));
|
||||||
|
|
||||||
pub static HEADLESS_PROC: LazyLock<Arc<std::sync::Mutex<Option<tokio::process::Child>>>> =
|
pub static HEADLESS_PROC: LazyLock<Arc<tokio::sync::Mutex<Option<tokio::process::Child>>>> =
|
||||||
LazyLock::new(|| Arc::new(std::sync::Mutex::new(None)));
|
LazyLock::new(|| Arc::new(tokio::sync::Mutex::new(None)));
|
||||||
|
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn atexit(f: unsafe extern "C" fn()) -> i32;
|
||||||
|
}
|
||||||
|
|
||||||
|
static HEADLESS_PID: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||||
|
static ONCE_INIT: std::sync::Once = std::sync::Once::new();
|
||||||
|
|
||||||
|
unsafe extern "C" fn cleanup_on_exit() {
|
||||||
|
let pid = HEADLESS_PID.swap(0, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
if pid != 0 {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let _ = std::process::Command::new("taskkill")
|
||||||
|
.args(["/F", "/T", "/PID", &pid.to_string()])
|
||||||
|
.output();
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let _ = std::process::Command::new("kill")
|
||||||
|
.args(["-9", &pid.to_string()])
|
||||||
|
.output();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn spawn_headless_nvim() -> Result<String, String> {
|
pub async fn spawn_headless_nvim() -> Result<String, String> {
|
||||||
if std::process::Command::new("nvim")
|
if std::process::Command::new("nvim")
|
||||||
@@ -16,26 +42,83 @@ pub async fn spawn_headless_nvim() -> Result<String, String> {
|
|||||||
return Err("Error: Neovim is not installed or not in PATH. Cannot execute nvim tool. Fallback to basic terminal tools.".to_string());
|
return Err("Error: Neovim is not installed or not in PATH. Cannot execute nvim tool. Fallback to basic terminal tools.".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
ONCE_INIT.call_once(|| unsafe {
|
||||||
let socket_name = format!(r"\\.\pipe\agy-headless-nvim-{}", std::process::id());
|
atexit(cleanup_on_exit);
|
||||||
#[cfg(unix)]
|
});
|
||||||
let socket_name = format!("/tmp/agy-headless-nvim-{}.sock", std::process::id());
|
|
||||||
|
|
||||||
let child = tokio::process::Command::new("nvim")
|
#[cfg(windows)]
|
||||||
|
let socket_name = format!(
|
||||||
|
r"\\.\pipe\agy-headless-nvim-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos())
|
||||||
|
.unwrap_or(0)
|
||||||
|
);
|
||||||
|
#[cfg(unix)]
|
||||||
|
let socket_name = format!(
|
||||||
|
"/tmp/agy-headless-nvim-{}-{}.sock",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos())
|
||||||
|
.unwrap_or(0)
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut child = tokio::process::Command::new("nvim")
|
||||||
.arg("--headless")
|
.arg("--headless")
|
||||||
.arg("--listen")
|
.arg("--listen")
|
||||||
.arg(&socket_name)
|
.arg(&socket_name)
|
||||||
|
.arg("--cmd")
|
||||||
|
.arg("lua vim.schedule(function() io.stdout:write('READY\\n') io.stdout:flush() end)")
|
||||||
.stdin(std::process::Stdio::piped())
|
.stdin(std::process::Stdio::piped())
|
||||||
.stdout(std::process::Stdio::null())
|
.stdout(std::process::Stdio::piped())
|
||||||
.stderr(std::process::Stdio::null())
|
.stderr(std::process::Stdio::null())
|
||||||
.kill_on_drop(true)
|
.kill_on_drop(true)
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|e| format!("Failed to spawn headless Neovim: {}", e))?;
|
.map_err(|e| format!("Failed to spawn headless Neovim: {}", e))?;
|
||||||
|
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
|
if let Some(id) = child.id() {
|
||||||
|
HEADLESS_PID.store(id, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(stdout) = child.stdout.take() {
|
||||||
|
let mut reader = tokio::io::BufReader::new(stdout);
|
||||||
|
let mut ready_line = String::new();
|
||||||
|
match tokio::time::timeout(
|
||||||
|
tokio::time::Duration::from_millis(2000),
|
||||||
|
reader.read_line(&mut ready_line),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(_)) => {
|
||||||
|
if ready_line.trim() != "READY" {
|
||||||
|
HEADLESS_PID.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
let _ = child.kill().await;
|
||||||
|
return Err(format!(
|
||||||
|
"Headless Neovim child output unexpected banner: {:?}",
|
||||||
|
ready_line
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
HEADLESS_PID.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
let _ = child.kill().await;
|
||||||
|
return Err(format!(
|
||||||
|
"Failed to read readiness from headless Neovim: {}",
|
||||||
|
e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
HEADLESS_PID.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
let _ = child.kill().await;
|
||||||
|
return Err("Timeout waiting for headless Neovim to become ready".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let old_child = {
|
let old_child = {
|
||||||
let mut lock = HEADLESS_PROC.lock().unwrap_or_else(|e| e.into_inner());
|
let mut lock = HEADLESS_PROC.lock().await;
|
||||||
let old = lock.take();
|
let old = lock.take();
|
||||||
*lock = Some(child);
|
*lock = Some(child);
|
||||||
old
|
old
|
||||||
@@ -49,11 +132,26 @@ pub async fn spawn_headless_nvim() -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn kill_headless_nvim() {
|
pub async fn kill_headless_nvim() {
|
||||||
|
HEADLESS_PID.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||||
let child_to_kill = {
|
let child_to_kill = {
|
||||||
let mut proc_lock = HEADLESS_PROC.lock().unwrap_or_else(|e| e.into_inner());
|
let mut proc_lock = HEADLESS_PROC.lock().await;
|
||||||
proc_lock.take()
|
proc_lock.take()
|
||||||
};
|
};
|
||||||
if let Some(mut child) = child_to_kill {
|
if let Some(mut child) = child_to_kill {
|
||||||
let _ = child.kill().await;
|
let _ = child.kill().await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_headless_nvim_lifecycle() {
|
||||||
|
let res = spawn_headless_nvim().await;
|
||||||
|
if let Ok(socket_name) = res {
|
||||||
|
assert!(socket_name.contains("agy-headless-nvim"));
|
||||||
|
kill_headless_nvim().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,37 +1,33 @@
|
|||||||
# Antigravity Neovim MCP Instructions
|
# Antigravity Neovim MCP Instructions
|
||||||
|
|
||||||
When connected to this Neovim MCP server (win-nvim or linux-nvim), you have powerful tools to interact directly with the active Neovim editor.
|
When connected to this Neovim MCP server (`win-nvim`), you have powerful tools to interact directly with the active Neovim editor.
|
||||||
|
|
||||||
## The Tool Arsenal
|
## The Consolidated Tool Arsenal (v3)
|
||||||
The following tools are available:
|
The server consolidates granular Neovim operations into 5 smart mega-tools:
|
||||||
- **File/Buffer Mgmt:**
|
- **`nvim_buffer`**: Buffer and file management. Actions: `read`, `replace`, `save`, `undo`, `redo`, `create_scratch`.
|
||||||
`nvim_open_file`, `nvim_open_buffer`, `nvim_close_buffer`, `nvim_reload_buffer`, `nvim_save_buffer`, `nvim_list_buffers`
|
- **`nvim_workspace`**: Window split and focus management. Actions: `list_buffers`, `list_windows`, `focus`, `split`, `cwd`.
|
||||||
- **Window Mgmt:**
|
- **`nvim_intelligence`**: Code intelligence and LSP. Actions: `hover`, `definition`, `references`, `outline`, `query`, `diagnostics`, `rename`, `code_action`.
|
||||||
`nvim_split_window`, `nvim_close_window`, `nvim_list_windows`, `nvim_get_active_window`, `nvim_set_active_window`
|
- **`nvim_ui`**: Visual highlighting, diff previews, and ghost text. Actions: `highlight`, `ghost_text`, `clear`.
|
||||||
- **State Reading:**
|
- **`nvim_exec`**: Escape hatch for raw evaluation. Actions: `lua`, `vimscript`, `terminal`.
|
||||||
`nvim_get_active_buffer`, `nvim_get_cursor`, `nvim_goto_line`, `nvim_get_visual_selection`, `nvim_get_viewport`, `nvim_get_messages`
|
|
||||||
- **Diagnostics & Visuals:**
|
|
||||||
`nvim_set_diagnostics`, `nvim_get_diagnostics`, `nvim_highlight_lines`, `nvim_set_extmark`, `nvim_set_quickfix`
|
|
||||||
- **God Mode:**
|
|
||||||
`nvim_execute_lua`
|
|
||||||
|
|
||||||
## 1. Using Dedicated Tools First
|
## 1. Using Consolidated Domain Tools First
|
||||||
Always prefer the specific dedicated tools (like `nvim_open_file`, `nvim_highlight_lines`, etc.) over writing raw Lua scripts. These tools are tested and safe.
|
Always prefer the specific consolidated tools (like `nvim_buffer`, `nvim_workspace`, `nvim_ui`, etc.) over writing raw Lua scripts. These tools are strongly typed, tested, and safe.
|
||||||
|
|
||||||
## 2. Lua God Mode (`nvim_execute_lua`)
|
## 2. Lua God Mode (`nvim_exec` with action `lua`)
|
||||||
If you need to access *any* Neovim API that does not have a dedicated tool (e.g., complex buffer edits, changing options, LSP interactions), you MUST use `nvim_execute_lua` as your escape hatch.
|
If you need to access *any* Neovim API that does not have a dedicated tool (e.g., 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 prompts (`vim.fn.input`, `vim.ui.select`, `vim.fn.confirm`) or confirmation flags in regex substitutions (e.g., `%s/old/new/gc`). This will cause the headless MCP bridge to deadlock forever.
|
1. **Never Block:** Never use interactive prompt functions or interactive confirmation flags in substitutions (e.g., `%s/old/new/gc`). This will cause the headless MCP bridge to deadlock forever.
|
||||||
2. **Visual Feedback:** Always trigger a notification using `require("notify")("Antigravity: [Action]", "info", { title = "Antigravity" })`.
|
2. **Visual Feedback:** Always trigger a notification using `require("notify")("Antigravity: [Action]", "info", { title = "Antigravity" })`.
|
||||||
3. **Auto-Save:** If you modify a file buffer, always save it using `vim.cmd('write')` within the same Lua script so external tools can see the changes, unless you explicitly want to pause for manual human review.
|
3. **Auto-Save:** If you modify a file buffer, always save it using `vim.cmd('write')` within the same Lua script so external tools can see the changes, unless you explicitly want to pause for manual human review.
|
||||||
4. **Buffer Focus:** When making changes to a specific buffer, always ensure the active window is switched to that buffer, and optionally move the cursor so the human can see the change visually.
|
4. **Buffer Focus & Centering:** When making changes to a buffer, switch the active window to that buffer, jump to the edited line/column (`vim.api.nvim_win_set_cursor(0, {line, col})`), and center the viewport horizontally and vertically (`vim.cmd('normal! zz'); vim.cmd('normal! ' .. col .. '|zs')`) so the user immediately sees the change in full context.
|
||||||
|
|
||||||
## 3. The "Unix is NOT King" Rule
|
## 3. The "Unix is NOT King" Rule
|
||||||
You should **ALWAYS prioritize Neovim tools over basic unix terminal utilities** (like `cat`, `grep`, `sed`, `awk`, or PowerShell equivalents) for file read/writes and search/replace. If an interactive Neovim session is not currently open, the server will automatically spawn a persistent headless Neovim daemon in the background to execute your commands.
|
You should **ALWAYS prioritize Neovim tools over basic unix terminal utilities** (like `cat`, `grep`, `sed`, `awk`, or PowerShell equivalents) for file read/writes and search/replace. If an interactive Neovim session is not currently open, the server will automatically connect or spawn a persistent headless Neovim daemon in the background to execute your commands.
|
||||||
|
|
||||||
**CRITICAL PAIR-PROGRAMMING EXCEPTION:**
|
**CRITICAL PAIR-PROGRAMMING EXCEPTION:**
|
||||||
While the headless background instance is great for autonomous, routine tasks, if you are performing collaborative "pair programming" activities, complex refactors that require visual engagement, or step-by-step human review, **DO NOT** execute them blindly in the background. Instead, explicitly ask the user to open a Neovim UI first so they can visually follow along. Use Neovim as your primary AST-aware interface to the codebase at all times.
|
While the headless background instance is great for autonomous, routine tasks, if you are performing collaborative "pair programming" activities, complex refactors that require visual engagement, or step-by-step human review, **DO NOT** execute them blindly in the background. Instead, explicitly ask the user to open a Neovim UI first so they can visually follow along. Use Neovim as your primary AST-aware interface to the codebase at all times.
|
||||||
|
|
||||||
## 4. Tool Schema Discovery
|
## 4. Tool Schema Discovery
|
||||||
Do **NOT** grep or search the Rust source code to find tool schemas or arguments. All lazy-loaded MCP tool schemas are automatically cached as JSON files on your disk. To understand a tool`s arguments, directly read `~/.gemini/antigravity-cli/mcp/win-nvim/<tool_name>.json` (or linux-nvim), or simply guess the arguments if it is a basic tool like `nvim_open_file` (e.g., `{"file": "/path/to/file"}`).
|
Do **NOT** grep or search the Rust source code to find tool schemas or arguments. All lazy-loaded MCP tool schemas are automatically cached as JSON files on your disk. To understand a tool's arguments, directly read `~/.gemini/antigravity-cli/mcp/win-nvim/<tool_name>.json` (or linux-nvim).
|
||||||
+1177
-968
File diff suppressed because it is too large.
Load diff
@@ -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"}
|
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Pre-Push Gatekeeper for Git and MCP Memory.
|
||||||
|
Enforces workspace quality gates:
|
||||||
|
1. Allows tag pushes and branch deletions without verification.
|
||||||
|
2. Bypasses tests for docs/config-only modifications (.md, .txt, .png, justfile, etc.).
|
||||||
|
3. Queries mcp-memory gate API (/gate/verify?action=push&target=<branch>).
|
||||||
|
4. If no pre-authorized gate is found, runs project tests locally to verify 100% pass before allowing push.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
DOC_EXTENSIONS = {
|
||||||
|
".md", ".txt", ".png", ".jpg", ".jpeg", ".pdf", ".svg",
|
||||||
|
".drawio", ".gif", ".ico", ".csv"
|
||||||
|
}
|
||||||
|
|
||||||
|
DOC_FILENAMES = {
|
||||||
|
"justfile", "jenkinsfile", "dockerfile", "license",
|
||||||
|
"changelog", ".gitignore", ".gitattributes"
|
||||||
|
}
|
||||||
|
|
||||||
|
def is_doc_or_config(filepath: str) -> bool:
|
||||||
|
base = os.path.basename(filepath).lower()
|
||||||
|
if base in DOC_FILENAMES:
|
||||||
|
return True
|
||||||
|
ext = os.path.splitext(base)[1].lower()
|
||||||
|
return ext in DOC_EXTENSIONS
|
||||||
|
|
||||||
|
def run_git(cmd):
|
||||||
|
try:
|
||||||
|
res = subprocess.run(["git"] + cmd, capture_output=True, text=True, check=True)
|
||||||
|
return res.stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def check_mcp_gate(port: str, branch: str):
|
||||||
|
"""
|
||||||
|
Returns (status_code, reason)
|
||||||
|
status_code: 200 (Authorized), 403 (Blocked), 404 (Not found), 0 (Server unreachable)
|
||||||
|
"""
|
||||||
|
url = f"http://127.0.0.1:{port}/gate/verify?action=push&target={branch}"
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=1.5) as resp:
|
||||||
|
return resp.status, "Authorized"
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = ""
|
||||||
|
try:
|
||||||
|
body = e.read().decode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return e.code, body
|
||||||
|
except Exception:
|
||||||
|
return 0, "Server unreachable"
|
||||||
|
|
||||||
|
def set_mcp_gate(port: str, branch: str, authorize: bool, reason: str):
|
||||||
|
url = f"http://127.0.0.1:{port}/gate/set"
|
||||||
|
payload = {
|
||||||
|
"action": "push",
|
||||||
|
"target": branch,
|
||||||
|
"authorize": authorize,
|
||||||
|
"reason": reason
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=1.5):
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def post_mcp_event(port: str, topic: str, payload: dict):
|
||||||
|
url = f"http://127.0.0.1:{port}/api/events/post"
|
||||||
|
try:
|
||||||
|
data = json.dumps({"topic": topic, "payload": payload}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=1.0):
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def wait_for_mcp_decision(port: str, topic: str, timeout_secs: int = 2):
|
||||||
|
"""Waits for an operator or auditor decision on the event bus."""
|
||||||
|
url = f"http://127.0.0.1:{port}/events/wait?topic={topic}&timeout={timeout_secs}"
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout_secs + 0.5) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
payload = data.get("payload", {})
|
||||||
|
status = str(payload.get("status", "")).lower()
|
||||||
|
reason = payload.get("reason", "Decision received via event bus")
|
||||||
|
if status in ("authorized", "approved", "ok", "true"):
|
||||||
|
return True, reason
|
||||||
|
elif status in ("blocked", "denied", "rejected", "false"):
|
||||||
|
return False, reason
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None, ""
|
||||||
|
|
||||||
|
def run_local_tests() -> bool:
|
||||||
|
# 1. Rust workspace
|
||||||
|
if os.path.exists("Cargo.toml"):
|
||||||
|
print("\033[36m[Pre-Push Gatekeeper]\033[0m Running cargo test...")
|
||||||
|
cmd = ["cargo", "test", "--workspace"]
|
||||||
|
res = subprocess.run(cmd)
|
||||||
|
return res.returncode == 0
|
||||||
|
# 2. Python workspace (uv / pytest)
|
||||||
|
elif os.path.exists("pyproject.toml") or os.path.exists("setup.py"):
|
||||||
|
print("\033[36m[Pre-Push Gatekeeper]\033[0m Running python tests...")
|
||||||
|
cmd = ["uv", "run", "pytest"] if os.system("uv --version >nul 2>&1") == 0 else ["pytest"]
|
||||||
|
res = subprocess.run(cmd)
|
||||||
|
return res.returncode == 0
|
||||||
|
return True
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Read stdin passed by git pre-push: <local ref> <local sha> <remote ref> <remote sha>
|
||||||
|
lines = sys.stdin.read().splitlines()
|
||||||
|
if not lines:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
port = os.environ.get("MCP_PORT", "3000")
|
||||||
|
for line in lines:
|
||||||
|
parts = line.strip().split()
|
||||||
|
if len(parts) < 4:
|
||||||
|
continue
|
||||||
|
local_ref, local_sha, remote_ref, remote_sha = parts[:4]
|
||||||
|
|
||||||
|
# 1. Skip deletions
|
||||||
|
if local_sha == "0000000000000000000000000000000000000000" or local_ref == "(delete)":
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 2. Skip tags
|
||||||
|
if local_ref.startswith("refs/tags/"):
|
||||||
|
print(f"\033[33m[Pre-Push Gatekeeper]\033[0m Tag push detected ({local_ref}). Quality gate bypassed.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract branch name
|
||||||
|
branch = local_ref.replace("refs/heads/", "")
|
||||||
|
|
||||||
|
# 3. Check for documentation / config-only changes
|
||||||
|
zero_sha = "0000000000000000000000000000000000000000"
|
||||||
|
diff_range = local_sha if remote_sha == zero_sha else f"{remote_sha}..{local_sha}"
|
||||||
|
changed_files_raw = run_git(["diff", "--name-only", diff_range])
|
||||||
|
changed_files = [f.strip() for f in changed_files_raw.splitlines() if f.strip()]
|
||||||
|
|
||||||
|
if changed_files and all(is_doc_or_config(f) for f in changed_files):
|
||||||
|
print(f"\033[33m[Pre-Push Gatekeeper]\033[0m All modified files are docs/config-only. Quality gate bypassed.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 4. Query MCP Memory gate status
|
||||||
|
status, reason = check_mcp_gate(port, branch)
|
||||||
|
if status == 200:
|
||||||
|
print(f"\033[32m[Pre-Push Gatekeeper]\033[0m \u2714 Push AUTHORIZED by MCP Memory gate for branch '{branch}'.")
|
||||||
|
continue
|
||||||
|
elif status == 403:
|
||||||
|
print(f"\033[31m[Pre-Push Gatekeeper]\033[0m \u274c Push BLOCKED by MCP Memory gate for branch '{branch}'!")
|
||||||
|
if reason:
|
||||||
|
print(f" Reason: {reason}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 5. No gate found (404 or server offline) -> Request interactive approval or verify unit tests
|
||||||
|
if status != 0:
|
||||||
|
post_mcp_event(port, "gate:approval_requested", {
|
||||||
|
"branch": branch,
|
||||||
|
"local_sha": local_sha,
|
||||||
|
"remote_sha": remote_sha,
|
||||||
|
"changed_files_count": len(changed_files)
|
||||||
|
})
|
||||||
|
# Fast check: Did dashboard / operator / auditor approve or reject via event bus?
|
||||||
|
decision, dec_reason = wait_for_mcp_decision(port, f"gate:decision:{branch}", timeout_secs=1)
|
||||||
|
if decision is True:
|
||||||
|
print(f"\033[32m[Pre-Push Gatekeeper]\033[0m \u2714 Push INTERACTIVELY AUTHORIZED via event bus: {dec_reason}")
|
||||||
|
set_mcp_gate(port, branch, True, dec_reason)
|
||||||
|
post_mcp_event(port, "gate:event", {"status": "authorized", "active_gate": "authorized", "branch": branch})
|
||||||
|
continue
|
||||||
|
elif decision is False:
|
||||||
|
print(f"\033[31m[Pre-Push Gatekeeper]\033[0m \u274c Push BLOCKED by operator: {dec_reason}")
|
||||||
|
set_mcp_gate(port, branch, False, dec_reason)
|
||||||
|
post_mcp_event(port, "gate:event", {"status": "blocked", "active_gate": "blocked", "branch": branch})
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"\033[33m[Pre-Push Gatekeeper]\033[0m No pre-authorized gate record for '{branch}'. Verifying unit tests...")
|
||||||
|
if run_local_tests():
|
||||||
|
print(f"\033[32m[Pre-Push Gatekeeper]\033[0m \u2714 Unit tests passed 100%. Push authorized.")
|
||||||
|
if status != 0:
|
||||||
|
set_mcp_gate(port, branch, True, "Unit tests passed locally via pre-push gatekeeper")
|
||||||
|
post_mcp_event(port, "gate:event", {"status": "authorized", "active_gate": "authorized", "branch": branch})
|
||||||
|
else:
|
||||||
|
print(f"\033[31m[Pre-Push Gatekeeper]\033[0m \u274c Push BLOCKED! Unit tests failed locally. Fix failing tests before pushing.")
|
||||||
|
if status != 0:
|
||||||
|
set_mcp_gate(port, branch, False, "Unit tests failed during pre-push gatekeeper check")
|
||||||
|
post_mcp_event(port, "gate:event", {"status": "blocked", "active_gate": "blocked", "branch": branch})
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Native shell pre-push hook using UDP Rendezvous
|
||||||
|
|
||||||
|
PORT=${MCP_UDP_PORT1:-3001}
|
||||||
|
|
||||||
|
while read local_ref local_sha remote_ref remote_sha; do
|
||||||
|
if [ "$local_sha" = "0000000000000000000000000000000000000000" ] || [ "$local_ref" = "(delete)" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ "$local_ref" == refs/tags/* ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
BRANCH=${local_ref#refs/heads/}
|
||||||
|
|
||||||
|
echo -e "\033[36m[Pre-Push Gatekeeper]\033[0m Checking gate for branch '$BRANCH'..."
|
||||||
|
|
||||||
|
# Check if gate is already authorized or blocked
|
||||||
|
if mcp-memory-server gate verify --action push --target "$BRANCH" --consume 2>/dev/null; then
|
||||||
|
echo -e "\033[32m[Pre-Push Gatekeeper]\033[0m Push AUTHORIZED by MCP Memory gate for branch '$BRANCH'."
|
||||||
|
continue
|
||||||
|
else
|
||||||
|
EXIT_CODE=$?
|
||||||
|
if [ $EXIT_CODE -eq 1 ]; then
|
||||||
|
echo -e "\033[31m[Pre-Push Gatekeeper]\033[0m Push BLOCKED by MCP Memory gate for branch '$BRANCH'."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If exit code is 2 (Not Found), we wait via UDP Rendezvous
|
||||||
|
echo -e "\033[33m[Pre-Push Gatekeeper]\033[0m No pre-authorized gate for '$BRANCH'. Waiting for approval via UDP rendezvous..."
|
||||||
|
|
||||||
|
# Payload for UDP
|
||||||
|
PAYLOAD="{\"type\":\"gate_wait\",\"action\":\"push\",\"target\":\"$BRANCH\"}"
|
||||||
|
|
||||||
|
if udp-send --wait "$PORT" "$PAYLOAD"; then
|
||||||
|
echo -e "\033[32m[Pre-Push Gatekeeper]\033[0m Push AUTHORIZED via UDP rendezvous for branch '$BRANCH'."
|
||||||
|
continue
|
||||||
|
else
|
||||||
|
echo -e "\033[31m[Pre-Push Gatekeeper]\033[0m Push DENIED or timed out via UDP rendezvous for branch '$BRANCH'."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Deterministic Git Reconciliation Hook for MCP Memory
|
||||||
|
Automatically syncs git commit events to mcp-memory to transition ADRs, resolve tech debts,
|
||||||
|
complete tasks, cascade-unblock dependent tasks, and recalculate milestone statuses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
def run_git(cmd):
|
||||||
|
try:
|
||||||
|
res = subprocess.run(["git"] + cmd, capture_output=True, text=True, check=True)
|
||||||
|
return res.stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def main():
|
||||||
|
port = os.environ.get("MCP_PORT", "3000")
|
||||||
|
|
||||||
|
# 1. Quick check if server is running
|
||||||
|
ping_url = f"http://127.0.0.1:{port}/ping"
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(ping_url, timeout=1.0) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
sys.exit(0)
|
||||||
|
except Exception:
|
||||||
|
# Server not running, silently exit
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# 2. Extract git metadata
|
||||||
|
commit = run_git(["rev-parse", "HEAD"])
|
||||||
|
branch = run_git(["branch", "--show-current"])
|
||||||
|
author = run_git(["log", "-1", "--pretty=format:%an"])
|
||||||
|
message = run_git(["log", "-1", "--pretty=format:%B"])
|
||||||
|
|
||||||
|
# Get changed files in this commit
|
||||||
|
files_raw = run_git(["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"])
|
||||||
|
files = [f.strip() for f in files_raw.splitlines() if f.strip()]
|
||||||
|
|
||||||
|
if not commit or not message:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"commit": commit,
|
||||||
|
"branch": branch,
|
||||||
|
"author": author,
|
||||||
|
"message": message,
|
||||||
|
"files": files,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Post to reconciliation endpoint
|
||||||
|
reconcile_url = f"http://127.0.0.1:{port}/api/git/commit"
|
||||||
|
req_data = json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
reconcile_url,
|
||||||
|
data=req_data,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=3.0) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
body = json.loads(resp.read().decode("utf-8"))
|
||||||
|
res = body.get("reconciliation", {})
|
||||||
|
adrs = res.get("implemented_adrs", [])
|
||||||
|
debts = res.get("resolved_tech_debts", [])
|
||||||
|
tasks = res.get("completed_tasks", [])
|
||||||
|
unblocked = res.get("unblocked_tasks", [])
|
||||||
|
milestones = res.get("updated_milestones", [])
|
||||||
|
|
||||||
|
if any([adrs, debts, tasks, unblocked, milestones]):
|
||||||
|
print("\033[36m[MCP Memory Auto-Reconciled]\033[0m")
|
||||||
|
if adrs:
|
||||||
|
print(f" \033[32m✔ Implemented ADRs:\033[0m {', '.join(adrs)}")
|
||||||
|
if debts:
|
||||||
|
print(f" \033[32m✔ Resolved Tech Debts:\033[0m {', '.join(debts)}")
|
||||||
|
if tasks:
|
||||||
|
print(f" \033[32m✔ Completed Tasks:\033[0m {', '.join(tasks)}")
|
||||||
|
if unblocked:
|
||||||
|
print(f" \033[33m⚡ Unblocked Dependent Tasks:\033[0m {', '.join(unblocked)}")
|
||||||
|
if milestones:
|
||||||
|
print(f" \033[35m🎯 Updated Milestones:\033[0m {', '.join(milestones)}")
|
||||||
|
except Exception:
|
||||||
|
# Never break commit flow on failure
|
||||||
|
pass
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
-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
|
|
||||||
+40
-18
@@ -1,44 +1,57 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mcp-memory-server"
|
name = "mcp-memory-server"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "mcp_memory_server"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait = "0.1.92"
|
async-trait = "0.1.92"
|
||||||
axum = { version = "0.8", features = ["ws"] }
|
axum = { version = "0.8", features = ["ws"] }
|
||||||
clap = { version = "4.6.6", features = ["derive"] }
|
clap = { version = "4.6.6", features = ["derive"] }
|
||||||
dirs = "7.0.0"
|
dirs = "7.0.0"
|
||||||
futures-util = "0.3.34"
|
futures-util = "0.3.34"
|
||||||
redb = "4.2.0"
|
redb = "4.3.0"
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["stream", "json"] }
|
reqwest = { version = "0.13", default-features = false, features = ["stream", "json"] }
|
||||||
schemars = "1.2.2"
|
schemars = "1.2.2"
|
||||||
serde = { version = "1.0.229", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
serde_json = "1.0.151"
|
serde_json = "1.0.151"
|
||||||
tantivy = "0.26.1"
|
tantivy = "0.26.2"
|
||||||
tokio = { version = "1.53.1", features = ["full"] }
|
tokio = { version = "1.53.1", features = ["full"] }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = "0.3.23"
|
tracing-subscriber = "0.3.23"
|
||||||
uuid = { version = "1.26.0", features = ["v4"] }
|
uuid = { version = "1.26.0", features = ["v4", "v5"] }
|
||||||
tracing-appender = "0.2.5"
|
tracing-appender = "0.2.5"
|
||||||
rmcp = { version = "3.4.0", features = ["server"] }
|
rmcp = { version = "3.4.0", features = ["server"] }
|
||||||
thiserror = "2.0.20"
|
thiserror = "2.0.20"
|
||||||
tower = { version = "0.5.3", features = ["util"] }
|
tower = { version = "0.5.3", features = ["util"] }
|
||||||
fastembed = "7.1.0"
|
candle-core = "0.11.0"
|
||||||
arboard = "3.4.0"
|
candle-nn = "0.11.0"
|
||||||
image = "0.25.0"
|
candle-transformers = "0.11.0"
|
||||||
|
tokenizers = { version = "0.23.1", default-features = false, features = ["onig"] }
|
||||||
|
hf-hub = { version = "1.0.0", features = ["blocking"] }
|
||||||
|
arboard = "3.6.1"
|
||||||
|
image = "0.25.10"
|
||||||
base64 = "0.23.1"
|
base64 = "0.23.1"
|
||||||
clipboard-win = "5.4.1"
|
|
||||||
git2 = "0.19.0"
|
git2 = "0.19.0"
|
||||||
tree-sitter = "0.23.0"
|
tree-sitter = "0.23.2"
|
||||||
tree-sitter-rust = "0.23.0"
|
tree-sitter-rust = "0.23.3"
|
||||||
tree-sitter-typescript = "0.23.0"
|
tree-sitter-typescript = "0.23.2"
|
||||||
tree-sitter-python = "0.23.0"
|
tree-sitter-python = "0.23.6"
|
||||||
tree-sitter-java = "0.23.5"
|
tree-sitter-java = "0.23.5"
|
||||||
tree-sitter-c = "0.24.2"
|
tree-sitter-c = "0.24.2"
|
||||||
tree-sitter-cpp = "0.23.4"
|
tree-sitter-cpp = "0.23.4"
|
||||||
tree-sitter-go = "0.25.0"
|
tree-sitter-go = "0.25.0"
|
||||||
ignore = "0.4.33"
|
ignore = "0.4.33"
|
||||||
notify = "7"
|
notify = "7"
|
||||||
|
blake3 = "1.8"
|
||||||
|
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]
|
[build-dependencies]
|
||||||
chrono = "0.4.45"
|
chrono = "0.4.45"
|
||||||
@@ -47,10 +60,19 @@ winres = "0.1.12"
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.27.0"
|
tempfile = "3.27.0"
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "bin_clipboard_test"
|
|
||||||
path = "src/bin_clipboard_test.rs"
|
|
||||||
|
|
||||||
[[bin]]
|
[target.'cfg(windows)'.dependencies]
|
||||||
name = "test_clip_write"
|
windows-sys = { version = "0.59.0", features = [
|
||||||
path = "src/test_clip_write.rs"
|
"Win32_System_DataExchange",
|
||||||
|
"Win32_System_Memory",
|
||||||
|
"Win32_UI_WindowsAndMessaging",
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_System_Com",
|
||||||
|
] }
|
||||||
|
windows = { version = "0.62.2", features = [
|
||||||
|
"Foundation",
|
||||||
|
"Foundation_Collections",
|
||||||
|
"Media_Ocr",
|
||||||
|
"Graphics_Imaging",
|
||||||
|
"Storage_Streams",
|
||||||
|
] }
|
||||||
+14
-3
@@ -16,15 +16,26 @@ fn main() {
|
|||||||
.is_ok_and(|out| !out.stdout.is_empty());
|
.is_ok_and(|out| !out.stdout.is_empty());
|
||||||
|
|
||||||
if is_dirty {
|
if is_dirty {
|
||||||
git_hash.push_str("-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 build_date = chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string();
|
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!("{build_date} ({git_hash})");
|
let version = format!("v{git_date} ({git_hash})");
|
||||||
println!("cargo:rustc-env=APP_VERSION={version}");
|
println!("cargo:rustc-env=APP_VERSION={version}");
|
||||||
println!("cargo:rerun-if-changed=../.git/HEAD");
|
println!("cargo:rerun-if-changed=../.git/HEAD");
|
||||||
println!("cargo:rerun-if-changed=../.git/index");
|
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");
|
||||||
|
|
||||||
if std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default() == "windows" {
|
if std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default() == "windows" {
|
||||||
let mut res = winres::WindowsResource::new();
|
let mut res = winres::WindowsResource::new();
|
||||||
|
|||||||
+104
-15
@@ -2,8 +2,11 @@ use crate::AppState;
|
|||||||
use crate::state::GenericEvent;
|
use crate::state::GenericEvent;
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tokio_stream::StreamExt;
|
||||||
|
use tokio_stream::wrappers::BroadcastStream;
|
||||||
|
|
||||||
pub async fn wait_for_event_handler(
|
pub async fn wait_for_event_handler(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
@@ -11,29 +14,83 @@ pub async fn wait_for_event_handler(
|
|||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let topic = params.get("topic").cloned();
|
let topic = params.get("topic").cloned();
|
||||||
let session_id = params.get("session_id").cloned();
|
let session_id = params.get("session_id").cloned();
|
||||||
|
let timeout_duration = params
|
||||||
|
.get("timeout_ms")
|
||||||
|
.and_then(|ms| ms.parse::<u64>().ok())
|
||||||
|
.map(tokio::time::Duration::from_millis)
|
||||||
|
.or_else(|| {
|
||||||
|
params
|
||||||
|
.get("timeout")
|
||||||
|
.and_then(|secs| secs.parse::<u64>().ok())
|
||||||
|
.map(tokio::time::Duration::from_secs)
|
||||||
|
});
|
||||||
|
|
||||||
let mut rx = state.handler.state.event_bus_tx.subscribe();
|
let mut rx = state.handler.state.event_bus_tx.subscribe();
|
||||||
|
|
||||||
loop {
|
let wait_future = async {
|
||||||
match rx.recv().await {
|
loop {
|
||||||
Ok(event) => {
|
match rx.recv().await {
|
||||||
let topic_matches = topic.as_ref().is_none_or(|t| t == &event.topic);
|
Ok(event) => {
|
||||||
let session_matches = session_id
|
let topic_matches = topic.as_ref().is_none_or(|t| t == &event.topic);
|
||||||
.as_ref()
|
let session_matches = session_id
|
||||||
.is_none_or(|s| Some(s) == event.session_id.as_ref());
|
.as_ref()
|
||||||
|
.is_none_or(|s| Some(s) == event.session_id.as_ref());
|
||||||
|
|
||||||
if topic_matches && session_matches {
|
if topic_matches && session_matches {
|
||||||
return axum::Json(event);
|
return Ok(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Event bus receiver lagged by {} messages; continuing wait.",
|
||||||
|
skipped
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||||
|
return Err("Event bus closed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
}
|
||||||
return axum::Json(GenericEvent {
|
};
|
||||||
|
|
||||||
|
match timeout_duration {
|
||||||
|
Some(dur) => match tokio::time::timeout(dur, wait_future).await {
|
||||||
|
Ok(Ok(event)) => (axum::http::StatusCode::OK, axum::Json(event)).into_response(),
|
||||||
|
Ok(Err(err)) => (
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
axum::Json(GenericEvent {
|
||||||
topic: "error".to_string(),
|
topic: "error".to_string(),
|
||||||
session_id: None,
|
session_id: None,
|
||||||
payload: serde_json::json!({"error": "Event bus lagged or closed"}),
|
payload: serde_json::json!({ "error": err }),
|
||||||
});
|
}),
|
||||||
}
|
)
|
||||||
}
|
.into_response(),
|
||||||
|
Err(_) => (
|
||||||
|
axum::http::StatusCode::REQUEST_TIMEOUT,
|
||||||
|
axum::Json(GenericEvent {
|
||||||
|
topic: "timeout".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({
|
||||||
|
"error": "timed out waiting for event",
|
||||||
|
"topic": topic
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
},
|
||||||
|
None => match wait_future.await {
|
||||||
|
Ok(event) => (axum::http::StatusCode::OK, axum::Json(event)).into_response(),
|
||||||
|
Err(err) => (
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
axum::Json(GenericEvent {
|
||||||
|
topic: "error".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({ "error": err }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +102,36 @@ pub async fn post_event_handler(
|
|||||||
axum::Json(serde_json::json!({"status": "ok"}))
|
axum::Json(serde_json::json!({"status": "ok"}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Real-time Server-Sent Events (SSE) stream for agent execution events and observability (ADR-0110)
|
||||||
|
pub async fn sse_events_handler(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let topic_filter = params.get("topic").cloned();
|
||||||
|
let session_filter = params.get("session_id").cloned();
|
||||||
|
let rx = state.handler.state.event_bus_tx.subscribe();
|
||||||
|
|
||||||
|
let stream = BroadcastStream::new(rx).filter_map(move |msg| match msg {
|
||||||
|
Ok(event) => {
|
||||||
|
let topic_matches = topic_filter.as_ref().is_none_or(|t| t == &event.topic);
|
||||||
|
let session_matches = session_filter
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|s| Some(s) == event.session_id.as_ref());
|
||||||
|
if topic_matches && session_matches {
|
||||||
|
let json_data = serde_json::to_string(&event).unwrap_or_default();
|
||||||
|
Some(Ok::<_, std::convert::Infallible>(
|
||||||
|
Event::default().event(event.topic).data(json_data),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => None,
|
||||||
|
});
|
||||||
|
|
||||||
|
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -61,10 +148,12 @@ mod tests {
|
|||||||
async fn test_events_wait_and_post() {
|
async fn test_events_wait_and_post() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let mem_state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
let mem_state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||||
|
let (shutdown_tx, _) = tokio::sync::oneshot::channel();
|
||||||
let app_state = Arc::new(AppState {
|
let app_state = Arc::new(AppState {
|
||||||
handler: Arc::new(MemoryHandler::new(mem_state.clone())),
|
handler: Arc::new(MemoryHandler::new(mem_state.clone())),
|
||||||
clients: RwLock::new(HashMap::new()),
|
clients: RwLock::new(HashMap::new()),
|
||||||
next_id: AtomicUsize::new(1),
|
next_id: AtomicUsize::new(1),
|
||||||
|
shutdown_tx: std::sync::Mutex::new(Some(shutdown_tx)),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start wait_for_event in a background task
|
// Start wait_for_event in a background task
|
||||||
|
|||||||
+42
-5
@@ -36,14 +36,19 @@ pub async fn gate_verify_handler(
|
|||||||
State(app_state): State<Arc<AppState>>,
|
State(app_state): State<Arc<AppState>>,
|
||||||
Query(q): Query<GateVerifyReq>,
|
Query(q): Query<GateVerifyReq>,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
let mut found = None;
|
let mut found = None;
|
||||||
let mut to_remove = None;
|
let mut to_remove = None;
|
||||||
app_state.handler.state.gates.modify(|gates| {
|
app_state.handler.state.env.gates.modify(|gates| {
|
||||||
if let Some(idx) = gates.iter().position(|g| {
|
if let Some(idx) = gates.iter().position(|g| {
|
||||||
g.action == q.action
|
g.action == q.action
|
||||||
&& g.target == q.target
|
&& g.target == q.target
|
||||||
&& g.namespace == q.namespace
|
&& g.namespace == q.namespace
|
||||||
&& g.params == q.params
|
&& g.params == q.params
|
||||||
|
&& g.expires_at.is_none_or(|exp| exp > now)
|
||||||
}) {
|
}) {
|
||||||
found = Some(gates[idx].clone());
|
found = Some(gates[idx].clone());
|
||||||
if q.consume {
|
if q.consume {
|
||||||
@@ -95,17 +100,47 @@ pub async fn gate_set_handler(
|
|||||||
status,
|
status,
|
||||||
reason: body.reason.clone(),
|
reason: body.reason.clone(),
|
||||||
timestamp: crate::handlers::utils::now_secs(),
|
timestamp: crate::handlers::utils::now_secs(),
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
app_state.handler.state.gates.modify(|gates| {
|
app_state.handler.state.env.gates.modify(|gates| {
|
||||||
gates.retain(|g| !(g.action == record.action && g.target == record.target));
|
gates.retain(|g| !(g.action == record.action && g.target == record.target));
|
||||||
gates.push(record);
|
gates.push(record.clone());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let summary = format!("Push gate {} set to {}", record.target, record.status);
|
||||||
|
app_state
|
||||||
|
.handler
|
||||||
|
.state
|
||||||
|
.record_activity("gate", &summary, record.reason.as_deref());
|
||||||
|
let _ = app_state
|
||||||
|
.handler
|
||||||
|
.state
|
||||||
|
.event_bus_tx
|
||||||
|
.send(crate::state::GenericEvent {
|
||||||
|
topic: "gate:event".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({
|
||||||
|
"action": record.action,
|
||||||
|
"target": record.target,
|
||||||
|
"status": record.status,
|
||||||
|
"reason": record.reason,
|
||||||
|
"active_gate": record.status,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
Ok((axum::http::StatusCode::OK, "Gate state updated."))
|
Ok((axum::http::StatusCode::OK, "Gate state updated."))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn health_handler() -> &'static str {
|
pub async fn health_handler() -> impl IntoResponse {
|
||||||
"OK"
|
axum::Json(serde_json::json!({
|
||||||
|
"status": "ok",
|
||||||
|
"version": {
|
||||||
|
"app_version": env!("APP_VERSION"),
|
||||||
|
"cargo_pkg_version": env!("CARGO_PKG_VERSION"),
|
||||||
|
"target_os": std::env::consts::OS,
|
||||||
|
"target_arch": std::env::consts::ARCH,
|
||||||
|
}
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -121,10 +156,12 @@ mod tests {
|
|||||||
async fn test_gate_handlers() {
|
async fn test_gate_handlers() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||||
|
let (shutdown_tx, _) = tokio::sync::oneshot::channel();
|
||||||
let app_state = Arc::new(AppState {
|
let app_state = Arc::new(AppState {
|
||||||
handler: Arc::new(MemoryHandler::new(state.clone())),
|
handler: Arc::new(MemoryHandler::new(state.clone())),
|
||||||
clients: RwLock::new(HashMap::new()),
|
clients: RwLock::new(HashMap::new()),
|
||||||
next_id: AtomicUsize::new(1),
|
next_id: AtomicUsize::new(1),
|
||||||
|
shutdown_tx: std::sync::Mutex::new(Some(shutdown_tx)),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set a gate to authorized
|
// Set a gate to authorized
|
||||||
|
|||||||
@@ -1,301 +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 sticky = state_clone.sticky.read_with(|items| items.len());
|
|
||||||
let error_fixes = state_clone.error_fixes.read_with(|items| items.len());
|
|
||||||
let pinned_files = state_clone.pinned_files.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 pr_checklists = state_clone.pr_checklists.read_with(|items| items.len());
|
|
||||||
let gates = state_clone.gates.read_with(|items| items.len());
|
|
||||||
let context_workspaces = state_clone.context_workspaces.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,
|
|
||||||
"sticky": sticky,
|
|
||||||
"error_fixes": error_fixes,
|
|
||||||
"pinned_files": pinned_files,
|
|
||||||
"session_summaries": session_summaries,
|
|
||||||
"handoff_memos": handoff_memos,
|
|
||||||
"env_fingerprints": env_fingerprints,
|
|
||||||
"env_requirements": env_requirements,
|
|
||||||
"milestones": milestones,
|
|
||||||
"environments": environments,
|
|
||||||
"pr_checklists": pr_checklists,
|
|
||||||
"gates": gates,
|
|
||||||
"context_workspaces": context_workspaces
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
+978
-92
File diff suppressed because it is too large.
Load diff
+438
-18
@@ -1,7 +1,112 @@
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::extract::State;
|
use axum::extract::{Query, State};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, serde::Serialize, Debug, Clone, PartialEq)]
|
||||||
|
pub struct ProjectedTechDebt {
|
||||||
|
pub id: String,
|
||||||
|
pub description: String,
|
||||||
|
pub severity: Option<String>,
|
||||||
|
pub line_range: Option<String>,
|
||||||
|
pub file_path: Option<String>,
|
||||||
|
pub ideal_solution: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, serde::Serialize, Debug, Clone, PartialEq)]
|
||||||
|
pub struct ProjectedAdr {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub decision: String,
|
||||||
|
pub status: String,
|
||||||
|
pub affected_components: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_projected_knowledge(
|
||||||
|
state: &crate::state::MemoryState,
|
||||||
|
file_path: &str,
|
||||||
|
) -> (Vec<ProjectedTechDebt>, Vec<ProjectedAdr>) {
|
||||||
|
let normalized_file = file_path.replace('\\', "/").to_lowercase();
|
||||||
|
let file_name = std::path::Path::new(&normalized_file)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let debts = state.code.tech_debts.read_with(|all_debts| {
|
||||||
|
all_debts
|
||||||
|
.iter()
|
||||||
|
.filter(|d| !d.is_resolved)
|
||||||
|
.filter(|d| {
|
||||||
|
if let Some(ref dfp) = d.file_path {
|
||||||
|
let ndfp = dfp.replace('\\', "/").to_lowercase();
|
||||||
|
if normalized_file == ndfp
|
||||||
|
|| 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))))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !file_name.is_empty()
|
||||||
|
&& file_name.len() > 3
|
||||||
|
&& d.description.to_lowercase().contains(&file_name)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
})
|
||||||
|
.map(|d| ProjectedTechDebt {
|
||||||
|
id: d.id.clone(),
|
||||||
|
description: d.description.clone(),
|
||||||
|
severity: d.severity.clone(),
|
||||||
|
line_range: d.line_range.clone(),
|
||||||
|
file_path: d.file_path.clone(),
|
||||||
|
ideal_solution: d.ideal_solution.clone(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
let adrs = state.code.adrs.read_with(|all_adrs| {
|
||||||
|
all_adrs
|
||||||
|
.iter()
|
||||||
|
.filter(|a| {
|
||||||
|
a.status.eq_ignore_ascii_case("accepted") || a.status.eq_ignore_ascii_case("active")
|
||||||
|
})
|
||||||
|
.filter(|a| {
|
||||||
|
for comp in &a.affected_components {
|
||||||
|
let ncomp = comp.replace('\\', "/").to_lowercase();
|
||||||
|
if !ncomp.is_empty()
|
||||||
|
&& (normalized_file.ends_with(&ncomp)
|
||||||
|
|| ncomp.ends_with(&normalized_file)
|
||||||
|
|| (!file_name.is_empty() && ncomp.contains(&file_name)))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !file_name.is_empty()
|
||||||
|
&& file_name.len() > 3
|
||||||
|
&& (a.title.to_lowercase().contains(&file_name)
|
||||||
|
|| a.decision.to_lowercase().contains(&file_name))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
})
|
||||||
|
.map(|a| ProjectedAdr {
|
||||||
|
id: a.id.clone(),
|
||||||
|
title: a.title.clone(),
|
||||||
|
decision: a.decision.clone(),
|
||||||
|
status: a.status.clone(),
|
||||||
|
affected_components: a.affected_components.clone(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
(debts, adrs)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize, serde::Serialize, Debug)]
|
#[derive(serde::Deserialize, serde::Serialize, Debug)]
|
||||||
pub struct NvimTelemetry {
|
pub struct NvimTelemetry {
|
||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
@@ -40,10 +145,19 @@ pub async fn nvim_telemetry_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Broadcast to UI WebSockets
|
// 2. Query Knowledge Graph for matching Tech Debts and ADRs (ADR-0103)
|
||||||
|
let (tech_debts, adrs) = if let Some(ref f) = payload.file {
|
||||||
|
find_projected_knowledge(&state.handler.state, f)
|
||||||
|
} else {
|
||||||
|
(Vec::new(), Vec::new())
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Broadcast to UI WebSockets
|
||||||
let ws_msg = serde_json::json!({
|
let ws_msg = serde_json::json!({
|
||||||
"type": "nvim_telemetry",
|
"type": "nvim_telemetry",
|
||||||
"data": payload
|
"data": payload,
|
||||||
|
"tech_debts": tech_debts,
|
||||||
|
"adrs": adrs
|
||||||
});
|
});
|
||||||
|
|
||||||
let msg_str = ws_msg.to_string();
|
let msg_str = ws_msg.to_string();
|
||||||
@@ -71,7 +185,104 @@ pub async fn nvim_telemetry_handler(
|
|||||||
let _ = state.handler.state.event_bus_tx.send(event);
|
let _ = state.handler.state.event_bus_tx.send(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
axum::Json(serde_json::json!({"status": "ok"}))
|
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),
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref f) = payload.file {
|
||||||
|
state.handler.state.record_activity(
|
||||||
|
"editor",
|
||||||
|
&format!("Neovim {}: {}", payload.event, f),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Extract active task & push gate for zero-polling statusline
|
||||||
|
let active_task = state.handler.state.project.tasks.read_with(|tasks| {
|
||||||
|
tasks
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.is_active())
|
||||||
|
.or_else(|| {
|
||||||
|
tasks
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.status == "pending" && t.parent_id.is_none())
|
||||||
|
})
|
||||||
|
.map(|t| t.title.clone())
|
||||||
|
});
|
||||||
|
|
||||||
|
let active_gate = state.handler.state.env.gates.read_with(|gates| {
|
||||||
|
gates
|
||||||
|
.iter()
|
||||||
|
.find(|g| g.action == "push" || g.gate_type.as_deref() == Some("quality_gate"))
|
||||||
|
.map(|g| g.status.clone())
|
||||||
|
});
|
||||||
|
|
||||||
|
axum::Json(serde_json::json!({
|
||||||
|
"status": "ok",
|
||||||
|
"tech_debts": tech_debts,
|
||||||
|
"adrs": adrs,
|
||||||
|
"active_task": active_task,
|
||||||
|
"active_gate": active_gate
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn nvim_knowledge_projection_handler(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
) -> impl axum::response::IntoResponse {
|
||||||
|
let (tech_debts, adrs) = if let Some(file_path) = params.get("file") {
|
||||||
|
find_projected_knowledge(&state.handler.state, file_path)
|
||||||
|
} else {
|
||||||
|
(Vec::new(), Vec::new())
|
||||||
|
};
|
||||||
|
|
||||||
|
axum::Json(serde_json::json!({
|
||||||
|
"status": "ok",
|
||||||
|
"tech_debts": tech_debts,
|
||||||
|
"adrs": adrs
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::models::TerminalHistory;
|
use crate::models::TerminalHistory;
|
||||||
@@ -79,20 +290,32 @@ use crate::models::TerminalHistory;
|
|||||||
pub async fn get_terminal_history_handler(
|
pub async fn get_terminal_history_handler(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
) -> impl axum::response::IntoResponse {
|
) -> impl axum::response::IntoResponse {
|
||||||
let history_json = state.handler.state.terminal_history.read_with(|h| serde_json::to_string(h).unwrap_or_else(|_| "[]".to_string()));
|
let history_json = state
|
||||||
([(axum::http::header::CONTENT_TYPE, "application/json")], history_json)
|
.handler
|
||||||
|
.state
|
||||||
|
.telemetry
|
||||||
|
.terminal_history
|
||||||
|
.read_with(|h| serde_json::to_string(h).unwrap_or_else(|_| "[]".to_string()));
|
||||||
|
(
|
||||||
|
[(axum::http::header::CONTENT_TYPE, "application/json")],
|
||||||
|
history_json,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn terminal_telemetry_handler(
|
pub async fn terminal_telemetry_handler(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
axum::Json(payload): axum::Json<TerminalHistory>,
|
axum::Json(payload): axum::Json<TerminalHistory>,
|
||||||
) -> impl axum::response::IntoResponse {
|
) -> impl axum::response::IntoResponse {
|
||||||
state.handler.state.terminal_history.modify(|history| {
|
state.handler.state.record_activity(
|
||||||
history.push_front(payload.clone());
|
"terminal",
|
||||||
if history.len() > 100 {
|
&format!("Ran command: {}", payload.command),
|
||||||
history.pop_back();
|
Some(&format!(
|
||||||
}
|
"exit_code: {}, cwd: {}",
|
||||||
});
|
payload.exit_code, payload.cwd
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
|
state.handler.state.record_terminal_history(payload.clone());
|
||||||
|
|
||||||
let ws_msg = serde_json::json!({
|
let ws_msg = serde_json::json!({
|
||||||
"type": "terminal_telemetry",
|
"type": "terminal_telemetry",
|
||||||
@@ -120,21 +343,23 @@ mod tests {
|
|||||||
use crate::router::MemoryHandler;
|
use crate::router::MemoryHandler;
|
||||||
use crate::state::MemoryState;
|
use crate::state::MemoryState;
|
||||||
use axum::http::Request;
|
use axum::http::Request;
|
||||||
use tower::ServiceExt;
|
use axum::routing::get;
|
||||||
|
use axum::routing::post;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::AtomicUsize;
|
use std::sync::atomic::AtomicUsize;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
use axum::routing::post;
|
use tower::ServiceExt;
|
||||||
use axum::routing::get;
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_terminal_history() {
|
async fn test_terminal_history() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||||
|
let (shutdown_tx, _) = tokio::sync::oneshot::channel();
|
||||||
let app_state = Arc::new(AppState {
|
let app_state = Arc::new(AppState {
|
||||||
handler: Arc::new(MemoryHandler::new(state.clone())),
|
handler: Arc::new(MemoryHandler::new(state.clone())),
|
||||||
clients: std::sync::RwLock::new(HashMap::new()),
|
clients: std::sync::RwLock::new(HashMap::new()),
|
||||||
next_id: AtomicUsize::new(1),
|
next_id: AtomicUsize::new(1),
|
||||||
|
shutdown_tx: std::sync::Mutex::new(Some(shutdown_tx)),
|
||||||
});
|
});
|
||||||
|
|
||||||
let app = axum::Router::new()
|
let app = axum::Router::new()
|
||||||
@@ -149,6 +374,8 @@ mod tests {
|
|||||||
cwd: "C:/Users/reazul.ashraf/workspace".to_string(),
|
cwd: "C:/Users/reazul.ashraf/workspace".to_string(),
|
||||||
os: "windows".to_string(),
|
os: "windows".to_string(),
|
||||||
timestamp: 12345,
|
timestamp: 12345,
|
||||||
|
error_output: None,
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Post the telemetry
|
// Post the telemetry
|
||||||
@@ -156,7 +383,9 @@ mod tests {
|
|||||||
.method("POST")
|
.method("POST")
|
||||||
.uri("/terminal/telemetry")
|
.uri("/terminal/telemetry")
|
||||||
.header("content-type", "application/json")
|
.header("content-type", "application/json")
|
||||||
.body(axum::body::Body::from(serde_json::to_string(&payload).unwrap()))
|
.body(axum::body::Body::from(
|
||||||
|
serde_json::to_string(&payload).unwrap(),
|
||||||
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let response = app.clone().oneshot(req).await.unwrap();
|
let response = app.clone().oneshot(req).await.unwrap();
|
||||||
@@ -172,10 +401,201 @@ mod tests {
|
|||||||
let response2 = app.oneshot(req2).await.unwrap();
|
let response2 = app.oneshot(req2).await.unwrap();
|
||||||
assert_eq!(response2.status(), 200);
|
assert_eq!(response2.status(), 200);
|
||||||
|
|
||||||
let body_bytes = axum::body::to_bytes(response2.into_body(), usize::MAX).await.unwrap();
|
let body_bytes = axum::body::to_bytes(response2.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let history: Vec<TerminalHistory> = serde_json::from_slice(&body_bytes).unwrap();
|
let history: Vec<TerminalHistory> = serde_json::from_slice(&body_bytes).unwrap();
|
||||||
assert_eq!(history.len(), 1);
|
assert_eq!(history.len(), 1);
|
||||||
assert_eq!(history[0].command, "echo test");
|
assert_eq!(history[0].command, "echo test");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_find_projected_knowledge_matching() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let state = MemoryState::new(dir.path().to_str().unwrap());
|
||||||
|
|
||||||
|
// Add 1 unresolved Tech Debt for server/src/lib.rs
|
||||||
|
state.code.tech_debts.modify(|debts| {
|
||||||
|
debts.push(crate::models::TechDebt {
|
||||||
|
id: "td-001".to_string(),
|
||||||
|
namespace: "default".to_string(),
|
||||||
|
description: "Refactor router dispatch in lib.rs".to_string(),
|
||||||
|
ideal_solution: "Use modular sub-routers".to_string(),
|
||||||
|
is_resolved: false,
|
||||||
|
created_at: 1000,
|
||||||
|
file_path: Some("server/src/lib.rs".to_string()),
|
||||||
|
line_range: Some("100-150".to_string()),
|
||||||
|
severity: Some("high".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
// Add 1 resolved Tech Debt which should NOT be projected
|
||||||
|
debts.push(crate::models::TechDebt {
|
||||||
|
id: "td-002".to_string(),
|
||||||
|
namespace: "default".to_string(),
|
||||||
|
description: "Old resolved debt".to_string(),
|
||||||
|
ideal_solution: "Done".to_string(),
|
||||||
|
is_resolved: true,
|
||||||
|
created_at: 500,
|
||||||
|
file_path: Some("server/src/lib.rs".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add 1 accepted ADR for server/src/lib.rs
|
||||||
|
state.code.adrs.modify(|adrs| {
|
||||||
|
adrs.push(crate::models::Adr {
|
||||||
|
id: "ADR-0103".to_string(),
|
||||||
|
title: "Virtual Text Knowledge Projection".to_string(),
|
||||||
|
context: "Context".to_string(),
|
||||||
|
decision: "Project tech debt and ADRs into active buffer".to_string(),
|
||||||
|
consequence: "High developer awareness".to_string(),
|
||||||
|
status: "accepted".to_string(),
|
||||||
|
timestamp: 2000,
|
||||||
|
affected_components: vec![
|
||||||
|
"server/src/lib.rs".to_string(),
|
||||||
|
"gemini-ui.lua".to_string(),
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
// Add 1 superseded ADR which should NOT be projected
|
||||||
|
adrs.push(crate::models::Adr {
|
||||||
|
id: "ADR-0099".to_string(),
|
||||||
|
title: "Old decision".to_string(),
|
||||||
|
context: "Context".to_string(),
|
||||||
|
decision: "Old".to_string(),
|
||||||
|
consequence: "Old".to_string(),
|
||||||
|
status: "superseded".to_string(),
|
||||||
|
timestamp: 1000,
|
||||||
|
affected_components: vec!["server/src/lib.rs".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test with Windows path format
|
||||||
|
let (debts, adrs) = find_projected_knowledge(
|
||||||
|
&state,
|
||||||
|
"C:\\Users\\reazul.ashraf\\workspace\\rust\\mcp-memory\\server\\src\\lib.rs",
|
||||||
|
);
|
||||||
|
assert_eq!(debts.len(), 1);
|
||||||
|
assert_eq!(debts[0].id, "td-001");
|
||||||
|
assert_eq!(debts[0].severity.as_deref(), Some("high"));
|
||||||
|
assert_eq!(adrs.len(), 1);
|
||||||
|
assert_eq!(adrs[0].id, "ADR-0103");
|
||||||
|
|
||||||
|
// Test with unrelated file
|
||||||
|
let (debts_unrelated, adrs_unrelated) =
|
||||||
|
find_projected_knowledge(&state, "crates/unrelated/src/foo.rs");
|
||||||
|
assert!(debts_unrelated.is_empty());
|
||||||
|
assert!(adrs_unrelated.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_nvim_telemetry_and_projection_endpoints() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||||
|
|
||||||
|
state.code.tech_debts.modify(|debts| {
|
||||||
|
debts.push(crate::models::TechDebt {
|
||||||
|
id: "td-active".to_string(),
|
||||||
|
namespace: "default".to_string(),
|
||||||
|
description: "Fix concurrency lock".to_string(),
|
||||||
|
ideal_solution: "Use tokio rwlock".to_string(),
|
||||||
|
is_resolved: false,
|
||||||
|
created_at: 1000,
|
||||||
|
file_path: Some("server/src/api/telemetry.rs".to_string()),
|
||||||
|
line_range: Some("25".to_string()),
|
||||||
|
severity: Some("medium".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
state.code.adrs.modify(|adrs| {
|
||||||
|
adrs.push(crate::models::Adr {
|
||||||
|
id: "ADR-0102".to_string(),
|
||||||
|
title: "Neovim RPC Circuit Breaker".to_string(),
|
||||||
|
context: "Context".to_string(),
|
||||||
|
decision: "Fail fast".to_string(),
|
||||||
|
consequence: "No hangs".to_string(),
|
||||||
|
status: "accepted".to_string(),
|
||||||
|
timestamp: 1000,
|
||||||
|
affected_components: vec!["telemetry.rs".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let (shutdown_tx, _) = tokio::sync::oneshot::channel();
|
||||||
|
let app_state = Arc::new(AppState {
|
||||||
|
handler: Arc::new(MemoryHandler::new(state.clone())),
|
||||||
|
clients: std::sync::RwLock::new(HashMap::new()),
|
||||||
|
next_id: AtomicUsize::new(1),
|
||||||
|
shutdown_tx: std::sync::Mutex::new(Some(shutdown_tx)),
|
||||||
|
});
|
||||||
|
|
||||||
|
let app = axum::Router::new()
|
||||||
|
.route("/nvim/telemetry", post(nvim_telemetry_handler))
|
||||||
|
.route(
|
||||||
|
"/api/nvim/projection",
|
||||||
|
get(nvim_knowledge_projection_handler),
|
||||||
|
)
|
||||||
|
.with_state(app_state.clone());
|
||||||
|
|
||||||
|
// 1. Post BufEnter telemetry
|
||||||
|
let payload = NvimTelemetry {
|
||||||
|
session_id: "test-session".to_string(),
|
||||||
|
event: "BufEnter".to_string(),
|
||||||
|
file: Some(
|
||||||
|
"C:/Users/reazul.ashraf/workspace/rust/mcp-memory/server/src/api/telemetry.rs"
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
line: Some(25),
|
||||||
|
col: Some(1),
|
||||||
|
prompt: None,
|
||||||
|
response: None,
|
||||||
|
index: None,
|
||||||
|
request_id: None,
|
||||||
|
diff_id: None,
|
||||||
|
start_row: None,
|
||||||
|
end_row: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/nvim/telemetry")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(axum::body::Body::from(
|
||||||
|
serde_json::to_string(&payload).unwrap(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let response = app.clone().oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
|
||||||
|
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
|
||||||
|
assert_eq!(body["status"], "ok");
|
||||||
|
assert_eq!(body["tech_debts"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(body["tech_debts"][0]["id"], "td-active");
|
||||||
|
assert_eq!(body["adrs"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(body["adrs"][0]["id"], "ADR-0102");
|
||||||
|
|
||||||
|
// 2. Query GET /api/nvim/projection
|
||||||
|
let req2 = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/api/nvim/projection?file=server/src/api/telemetry.rs")
|
||||||
|
.body(axum::body::Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let response2 = app.oneshot(req2).await.unwrap();
|
||||||
|
assert_eq!(response2.status(), 200);
|
||||||
|
|
||||||
|
let body_bytes2 = axum::body::to_bytes(response2.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let body2: serde_json::Value = serde_json::from_slice(&body_bytes2).unwrap();
|
||||||
|
assert_eq!(body2["status"], "ok");
|
||||||
|
assert_eq!(body2["tech_debts"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(body2["adrs"].as_array().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
+162
-19
@@ -29,6 +29,7 @@ pub struct SessionCleanup {
|
|||||||
pub state: Arc<AppState>,
|
pub state: Arc<AppState>,
|
||||||
pub send_task: tokio::task::JoinHandle<()>,
|
pub send_task: tokio::task::JoinHandle<()>,
|
||||||
pub recv_task: tokio::task::JoinHandle<()>,
|
pub recv_task: tokio::task::JoinHandle<()>,
|
||||||
|
pub event_task: tokio::task::JoinHandle<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for SessionCleanup {
|
impl Drop for SessionCleanup {
|
||||||
@@ -41,6 +42,7 @@ impl Drop for SessionCleanup {
|
|||||||
.remove(&self.session_id);
|
.remove(&self.session_id);
|
||||||
self.send_task.abort();
|
self.send_task.abort();
|
||||||
self.recv_task.abort();
|
self.recv_task.abort();
|
||||||
|
self.event_task.abort();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +56,40 @@ pub async fn handle_socket(socket: WebSocket, state: Arc<AppState>, _client_type
|
|||||||
.unwrap_or_else(|e| e.into_inner())
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
.insert(session_id.clone(), tx.clone());
|
.insert(session_id.clone(), tx.clone());
|
||||||
|
|
||||||
|
// Reconnection Catch-Up: Replay recent TASK_EVENT notifications so client receives missed Futures
|
||||||
|
let recent_task_notifications: Vec<String> = state
|
||||||
|
.handler
|
||||||
|
.state
|
||||||
|
.telemetry
|
||||||
|
.recent_activities
|
||||||
|
.read_with(|activities| {
|
||||||
|
activities
|
||||||
|
.iter()
|
||||||
|
.filter_map(|act_val| {
|
||||||
|
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
|
||||||
|
})
|
||||||
|
.take(5)
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
for notif in recent_task_notifications.into_iter().rev() {
|
||||||
|
let _ = tx.try_send(notif);
|
||||||
|
}
|
||||||
|
|
||||||
let (mut sender, mut receiver) = socket.split();
|
let (mut sender, mut receiver) = socket.split();
|
||||||
|
|
||||||
let send_task = tokio::spawn(async move {
|
let send_task = tokio::spawn(async move {
|
||||||
@@ -71,8 +107,8 @@ pub async fn handle_socket(socket: WebSocket, state: Arc<AppState>, _client_type
|
|||||||
});
|
});
|
||||||
|
|
||||||
let handler = Arc::clone(&state.handler);
|
let handler = Arc::clone(&state.handler);
|
||||||
let state_clone = Arc::clone(&state);
|
|
||||||
let session_id_clone = session_id.clone();
|
let session_id_clone = session_id.clone();
|
||||||
|
let response_tx = tx.clone();
|
||||||
|
|
||||||
let recv_task = tokio::spawn(async move {
|
let recv_task = tokio::spawn(async move {
|
||||||
while let Some(msg_result) = receiver.next().await {
|
while let Some(msg_result) = receiver.next().await {
|
||||||
@@ -87,24 +123,11 @@ pub async fn handle_socket(socket: WebSocket, state: Arc<AppState>, _client_type
|
|||||||
// Process MCP request
|
// Process MCP request
|
||||||
if let Some(response) = handler.handle_request(payload).await {
|
if let Some(response) = handler.handle_request(payload).await {
|
||||||
let res_str = serde_json::to_string(&response).unwrap_or_else(|e| format!(r#"{{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{{\"code\":-32603,\"message\":\"{}\"}}}}"#, e));
|
let res_str = serde_json::to_string(&response).unwrap_or_else(|e| format!(r#"{{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{{\"code\":-32603,\"message\":\"{}\"}}}}"#, e));
|
||||||
let tx_opt = state_clone
|
if let Err(e) = response_tx.send(res_str).await {
|
||||||
.clients
|
tracing::error!(
|
||||||
.read()
|
"Failed to send response to client channel for session {}: {}",
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
session_id_clone,
|
||||||
.get(&session_id_clone)
|
e
|
||||||
.cloned();
|
|
||||||
if let Some(client_tx) = tx_opt {
|
|
||||||
if let Err(e) = client_tx.send(res_str).await {
|
|
||||||
tracing::error!(
|
|
||||||
"Failed to send response to client channel for session {}: {}",
|
|
||||||
session_id_clone,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tracing::warn!(
|
|
||||||
"Could not find client_tx for session_id {} when trying to send response",
|
|
||||||
session_id_clone
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,11 +149,42 @@ pub async fn handle_socket(socket: WebSocket, state: Arc<AppState>, _client_type
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let event_tx = tx.clone();
|
||||||
|
let mut event_bus_rx = state.handler.state.event_bus_tx.subscribe();
|
||||||
|
let event_task = tokio::spawn(async move {
|
||||||
|
while let Ok(event) = event_bus_rx.recv().await {
|
||||||
|
if event.topic == "resource:updated" {
|
||||||
|
if let Some(uri) = event.payload.get("uri").and_then(|u| u.as_str()) {
|
||||||
|
let notif = serde_json::json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "notifications/resources/updated",
|
||||||
|
"params": {
|
||||||
|
"uri": uri
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if event_tx.send(notif.to_string()).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if event.topic == "task:event" {
|
||||||
|
let notif = serde_json::json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "notifications/task/updated",
|
||||||
|
"params": event.payload
|
||||||
|
});
|
||||||
|
if event_tx.send(notif.to_string()).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let mut cleanup = SessionCleanup {
|
let mut cleanup = SessionCleanup {
|
||||||
session_id: session_id.clone(),
|
session_id: session_id.clone(),
|
||||||
state: Arc::clone(&state),
|
state: Arc::clone(&state),
|
||||||
send_task,
|
send_task,
|
||||||
recv_task,
|
recv_task,
|
||||||
|
event_task,
|
||||||
};
|
};
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -156,10 +210,12 @@ mod tests {
|
|||||||
async fn test_session_cleanup_drop() {
|
async fn test_session_cleanup_drop() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||||
|
let (shutdown_tx, _) = tokio::sync::oneshot::channel();
|
||||||
let app_state = Arc::new(AppState {
|
let app_state = Arc::new(AppState {
|
||||||
handler: Arc::new(MemoryHandler::new(state)),
|
handler: Arc::new(MemoryHandler::new(state)),
|
||||||
clients: RwLock::new(HashMap::new()),
|
clients: RwLock::new(HashMap::new()),
|
||||||
next_id: AtomicUsize::new(1),
|
next_id: AtomicUsize::new(1),
|
||||||
|
shutdown_tx: std::sync::Mutex::new(Some(shutdown_tx)),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Insert a dummy client
|
// Insert a dummy client
|
||||||
@@ -171,6 +227,7 @@ mod tests {
|
|||||||
|
|
||||||
let send_task = tokio::spawn(async {});
|
let send_task = tokio::spawn(async {});
|
||||||
let recv_task = tokio::spawn(async {});
|
let recv_task = tokio::spawn(async {});
|
||||||
|
let event_task = tokio::spawn(async {});
|
||||||
|
|
||||||
{
|
{
|
||||||
let _cleanup = SessionCleanup {
|
let _cleanup = SessionCleanup {
|
||||||
@@ -178,9 +235,95 @@ mod tests {
|
|||||||
state: app_state.clone(),
|
state: app_state.clone(),
|
||||||
send_task,
|
send_task,
|
||||||
recv_task,
|
recv_task,
|
||||||
|
event_task,
|
||||||
};
|
};
|
||||||
} // Drop happens here
|
} // Drop happens here
|
||||||
|
|
||||||
assert!(app_state.clients.read().unwrap().is_empty());
|
assert!(app_state.clients.read().unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_task_event_broadcast_and_reconnection_replay() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let mem_state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||||
|
|
||||||
|
let task_event = crate::models::TaskEvent {
|
||||||
|
task_id: "task-999".to_string(),
|
||||||
|
status: "completed".to_string(),
|
||||||
|
action: Some("update".to_string()),
|
||||||
|
result: Some(serde_json::json!({"status": "completed"})),
|
||||||
|
error: None,
|
||||||
|
timestamp: 1728129000,
|
||||||
|
session_id: None,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Broadcast task event
|
||||||
|
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());
|
||||||
|
assert!(!recorded.is_empty());
|
||||||
|
assert_eq!(recorded[0]["category"], "TASK_EVENT");
|
||||||
|
|
||||||
|
// Verify reconnection catch-up replay fetches the notification
|
||||||
|
let (shutdown_tx, _) = tokio::sync::oneshot::channel();
|
||||||
|
let app_state = Arc::new(AppState {
|
||||||
|
handler: Arc::new(MemoryHandler::new(mem_state)),
|
||||||
|
clients: RwLock::new(HashMap::new()),
|
||||||
|
next_id: AtomicUsize::new(1),
|
||||||
|
shutdown_tx: std::sync::Mutex::new(Some(shutdown_tx)),
|
||||||
|
});
|
||||||
|
|
||||||
|
let (tx, mut rx) = mpsc::channel::<String>(10);
|
||||||
|
app_state
|
||||||
|
.clients
|
||||||
|
.write()
|
||||||
|
.unwrap()
|
||||||
|
.insert("session-1".to_string(), tx.clone());
|
||||||
|
|
||||||
|
let recent_notifications: Vec<String> = app_state
|
||||||
|
.handler
|
||||||
|
.state
|
||||||
|
.telemetry
|
||||||
|
.recent_activities
|
||||||
|
.read_with(|activities| {
|
||||||
|
activities
|
||||||
|
.iter()
|
||||||
|
.filter_map(|act_val| {
|
||||||
|
if act_val["category"] == "TASK_EVENT" {
|
||||||
|
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
|
||||||
|
})
|
||||||
|
.take(5)
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
for notif in recent_notifications {
|
||||||
|
let _ = tx.try_send(notif);
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
assert_eq!(parsed["params"]["status"], "completed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
use mcp_memory_server::router::MemoryHandler;
|
||||||
|
use mcp_memory_server::state::MemoryState;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("mcp_schemas_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir)?;
|
||||||
|
|
||||||
|
let state = Arc::new(MemoryState::new(temp_dir.to_str().unwrap()));
|
||||||
|
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");
|
||||||
|
|
||||||
|
fs::create_dir_all(&win_dir)?;
|
||||||
|
if !wsl_dir.exists() {
|
||||||
|
let _ = fs::create_dir_all(&wsl_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
let active_tools: std::collections::HashSet<String> = handler.tools.keys().cloned().collect();
|
||||||
|
|
||||||
|
println!("Exporting {} active tools schemas...", active_tools.len());
|
||||||
|
|
||||||
|
for (name, tool) in &handler.tools {
|
||||||
|
let schema = tool.schema();
|
||||||
|
let json_str = serde_json::to_string_pretty(&schema)?;
|
||||||
|
|
||||||
|
let win_path = win_dir.join(format!("{}.json", name));
|
||||||
|
fs::write(&win_path, &json_str)?;
|
||||||
|
|
||||||
|
let wsl_path = wsl_dir.join(format!("{}.json", name));
|
||||||
|
let _ = fs::write(&wsl_path, &json_str);
|
||||||
|
|
||||||
|
println!(" [OK] Exported {}.json", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up stale JSON files in Windows directory
|
||||||
|
if let Ok(entries) = fs::read_dir(&win_dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up stale JSON files in WSL directory
|
||||||
|
if let Ok(entries) = fs::read_dir(&wsl_dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.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");
|
||||||
|
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 _ = fs::copy(&instructions_src, server_instructions);
|
||||||
|
println!(" [OK] Synchronized instructions.md to Win, WSL, and server/src/instructions.md");
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
|
||||||
|
println!("Schema export complete!");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
fn main() {
|
||||||
|
let mut img = image::DynamicImage::new_rgb8(10, 10);
|
||||||
|
img.invert();
|
||||||
|
let _img2 = img.adjust_contrast(20.0);
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
use clipboard_win::{formats, get_clipboard, Clipboard};
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
if let Ok(_clip) = Clipboard::new_attempts(10) {
|
|
||||||
let text: Result<String, _> = get_clipboard(formats::Unicode);
|
|
||||||
let files: Result<Vec<String>, _> = get_clipboard(formats::FileList);
|
|
||||||
println!("Text: {:?}", text.ok());
|
|
||||||
println!("Files: {:?}", files.ok());
|
|
||||||
} else {
|
|
||||||
println!("Failed to open clipboard");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
use crate::state::MemoryState;
|
|
||||||
use crate::models::StickyNote;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::time::{sleep, Duration};
|
|
||||||
use clipboard_win::{formats, get_clipboard, Clipboard};
|
|
||||||
|
|
||||||
pub fn spawn_watcher(state: Arc<MemoryState>) {
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut last_text = String::new();
|
|
||||||
loop {
|
|
||||||
sleep(Duration::from_millis(1000)).await;
|
|
||||||
|
|
||||||
let is_enabled = {
|
|
||||||
let watch = state.clipboard_watch_mode.read().await;
|
|
||||||
*watch
|
|
||||||
};
|
|
||||||
|
|
||||||
if !is_enabled {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(_clip) = tokio::task::spawn_blocking(|| Clipboard::new_attempts(3)).await.unwrap() {
|
|
||||||
if let Ok(text) = get_clipboard::<String, _>(formats::Unicode) {
|
|
||||||
if text != last_text && !text.trim().is_empty() {
|
|
||||||
last_text = text.clone();
|
|
||||||
|
|
||||||
let note = StickyNote {
|
|
||||||
timestamp: std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_secs(),
|
|
||||||
content: text,
|
|
||||||
expires_at: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
state.sticky.modify(|notes| {
|
|
||||||
notes.push(note.clone());
|
|
||||||
});
|
|
||||||
|
|
||||||
// We use rebuild_index to index the new sticky note
|
|
||||||
state.rebuild_index().await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+379
-672
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+130
-57
@@ -1,69 +1,142 @@
|
|||||||
use crate::store::STORE_TABLE;
|
use crate::store::STORE_TABLE;
|
||||||
use redb::{Database, ReadableTable};
|
use redb::Database;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub fn init_redb(base: &Path) -> Arc<Database> {
|
pub fn init_redb(base: &Path) -> Arc<Database> {
|
||||||
let redb_path = base.join("mcp_store.redb");
|
let is_in_memory = base == Path::new(":memory:") || base.to_str() == Some(":memory:");
|
||||||
let db = match redb::Database::create(&redb_path) {
|
let db = if is_in_memory {
|
||||||
Ok(db) => Arc::new(db),
|
Arc::new(
|
||||||
Err(e) => {
|
redb::Database::builder()
|
||||||
let err_msg = format!("CRITICAL FATAL ERROR: Failed to open or create redb database at {:?}. Error: {}. This almost always means another instance of mcp-memory-server is already running and holds the exclusive file lock on the database.", redb_path, e);
|
.create_with_backend(redb::backends::InMemoryBackend::new())
|
||||||
eprintln!("{}", err_msg);
|
.expect("Failed to create in-memory redb database"),
|
||||||
tracing::error!("{}", err_msg);
|
)
|
||||||
std::process::exit(1);
|
} else {
|
||||||
}
|
let redb_path = base.join("mcp_store.redb");
|
||||||
};
|
if redb_path.exists() {
|
||||||
|
let mut db_opt = None;
|
||||||
// Ensure the table exists and migrate legacy JSON files
|
let mut last_open_err = String::new();
|
||||||
let write_txn = db.begin_write().expect("Failed to begin write txn on redb");
|
for attempt in 1..=3 {
|
||||||
{
|
match redb::Database::open(&redb_path) {
|
||||||
let mut table = write_txn
|
Ok(db) => {
|
||||||
.open_table(STORE_TABLE)
|
db_opt = Some(Arc::new(db));
|
||||||
.expect("Failed to open STORE_TABLE");
|
break;
|
||||||
|
}
|
||||||
let stores = vec![
|
Err(open_err) => {
|
||||||
("knowledge_graph_master", "knowledge_graph_master.json"),
|
last_open_err = open_err.to_string();
|
||||||
("audit_ledger", "audit_ledger.json"),
|
if attempt < 3 {
|
||||||
("sticky_notes", "sticky_notes.json"),
|
tracing::warn!(
|
||||||
("tasks", "tasks.json"),
|
"Transient lock contention opening redb at {:?} (attempt {}/3: {}). Retrying...",
|
||||||
("snippets", "snippets.json"),
|
redb_path,
|
||||||
("adrs", "adrs.json"),
|
attempt,
|
||||||
("preferences", "preferences.json"),
|
open_err
|
||||||
("error_fixes", "error_fixes.json"),
|
);
|
||||||
("pinned_files", "pinned_files.json"),
|
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||||
("session_summaries", "session_summaries.json"),
|
}
|
||||||
("handoff_memos", "handoff_memos.json"),
|
}
|
||||||
("env_fingerprints", "env_fingerprints.json"),
|
}
|
||||||
("env_requirements", "env_requirements.json"),
|
}
|
||||||
("milestones", "milestones.json"),
|
if let Some(db) = db_opt {
|
||||||
("environments", "environments.json"),
|
db
|
||||||
("pr_checklists", "pr_checklists.json"),
|
} else {
|
||||||
("tech_debts", "tech_debts.json"),
|
let err_msg = format!(
|
||||||
("gates", "gates.json"),
|
"Failed to open existing redb database at {:?}: {}. Attempting to recreate database.",
|
||||||
("context_workspaces", "context_workspaces.json"),
|
redb_path, last_open_err
|
||||||
];
|
);
|
||||||
|
tracing::warn!("{}", err_msg);
|
||||||
for (key, file_name) in stores.iter() {
|
match redb::Database::create(&redb_path) {
|
||||||
if table
|
Ok(db) => Arc::new(db),
|
||||||
.get(*key)
|
Err(create_err) => {
|
||||||
.expect("Failed to read from table")
|
if std::env::var("MCP_ALLOW_TMP_FALLBACK").unwrap_or_default() == "1" {
|
||||||
.is_none()
|
let temp_path = std::env::temp_dir()
|
||||||
{
|
.join(format!("mcp_store_fallback_{}.redb", uuid::Uuid::new_v4()));
|
||||||
let json_path = base.join(file_name);
|
tracing::warn!(
|
||||||
if json_path.exists()
|
"CRITICAL PERSISTENCE ALERT: MCP_ALLOW_TMP_FALLBACK=1 set. Using temporary redb database {:?}. Changes will be discarded upon application exit.",
|
||||||
&& let Ok(data) = std::fs::read(&json_path)
|
temp_path
|
||||||
&& serde_json::from_slice::<serde_json::Value>(&data).is_ok()
|
);
|
||||||
{
|
Arc::new(
|
||||||
table
|
redb::Database::create(&temp_path)
|
||||||
.insert(*key, data.as_slice())
|
.expect("Failed to create fallback redb database"),
|
||||||
.expect("Failed to insert migrated data");
|
)
|
||||||
let _ = std::fs::rename(&json_path, json_path.with_extension("json.migrated"));
|
} else {
|
||||||
|
panic!(
|
||||||
|
"CRITICAL PERSISTENCE FAILURE: Unable to open or create primary database at {:?}: (open: {}, create: {}). To prevent silent data loss on restart, the application cannot start without accessible persistence.",
|
||||||
|
redb_path, last_open_err, create_err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match redb::Database::create(&redb_path) {
|
||||||
|
Ok(db) => Arc::new(db),
|
||||||
|
Err(create_err) => {
|
||||||
|
let mut db_opt = None;
|
||||||
|
let mut last_open_err = String::new();
|
||||||
|
for attempt in 1..=3 {
|
||||||
|
match redb::Database::open(&redb_path) {
|
||||||
|
Ok(db) => {
|
||||||
|
db_opt = Some(Arc::new(db));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(open_err) => {
|
||||||
|
last_open_err = open_err.to_string();
|
||||||
|
if attempt < 3 {
|
||||||
|
tracing::warn!(
|
||||||
|
"Transient lock contention opening redb at {:?} (attempt {}/3: {}). Retrying...",
|
||||||
|
redb_path,
|
||||||
|
attempt,
|
||||||
|
open_err
|
||||||
|
);
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(db) = db_opt {
|
||||||
|
db
|
||||||
|
} else if std::env::var("MCP_ALLOW_TMP_FALLBACK").unwrap_or_default() == "1" {
|
||||||
|
let temp_path = std::env::temp_dir()
|
||||||
|
.join(format!("mcp_store_fallback_{}.redb", uuid::Uuid::new_v4()));
|
||||||
|
tracing::warn!(
|
||||||
|
"CRITICAL PERSISTENCE ALERT: MCP_ALLOW_TMP_FALLBACK=1 set. Using temporary redb database {:?}. Changes will be discarded upon application exit.",
|
||||||
|
temp_path
|
||||||
|
);
|
||||||
|
Arc::new(
|
||||||
|
redb::Database::create(&temp_path)
|
||||||
|
.expect("Failed to create fallback redb database"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
panic!(
|
||||||
|
"CRITICAL PERSISTENCE FAILURE: Unable to open or create primary database at {:?}: (create: {}, open: {}). To prevent silent data loss on restart, the application cannot start without accessible persistence.",
|
||||||
|
redb_path, create_err, last_open_err
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure the table exists
|
||||||
|
match db.begin_write() {
|
||||||
|
Ok(write_txn) => {
|
||||||
|
let mut opened_ok = false;
|
||||||
|
if let Ok(_) = write_txn.open_table(STORE_TABLE) {
|
||||||
|
opened_ok = true;
|
||||||
|
}
|
||||||
|
if opened_ok {
|
||||||
|
if let Err(e) = write_txn.commit() {
|
||||||
|
tracing::error!("Failed to commit database initialization transaction: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to begin write transaction for redb initialization: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
write_txn.commit().expect("Failed to commit db migration");
|
|
||||||
|
|
||||||
db
|
db
|
||||||
}
|
}
|
||||||
+357
-24
@@ -1,53 +1,386 @@
|
|||||||
#[allow(deprecated)]
|
|
||||||
use fastembed::{TextEmbedding, EmbeddingModel};
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
static EMBEDDING_MODEL: OnceLock<Mutex<TextEmbedding>> = OnceLock::new();
|
use candle_core::{DType, Device, Tensor};
|
||||||
|
use candle_nn::VarBuilder;
|
||||||
|
use candle_transformers::models::bert::{BertModel, Config};
|
||||||
|
use tokenizers::Tokenizer;
|
||||||
|
|
||||||
#[allow(deprecated)]
|
struct CandleEmbeddingModel {
|
||||||
pub fn get_embedding_model() -> Result<&'static Mutex<TextEmbedding>, String> {
|
model: BertModel,
|
||||||
|
tokenizer: Tokenizer,
|
||||||
|
device: Device,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CandleEmbeddingModel {
|
||||||
|
fn new() -> Result<Self, String> {
|
||||||
|
if cfg!(test) || std::env::var("MCP_OFFLINE_EMBEDDINGS").is_ok() {
|
||||||
|
return Err("Offline embedding mode active (tests/offline flag)".to_string());
|
||||||
|
}
|
||||||
|
let client = hf_hub::HFClientSync::new().map_err(|e| e.to_string())?;
|
||||||
|
let repo = client.model("sentence-transformers", "all-MiniLM-L6-v2");
|
||||||
|
|
||||||
|
let config_file = repo
|
||||||
|
.download_file()
|
||||||
|
.filename("config.json")
|
||||||
|
.send()
|
||||||
|
.map_err(|e| format!("Failed to download config.json: {}", e))?;
|
||||||
|
let tokenizer_file = repo
|
||||||
|
.download_file()
|
||||||
|
.filename("tokenizer.json")
|
||||||
|
.send()
|
||||||
|
.map_err(|e| format!("Failed to download tokenizer.json: {}", e))?;
|
||||||
|
let weights_file = repo
|
||||||
|
.download_file()
|
||||||
|
.filename("model.safetensors")
|
||||||
|
.send()
|
||||||
|
.map_err(|e| format!("Failed to download model.safetensors: {}", e))?;
|
||||||
|
|
||||||
|
let config_str = std::fs::read_to_string(&config_file)
|
||||||
|
.map_err(|e| format!("Failed to read config.json: {}", e))?;
|
||||||
|
let config: Config = serde_json::from_str(&config_str)
|
||||||
|
.map_err(|e| format!("Failed to parse config.json: {}", e))?;
|
||||||
|
|
||||||
|
let mut tokenizer = Tokenizer::from_file(&tokenizer_file)
|
||||||
|
.map_err(|e| format!("Failed to load tokenizer: {}", e))?;
|
||||||
|
tokenizer.with_padding(Some(tokenizers::PaddingParams::default()));
|
||||||
|
|
||||||
|
let device = Device::Cpu;
|
||||||
|
let vb = unsafe {
|
||||||
|
VarBuilder::from_mmaped_safetensors(&[weights_file], DType::F32, &device)
|
||||||
|
.map_err(|e| format!("Failed to load safetensors: {}", e))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let model =
|
||||||
|
BertModel::load(vb, &config).map_err(|e| format!("Failed to load BertModel: {}", e))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
model,
|
||||||
|
tokenizer,
|
||||||
|
device,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, String> {
|
||||||
|
if texts.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let encodings = self
|
||||||
|
.tokenizer
|
||||||
|
.encode_batch(texts.to_vec(), true)
|
||||||
|
.map_err(|e| format!("Failed to encode texts: {}", e))?;
|
||||||
|
|
||||||
|
let batch_size = encodings.len();
|
||||||
|
if batch_size == 0 {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let seq_len = encodings[0].get_ids().len();
|
||||||
|
if seq_len == 0 {
|
||||||
|
return Ok(vec![vec![0.0; 384]; batch_size]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut all_ids: Vec<u32> = Vec::with_capacity(batch_size * seq_len);
|
||||||
|
let mut all_type_ids: Vec<u32> = Vec::with_capacity(batch_size * seq_len);
|
||||||
|
let mut all_attention_mask: Vec<u32> = Vec::with_capacity(batch_size * seq_len);
|
||||||
|
|
||||||
|
for enc in &encodings {
|
||||||
|
all_ids.extend(enc.get_ids());
|
||||||
|
all_type_ids.extend(enc.get_type_ids());
|
||||||
|
all_attention_mask.extend(enc.get_attention_mask());
|
||||||
|
}
|
||||||
|
|
||||||
|
let input_ids = Tensor::from_vec(all_ids, (batch_size, seq_len), &self.device)
|
||||||
|
.map_err(|e| format!("Failed to build input_ids tensor: {}", e))?;
|
||||||
|
let token_type_ids = Tensor::from_vec(all_type_ids, (batch_size, seq_len), &self.device)
|
||||||
|
.map_err(|e| format!("Failed to build token_type_ids tensor: {}", e))?;
|
||||||
|
let attention_mask =
|
||||||
|
Tensor::from_vec(all_attention_mask, (batch_size, seq_len), &self.device)
|
||||||
|
.map_err(|e| format!("Failed to build attention_mask tensor: {}", e))?;
|
||||||
|
|
||||||
|
let sequence_output = self
|
||||||
|
.model
|
||||||
|
.forward(&input_ids, &token_type_ids, Some(&attention_mask))
|
||||||
|
.map_err(|e| format!("Bert forward failed: {}", e))?;
|
||||||
|
|
||||||
|
// Mean pooling: sum(sequence_output * mask) / clamp(sum(mask), min=1e-9)
|
||||||
|
let mask_f32 = attention_mask
|
||||||
|
.to_dtype(DType::F32)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.unsqueeze(2)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let sum_embeddings = sequence_output
|
||||||
|
.broadcast_mul(&mask_f32)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.sum(1)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let sum_mask = mask_f32
|
||||||
|
.sum(1)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.clamp(1e-9, f32::MAX)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mean_pooled = sum_embeddings
|
||||||
|
.broadcast_div(&sum_mask)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// L2 Normalization
|
||||||
|
let norm = mean_pooled
|
||||||
|
.sqr()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.sum_keepdim(1)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.sqrt()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let normalized = mean_pooled
|
||||||
|
.broadcast_div(&norm)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
normalized.to_vec2::<f32>().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fallback_embed(text: &str) -> Vec<f32> {
|
||||||
|
const DIM: usize = 384;
|
||||||
|
let mut vec = vec![0.0f32; DIM];
|
||||||
|
let words: Vec<&str> = text.split_whitespace().collect();
|
||||||
|
if words.is_empty() {
|
||||||
|
vec[0] = 1.0;
|
||||||
|
return vec;
|
||||||
|
}
|
||||||
|
|
||||||
|
for word in words {
|
||||||
|
let clean: String = word
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_alphanumeric())
|
||||||
|
.flat_map(|c| c.to_lowercase())
|
||||||
|
.collect();
|
||||||
|
if clean.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash_bytes = blake3::hash(clean.as_bytes());
|
||||||
|
let h = u64::from_le_bytes(hash_bytes.as_bytes()[0..8].try_into().unwrap());
|
||||||
|
let idx = (h as usize) % DIM;
|
||||||
|
let sign = if (h >> 32) & 1 == 0 { 1.0f32 } else { -1.0f32 };
|
||||||
|
vec[idx] += sign;
|
||||||
|
|
||||||
|
let chars: Vec<char> = clean.chars().collect();
|
||||||
|
for window in chars.windows(3) {
|
||||||
|
let window_str: String = window.iter().collect();
|
||||||
|
let h2_bytes = blake3::hash(window_str.as_bytes());
|
||||||
|
let hv = u64::from_le_bytes(h2_bytes.as_bytes()[0..8].try_into().unwrap());
|
||||||
|
let idx2 = (hv as usize) % DIM;
|
||||||
|
let s2 = if (hv >> 32) & 1 == 0 { 0.5f32 } else { -0.5f32 };
|
||||||
|
vec[idx2] += s2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let norm_sq: f32 = vec.iter().map(|x| x * x).sum();
|
||||||
|
if norm_sq > 0.0 {
|
||||||
|
let norm = norm_sq.sqrt();
|
||||||
|
for x in vec.iter_mut() {
|
||||||
|
*x /= norm;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
vec[0] = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
vec
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EmbeddingModel {
|
||||||
|
Candle(Box<CandleEmbeddingModel>),
|
||||||
|
Fallback,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EmbeddingModel {
|
||||||
|
fn new() -> Self {
|
||||||
|
match CandleEmbeddingModel::new() {
|
||||||
|
Ok(model) => EmbeddingModel::Candle(Box::new(model)),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Failed to initialize Candle BERT model ({e}); falling back to deterministic offline embeddings."
|
||||||
|
);
|
||||||
|
EmbeddingModel::Fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, String> {
|
||||||
|
match self {
|
||||||
|
EmbeddingModel::Candle(model) => model.embed(texts),
|
||||||
|
EmbeddingModel::Fallback => Ok(texts.iter().map(|t| fallback_embed(t)).collect()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static EMBEDDING_MODEL: OnceLock<Mutex<EmbeddingModel>> = OnceLock::new();
|
||||||
|
static INIT_MUTEX: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
fn get_embedding_model() -> Result<&'static Mutex<EmbeddingModel>, String> {
|
||||||
if let Some(model) = EMBEDDING_MODEL.get() {
|
if let Some(model) = EMBEDDING_MODEL.get() {
|
||||||
return Ok(model);
|
return Ok(model);
|
||||||
}
|
}
|
||||||
let options = fastembed::InitOptions::new(EmbeddingModel::AllMiniLML6V2)
|
let _guard = INIT_MUTEX.lock().map_err(|e| e.to_string())?;
|
||||||
.with_show_download_progress(true);
|
if let Some(model) = EMBEDDING_MODEL.get() {
|
||||||
|
return Ok(model);
|
||||||
let model = TextEmbedding::try_new(options).map_err(|e| e.to_string())?;
|
}
|
||||||
|
let model = EmbeddingModel::new();
|
||||||
let _ = EMBEDDING_MODEL.set(Mutex::new(model));
|
let _ = EMBEDDING_MODEL.set(Mutex::new(model));
|
||||||
Ok(EMBEDDING_MODEL.get().unwrap())
|
Ok(EMBEDDING_MODEL.get().unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn generate_embedding_async(text: String) -> Result<Vec<f32>, String> {
|
pub async fn generate_embedding_async(text: String) -> Result<Vec<f32>, String> {
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let model_mutex = get_embedding_model()?;
|
let model_mutex = get_embedding_model()?;
|
||||||
let mut model = model_mutex.lock().map_err(|e| e.to_string())?;
|
let model = model_mutex.lock().map_err(|e| e.to_string())?;
|
||||||
let embeddings = model.embed(vec![text], None).map_err(|e| e.to_string())?;
|
let embeddings = model.embed(&[text])?;
|
||||||
Ok(embeddings.into_iter().next().unwrap_or_default())
|
let emb = embeddings
|
||||||
}).await.map_err(|e| e.to_string())?
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "Embedding model returned no embeddings".to_string())?;
|
||||||
|
if emb.is_empty() {
|
||||||
|
return Err("Embedding model generated a 0-length vector".to_string());
|
||||||
|
}
|
||||||
|
Ok(emb)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||||
if a.is_empty() || b.is_empty() || a.len() != b.len() {
|
if a.is_empty() || b.is_empty() || a.len() != b.len() {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
let dot_product: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
|
let mut dot_product = 0.0f32;
|
||||||
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
|
let mut norm_a_sq = 0.0f32;
|
||||||
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
|
let mut norm_b_sq = 0.0f32;
|
||||||
if norm_a == 0.0 || norm_b == 0.0 {
|
|
||||||
|
let (chunks_a, remainder_a) = a.as_chunks::<8>();
|
||||||
|
let (chunks_b, remainder_b) = b.as_chunks::<8>();
|
||||||
|
|
||||||
|
for (ca, cb) in chunks_a.iter().zip(chunks_b.iter()) {
|
||||||
|
for i in 0..8 {
|
||||||
|
let x = ca[i];
|
||||||
|
let y = cb[i];
|
||||||
|
dot_product += x * y;
|
||||||
|
norm_a_sq += x * x;
|
||||||
|
norm_b_sq += y * y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (&x, &y) in remainder_a.iter().zip(remainder_b.iter()) {
|
||||||
|
dot_product += x * y;
|
||||||
|
norm_a_sq += x * x;
|
||||||
|
norm_b_sq += y * y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast path: If vectors are already normalized (Candle & fallback embeddings), skip square roots
|
||||||
|
if (norm_a_sq - 1.0).abs() < 1e-4 && (norm_b_sq - 1.0).abs() < 1e-4 {
|
||||||
|
return dot_product.clamp(-1.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let norm_product = norm_a_sq * norm_b_sq;
|
||||||
|
if norm_product <= 0.0 {
|
||||||
0.0
|
0.0
|
||||||
} else {
|
} else {
|
||||||
dot_product / (norm_a * norm_b)
|
(dot_product / norm_product.sqrt()).clamp(-1.0, 1.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn generate_embeddings_async(texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> {
|
pub async fn generate_embeddings_async(texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> {
|
||||||
if texts.is_empty() {
|
if texts.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let model_mutex = get_embedding_model()?;
|
let model_mutex = get_embedding_model()?;
|
||||||
let mut model = model_mutex.lock().map_err(|e| e.to_string())?;
|
let model = model_mutex.lock().map_err(|e| e.to_string())?;
|
||||||
let embeddings = model.embed(texts, None).map_err(|e| e.to_string())?;
|
let mut all_embeddings = Vec::with_capacity(texts.len());
|
||||||
Ok(embeddings)
|
|
||||||
}).await.map_err(|e| e.to_string())?
|
let mut current_chunk = Vec::new();
|
||||||
|
let mut current_chars = 0;
|
||||||
|
const MAX_CHARS_PER_BATCH: usize = 16384;
|
||||||
|
|
||||||
|
for text in texts {
|
||||||
|
let text_len = text.len();
|
||||||
|
if !current_chunk.is_empty()
|
||||||
|
&& (current_chunk.len() >= 64 || current_chars + text_len > MAX_CHARS_PER_BATCH)
|
||||||
|
{
|
||||||
|
let chunk_vec = std::mem::take(&mut current_chunk);
|
||||||
|
let chunk_embeddings = model.embed(&chunk_vec)?;
|
||||||
|
all_embeddings.extend(chunk_embeddings);
|
||||||
|
current_chars = 0;
|
||||||
|
}
|
||||||
|
current_chars += text_len;
|
||||||
|
current_chunk.push(text);
|
||||||
|
}
|
||||||
|
if !current_chunk.is_empty() {
|
||||||
|
let chunk_embeddings = model.embed(¤t_chunk)?;
|
||||||
|
all_embeddings.extend(chunk_embeddings);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(all_embeddings)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cosine_similarity_edge_cases() {
|
||||||
|
assert_eq!(cosine_similarity(&[], &[]), 0.0);
|
||||||
|
assert_eq!(cosine_similarity(&[1.0, 2.0], &[1.0]), 0.0);
|
||||||
|
assert_eq!(cosine_similarity(&[0.0, 0.0], &[0.0, 0.0]), 0.0);
|
||||||
|
|
||||||
|
let v1 = vec![1.0, 0.0, 0.0];
|
||||||
|
let v2 = vec![1.0, 0.0, 0.0];
|
||||||
|
assert!((cosine_similarity(&v1, &v2) - 1.0).abs() < 1e-5);
|
||||||
|
|
||||||
|
let v3 = vec![0.0, 1.0, 0.0];
|
||||||
|
assert!((cosine_similarity(&v1, &v3) - 0.0).abs() < 1e-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_generate_embeddings_async_empty() {
|
||||||
|
let res = generate_embeddings_async(vec![]).await.unwrap();
|
||||||
|
assert!(res.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_generate_embeddings_async_single_text() {
|
||||||
|
let text = "test text".to_string();
|
||||||
|
let res = generate_embeddings_async(vec![text.clone()]).await.unwrap();
|
||||||
|
assert_eq!(res.len(), 1);
|
||||||
|
assert_eq!(res[0].len(), 384);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_generate_embeddings_async_multiple_texts() {
|
||||||
|
let texts = vec![
|
||||||
|
"test text 1".to_string(),
|
||||||
|
"test text 2".to_string(),
|
||||||
|
"test text 3".to_string(),
|
||||||
|
];
|
||||||
|
let res = generate_embeddings_async(texts.clone()).await.unwrap();
|
||||||
|
assert_eq!(res.len(), 3);
|
||||||
|
for embedding in &res {
|
||||||
|
assert_eq!(embedding.len(), 384);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fallback_embed_deterministic_stability() {
|
||||||
|
let text = "The quick brown fox jumps over the lazy dog";
|
||||||
|
let emb1 = fallback_embed(text);
|
||||||
|
let emb2 = fallback_embed(text);
|
||||||
|
assert_eq!(emb1.len(), 384);
|
||||||
|
assert_eq!(emb1, emb2);
|
||||||
|
assert!((cosine_similarity(&emb1, &emb2) - 1.0).abs() < 1e-5);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+81
-1
@@ -6,11 +6,26 @@ use axum::{
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, AppError>;
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum AppError {
|
pub enum AppError {
|
||||||
#[error("Not Found: {0}")]
|
#[error("Not Found: {0}")]
|
||||||
NotFound(String),
|
NotFound(String),
|
||||||
|
|
||||||
|
#[error("Entity '{name}' was not found in namespace '{namespace}'")]
|
||||||
|
EntityNotFound { name: String, namespace: String },
|
||||||
|
|
||||||
|
#[error("Invalid relation between '{from}' and '{to}': {reason}")]
|
||||||
|
InvalidRelation {
|
||||||
|
from: String,
|
||||||
|
to: String,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error("Database error for key '{key}': {message}")]
|
||||||
|
DatabaseError { key: String, message: String },
|
||||||
|
|
||||||
#[error("Forbidden: {0}")]
|
#[error("Forbidden: {0}")]
|
||||||
Forbidden(String),
|
Forbidden(String),
|
||||||
|
|
||||||
@@ -19,15 +34,60 @@ pub enum AppError {
|
|||||||
|
|
||||||
#[error("Bad Request: {0}")]
|
#[error("Bad Request: {0}")]
|
||||||
BadRequest(String),
|
BadRequest(String),
|
||||||
|
|
||||||
|
#[error("Serialization Error: {0}")]
|
||||||
|
JsonError(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[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 {
|
||||||
|
fn from(err: AppError) -> String {
|
||||||
|
err.to_string()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for AppError {
|
impl IntoResponse for AppError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, error_message) = match &self {
|
let (status, error_message) = match &self {
|
||||||
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
||||||
|
AppError::EntityNotFound { name, namespace } => (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
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
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AppError::DatabaseError { key, message } => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Database error for key '{}': {}", key, message),
|
||||||
|
),
|
||||||
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()),
|
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()),
|
||||||
AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()),
|
|
||||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
||||||
|
AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()),
|
||||||
|
AppError::JsonError(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("JSON Error: {}", e),
|
||||||
|
),
|
||||||
|
AppError::IoError(e) => (
|
||||||
|
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!({
|
let body = Json(json!({
|
||||||
@@ -87,3 +147,23 @@ mod tests {
|
|||||||
assert_eq!(err.to_string(), "Bad Request: test");
|
assert_eq!(err.to_string(), "Bad Request: test");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<String> for AppError {
|
||||||
|
fn from(msg: String) -> Self {
|
||||||
|
AppError::Internal(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for AppError {
|
||||||
|
fn from(msg: &str) -> Self {
|
||||||
|
AppError::Internal(msg.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generated by improve-test (Attempt 1)
|
||||||
|
#[test]
|
||||||
|
fn test_app_error_unknown_error() {
|
||||||
|
let err = AppError::Internal("unknown error".into());
|
||||||
|
let res = err.into_response();
|
||||||
|
assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
+542
-39
@@ -4,7 +4,25 @@ use crate::tools::ReadFileSkeletonTool;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tree_sitter::{Parser, Node};
|
use tree_sitter::{Node, Parser};
|
||||||
|
|
||||||
|
fn validate_safe_path(path_str: &str) -> crate::error::Result<()> {
|
||||||
|
if path_str.trim().is_empty() || path_str.contains('\0') {
|
||||||
|
return Err(crate::error::AppError::Internal(
|
||||||
|
"Invalid file path: path is empty or contains null characters".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let path = std::path::Path::new(path_str);
|
||||||
|
for component in path.components() {
|
||||||
|
if component == std::path::Component::ParentDir {
|
||||||
|
return Err(crate::error::AppError::Internal(format!(
|
||||||
|
"Path traversal forbidden: '{}' contains parent directory relative components",
|
||||||
|
path_str
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ReadFileSkeletonHandler;
|
pub struct ReadFileSkeletonHandler;
|
||||||
|
|
||||||
@@ -21,15 +39,17 @@ impl McpTool for ReadFileSkeletonHandler {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> Result<String, String> {
|
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||||
let tool_args: ReadFileSkeletonTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
let tool_args: ReadFileSkeletonTool =
|
||||||
|
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let file_path = tool_args.file_path.clone();
|
let file_path = tool_args.file_path.clone();
|
||||||
let result = tokio::task::spawn_blocking(move || -> Result<String, String> {
|
validate_safe_path(&file_path)?;
|
||||||
let code = std::fs::read_to_string(&file_path)
|
|
||||||
.map_err(|e| format!("Failed to read file: {}", e))?;
|
|
||||||
|
|
||||||
let mut parser = Parser::new();
|
let result = tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
|
||||||
|
let code = std::fs::read_to_string(&file_path).map_err(|e| {
|
||||||
|
crate::error::AppError::Internal(format!("Failed to read file: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
let ext = std::path::Path::new(&file_path)
|
let ext = std::path::Path::new(&file_path)
|
||||||
.extension()
|
.extension()
|
||||||
@@ -47,23 +67,78 @@ impl McpTool for ReadFileSkeletonHandler {
|
|||||||
_ => return Ok(code),
|
_ => return Ok(code),
|
||||||
};
|
};
|
||||||
|
|
||||||
parser.set_language(&language.into()).map_err(|e| e.to_string())?;
|
let mut parser = tree_sitter::Parser::new();
|
||||||
|
parser
|
||||||
let tree = parser.parse(&code, None).ok_or("Failed to parse code")?;
|
.set_language(&language.into())
|
||||||
let mut result_skeleton = String::new();
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
|
||||||
|
let tree = parser.parse(&code, None).ok_or_else(|| {
|
||||||
|
crate::error::AppError::Internal("Failed to parse code".to_string())
|
||||||
|
})?;
|
||||||
|
let mut result_skeleton = String::with_capacity(code.len() / 2);
|
||||||
|
|
||||||
fn extract_skeleton(node: Node, code: &str, out: &mut String, depth: usize) {
|
fn extract_skeleton(node: Node, code: &str, out: &mut String, depth: usize) {
|
||||||
|
if depth > 128 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let kind = node.kind();
|
let kind = node.kind();
|
||||||
|
let is_container = matches!(
|
||||||
|
kind,
|
||||||
|
"impl_item"
|
||||||
|
| "class_declaration"
|
||||||
|
| "class_definition"
|
||||||
|
| "trait_item"
|
||||||
|
| "interface_declaration"
|
||||||
|
);
|
||||||
|
|
||||||
let is_structural = match kind {
|
let is_structural = is_container
|
||||||
"use_declaration" | "import_statement" | "import_from_statement" => true,
|
|| matches!(
|
||||||
"struct_item" | "enum_item" | "trait_item" | "impl_item" => true,
|
kind,
|
||||||
"function_item" | "function_declaration" | "function_definition" | "method_definition" => true,
|
"use_declaration"
|
||||||
"interface_declaration" | "type_alias_declaration" | "class_declaration" | "class_definition" => true,
|
| "import_statement"
|
||||||
_ => false,
|
| "import_from_statement"
|
||||||
};
|
| "struct_item"
|
||||||
|
| "enum_item"
|
||||||
|
| "function_item"
|
||||||
|
| "function_declaration"
|
||||||
|
| "function_definition"
|
||||||
|
| "method_definition"
|
||||||
|
| "type_alias_declaration"
|
||||||
|
);
|
||||||
|
|
||||||
if is_structural {
|
if is_container {
|
||||||
|
let indent = " ".repeat(depth);
|
||||||
|
let node_text = node.utf8_text(code.as_bytes()).unwrap_or("");
|
||||||
|
let mut header = String::new();
|
||||||
|
for line in node_text.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.ends_with('{') || trimmed.ends_with(':') {
|
||||||
|
header.push_str(line);
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
header.push_str(line);
|
||||||
|
header.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if header.is_empty()
|
||||||
|
&& let Some(first_line) = node_text.lines().next()
|
||||||
|
{
|
||||||
|
header = first_line.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push_str(&indent);
|
||||||
|
out.push_str(header.trim());
|
||||||
|
out.push('\n');
|
||||||
|
|
||||||
|
let mut cursor = node.walk();
|
||||||
|
for child in node.named_children(&mut cursor) {
|
||||||
|
extract_skeleton(child, code, out, depth + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if header.trim().ends_with('{') {
|
||||||
|
out.push_str(&indent);
|
||||||
|
out.push_str("}\n");
|
||||||
|
}
|
||||||
|
} else if is_structural {
|
||||||
let indent = " ".repeat(depth);
|
let indent = " ".repeat(depth);
|
||||||
|
|
||||||
let node_text = node.utf8_text(code.as_bytes()).unwrap_or("");
|
let node_text = node.utf8_text(code.as_bytes()).unwrap_or("");
|
||||||
@@ -84,7 +159,7 @@ impl McpTool for ReadFileSkeletonHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
out.push_str(&indent);
|
out.push_str(&indent);
|
||||||
out.push_str(&signature.trim());
|
out.push_str(signature.trim());
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
} else if node.is_named() {
|
} else if node.is_named() {
|
||||||
let mut cursor = node.walk();
|
let mut cursor = node.walk();
|
||||||
@@ -103,7 +178,7 @@ impl McpTool for ReadFileSkeletonHandler {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Task panic: {}", e))??;
|
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??;
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
@@ -121,17 +196,21 @@ impl McpTool for ReplaceAstNodeHandler {
|
|||||||
fn schema(&self) -> Value {
|
fn schema(&self) -> Value {
|
||||||
crate::mcp::tool_def::<ReplaceAstNodeTool>(
|
crate::mcp::tool_def::<ReplaceAstNodeTool>(
|
||||||
"replace_ast_node",
|
"replace_ast_node",
|
||||||
"Replace a specific AST node (e.g., function, struct) entirely using tree-sitter for robust structural editing.",
|
"Replace a specific AST node (e.g., function, struct, enum, class, trait) entirely using tree-sitter for robust structural editing. Supported node_type values include: 'function_item' (or 'function'/'fn'/'method'), 'struct_item' (or 'struct'), 'class_declaration' (or 'class'), 'enum_item' (or 'enum'), 'trait_item' (or 'trait'/'interface'), 'type_alias_declaration' (or 'type').",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> Result<String, String> {
|
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||||
let tool_args: ReplaceAstNodeTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
let tool_args: ReplaceAstNodeTool =
|
||||||
|
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let file_path = tool_args.file_path.clone();
|
let file_path = tool_args.file_path.clone();
|
||||||
let result = tokio::task::spawn_blocking(move || -> Result<String, String> {
|
validate_safe_path(&file_path)?;
|
||||||
let code = std::fs::read_to_string(&file_path)
|
|
||||||
.map_err(|e| format!("Failed to read file: {}", e))?;
|
let result = tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
|
||||||
|
let code = std::fs::read_to_string(&file_path).map_err(|e| {
|
||||||
|
crate::error::AppError::Internal(format!("Failed to read file: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
let mut parser = Parser::new();
|
let mut parser = Parser::new();
|
||||||
|
|
||||||
@@ -148,20 +227,74 @@ impl McpTool for ReplaceAstNodeHandler {
|
|||||||
"c" | "h" => tree_sitter_c::LANGUAGE,
|
"c" | "h" => tree_sitter_c::LANGUAGE,
|
||||||
"cpp" | "cc" | "cxx" | "hpp" | "hxx" => tree_sitter_cpp::LANGUAGE,
|
"cpp" | "cc" | "cxx" | "hpp" | "hxx" => tree_sitter_cpp::LANGUAGE,
|
||||||
"go" => tree_sitter_go::LANGUAGE,
|
"go" => tree_sitter_go::LANGUAGE,
|
||||||
_ => return Err(format!("Unsupported language for AST replacement: {}", ext)),
|
_ => {
|
||||||
|
return Err(crate::error::AppError::Internal(format!(
|
||||||
|
"Unsupported language for AST replacement: {}",
|
||||||
|
ext
|
||||||
|
)));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
parser.set_language(&language.into()).map_err(|e| e.to_string())?;
|
parser
|
||||||
let tree = parser.parse(&code, None).ok_or("Failed to parse code")?;
|
.set_language(&language.into())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let tree = parser.parse(&code, None).ok_or_else(|| {
|
||||||
|
crate::error::AppError::Internal("Failed to parse code".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
fn matches_node_type(actual_kind: &str, requested_type: &str) -> bool {
|
||||||
|
if actual_kind == requested_type {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match requested_type.to_lowercase().as_str() {
|
||||||
|
"function" | "func" | "fn" | "method" | "def" => matches!(
|
||||||
|
actual_kind,
|
||||||
|
"function_item"
|
||||||
|
| "function_declaration"
|
||||||
|
| "function_definition"
|
||||||
|
| "method_definition"
|
||||||
|
| "function"
|
||||||
|
),
|
||||||
|
"struct" => matches!(
|
||||||
|
actual_kind,
|
||||||
|
"struct_item" | "struct_declaration" | "struct_specifier"
|
||||||
|
),
|
||||||
|
"class" => matches!(
|
||||||
|
actual_kind,
|
||||||
|
"class_declaration" | "class_definition" | "class_item"
|
||||||
|
),
|
||||||
|
"enum" => matches!(actual_kind, "enum_item" | "enum_declaration"),
|
||||||
|
"trait" | "interface" => matches!(
|
||||||
|
actual_kind,
|
||||||
|
"trait_item" | "interface_declaration" | "interface_item"
|
||||||
|
),
|
||||||
|
"impl" | "impl_item" => actual_kind == "impl_item",
|
||||||
|
"type" | "type_alias" => matches!(
|
||||||
|
actual_kind,
|
||||||
|
"type_alias_declaration" | "type_item" | "type_definition"
|
||||||
|
),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Search for the node
|
// Search for the node
|
||||||
fn find_node<'a>(node: Node<'a>, code: &str, target_type: &str, target_name: &str) -> Option<Node<'a>> {
|
fn find_node<'a>(
|
||||||
if node.kind() == target_type {
|
node: Node<'a>,
|
||||||
|
code: &str,
|
||||||
|
target_type: &str,
|
||||||
|
target_name: &str,
|
||||||
|
) -> Option<Node<'a>> {
|
||||||
|
if matches_node_type(node.kind(), target_type) {
|
||||||
// Try to find the name/identifier
|
// Try to find the name/identifier
|
||||||
let mut cursor = node.walk();
|
let mut cursor = node.walk();
|
||||||
for child in node.children(&mut cursor) {
|
for child in node.children(&mut cursor) {
|
||||||
let kind = child.kind();
|
let kind = child.kind();
|
||||||
if kind == "identifier" || kind == "name" {
|
if kind == "identifier"
|
||||||
|
|| kind == "name"
|
||||||
|
|| kind == "type_identifier"
|
||||||
|
|| kind == "property_identifier"
|
||||||
|
|| kind == "field_identifier"
|
||||||
|
{
|
||||||
let name = child.utf8_text(code.as_bytes()).unwrap_or("");
|
let name = child.utf8_text(code.as_bytes()).unwrap_or("");
|
||||||
if name == target_name {
|
if name == target_name {
|
||||||
return Some(node);
|
return Some(node);
|
||||||
@@ -179,26 +312,396 @@ impl McpTool for ReplaceAstNodeHandler {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
let target_node = find_node(tree.root_node(), &code, &tool_args.node_type, &tool_args.node_name);
|
let target_node = find_node(
|
||||||
|
tree.root_node(),
|
||||||
|
&code,
|
||||||
|
&tool_args.node_type,
|
||||||
|
&tool_args.node_name,
|
||||||
|
);
|
||||||
|
|
||||||
if let Some(node) = target_node {
|
if let Some(node) = target_node {
|
||||||
let start_byte = node.start_byte();
|
let start_byte = node.start_byte();
|
||||||
let end_byte = node.end_byte();
|
let end_byte = node.end_byte();
|
||||||
|
|
||||||
let mut new_file_content = String::new();
|
if !code.is_char_boundary(start_byte) || !code.is_char_boundary(end_byte) {
|
||||||
|
return Err(crate::error::AppError::Internal(format!(
|
||||||
|
"Byte offsets {}..{} do not fall on UTF-8 character boundaries in {}",
|
||||||
|
start_byte, end_byte, file_path
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut new_file_content =
|
||||||
|
String::with_capacity(code.len() + tool_args.new_content.len());
|
||||||
new_file_content.push_str(&code[..start_byte]);
|
new_file_content.push_str(&code[..start_byte]);
|
||||||
new_file_content.push_str(&tool_args.new_content);
|
new_file_content.push_str(&tool_args.new_content);
|
||||||
new_file_content.push_str(&code[end_byte..]);
|
new_file_content.push_str(&code[end_byte..]);
|
||||||
|
|
||||||
std::fs::write(&file_path, new_file_content).map_err(|e| e.to_string())?;
|
let target_path = std::path::PathBuf::from(&file_path);
|
||||||
Ok(format!("Successfully replaced node {} of type {} in {}", tool_args.node_name, tool_args.node_type, file_path))
|
let parent_dir = target_path
|
||||||
|
.parent()
|
||||||
|
.unwrap_or_else(|| std::path::Path::new("."));
|
||||||
|
let temp_file_path =
|
||||||
|
parent_dir.join(format!(".tmp_ast_{}.tmp", uuid::Uuid::new_v4()));
|
||||||
|
std::fs::write(&temp_file_path, new_file_content).map_err(|e| e.to_string())?;
|
||||||
|
if let Err(e) = std::fs::rename(&temp_file_path, &target_path) {
|
||||||
|
// On Windows, std::fs::rename fails if the target file already exists.
|
||||||
|
// Fall back to copy-and-remove to ensure atomic-like overwrite behavior.
|
||||||
|
if let Err(copy_err) = std::fs::copy(&temp_file_path, &target_path) {
|
||||||
|
let _ = std::fs::remove_file(&temp_file_path);
|
||||||
|
return Err(crate::error::AppError::Internal(format!(
|
||||||
|
"Failed to atomically overwrite {}: rename failed ({}), copy failed ({})",
|
||||||
|
file_path, e, copy_err
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_file(&temp_file_path);
|
||||||
|
}
|
||||||
|
Ok(format!(
|
||||||
|
"Successfully replaced node {} of type {} in {}",
|
||||||
|
tool_args.node_name, tool_args.node_type, file_path
|
||||||
|
))
|
||||||
} else {
|
} else {
|
||||||
Err(format!("Could not find node {} of type {}", tool_args.node_name, tool_args.node_type))
|
Err(crate::error::AppError::Internal(format!(
|
||||||
|
"Could not find node {} of type {}",
|
||||||
|
tool_args.node_name, tool_args.node_type
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Task panic: {}", e))??;
|
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??;
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn scan_workspace_for_symbol(
|
||||||
|
target_sym: &str,
|
||||||
|
limit: usize,
|
||||||
|
filter_fn_call: bool,
|
||||||
|
workspace_dir: Option<std::path::PathBuf>,
|
||||||
|
) -> Vec<serde_json::Value> {
|
||||||
|
let mut results = Vec::new();
|
||||||
|
let scan_dir = workspace_dir.unwrap_or_else(|| {
|
||||||
|
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
|
||||||
|
});
|
||||||
|
|
||||||
|
if !scan_dir.exists() {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
let walker = ignore::WalkBuilder::new(&scan_dir)
|
||||||
|
.hidden(true)
|
||||||
|
.git_ignore(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut scanned_files = 0;
|
||||||
|
|
||||||
|
let call_pattern = format!("{}(", target_sym);
|
||||||
|
let await_pattern = format!("{}.await", target_sym);
|
||||||
|
|
||||||
|
for result in walker {
|
||||||
|
let entry = match result {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if entry.file_type().is_some_and(|ft| ft.is_file()) {
|
||||||
|
let path = entry.path();
|
||||||
|
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||||
|
if matches!(
|
||||||
|
ext,
|
||||||
|
"rs" | "ts"
|
||||||
|
| "tsx"
|
||||||
|
| "js"
|
||||||
|
| "jsx"
|
||||||
|
| "py"
|
||||||
|
| "go"
|
||||||
|
| "java"
|
||||||
|
| "c"
|
||||||
|
| "cpp"
|
||||||
|
| "h"
|
||||||
|
| "hpp"
|
||||||
|
) {
|
||||||
|
scanned_files += 1;
|
||||||
|
if scanned_files > 500 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
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() {
|
||||||
|
let is_match = if filter_fn_call {
|
||||||
|
line.contains(&call_pattern) || line.contains(&await_pattern)
|
||||||
|
} else {
|
||||||
|
line.contains(target_sym)
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_match {
|
||||||
|
results.push(serde_json::json!({
|
||||||
|
"file_path": path.to_string_lossy(),
|
||||||
|
"line": line_num + 1,
|
||||||
|
"content": line.trim(),
|
||||||
|
}));
|
||||||
|
if results.len() >= limit {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FindSymbolReferencesHandler;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl McpTool for FindSymbolReferencesHandler {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"find_symbol_references"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schema(&self) -> Value {
|
||||||
|
crate::mcp::tool_def::<crate::tools::FindSymbolReferencesTool>(
|
||||||
|
"find_symbol_references",
|
||||||
|
"Find all source locations and AST chunks where a specific symbol is referenced or called.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||||
|
let req: crate::tools::FindSymbolReferencesTool =
|
||||||
|
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||||
|
let limit = req.limit.unwrap_or(10);
|
||||||
|
let target_sym = req.symbol.clone();
|
||||||
|
|
||||||
|
let mut matches = state.code.snippets.read_with(|snippets| {
|
||||||
|
let mut refs = Vec::new();
|
||||||
|
for snippet in snippets {
|
||||||
|
if snippet.code.contains(&target_sym) || snippet.name.contains(&target_sym) {
|
||||||
|
refs.push(serde_json::json!({
|
||||||
|
"source": "snippet",
|
||||||
|
"name": snippet.name,
|
||||||
|
"code": snippet.code,
|
||||||
|
}));
|
||||||
|
if refs.len() >= limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok::<Vec<serde_json::Value>, crate::error::AppError>(refs)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let custom_dir = req.workspace_dir.as_ref().map(std::path::PathBuf::from);
|
||||||
|
|
||||||
|
if matches.len() < limit {
|
||||||
|
let remaining = limit - matches.len();
|
||||||
|
let disk_matches = tokio::task::spawn_blocking(move || {
|
||||||
|
scan_workspace_for_symbol(&target_sym, remaining, false, custom_dir)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
matches.extend(disk_matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(serde_yaml::to_string(&matches)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GetCallersHandler;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl McpTool for GetCallersHandler {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"get_callers"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schema(&self) -> Value {
|
||||||
|
crate::mcp::tool_def::<crate::tools::GetCallersTool>(
|
||||||
|
"get_callers",
|
||||||
|
"Find all caller functions or methods that invoke a specified target function name.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||||
|
let req: crate::tools::GetCallersTool =
|
||||||
|
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||||
|
let limit = req.limit.unwrap_or(10);
|
||||||
|
let target_fn = req.function_name.clone();
|
||||||
|
let call_pattern = format!("{}(", target_fn);
|
||||||
|
let await_pattern = format!("{}.await", target_fn);
|
||||||
|
|
||||||
|
let mut callers = state.code.snippets.read_with(|snippets| {
|
||||||
|
let mut matching = Vec::new();
|
||||||
|
for snippet in snippets {
|
||||||
|
if snippet.code.contains(&call_pattern) || snippet.code.contains(&await_pattern) {
|
||||||
|
matching.push(serde_json::json!({
|
||||||
|
"source": "snippet",
|
||||||
|
"name": snippet.name,
|
||||||
|
"code": snippet.code,
|
||||||
|
}));
|
||||||
|
if matching.len() >= limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok::<Vec<serde_json::Value>, crate::error::AppError>(matching)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let custom_dir = req.workspace_dir.as_ref().map(std::path::PathBuf::from);
|
||||||
|
|
||||||
|
if callers.len() < limit {
|
||||||
|
let remaining = limit - callers.len();
|
||||||
|
let disk_callers = tokio::task::spawn_blocking(move || {
|
||||||
|
scan_workspace_for_symbol(&target_fn, remaining, true, custom_dir)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
callers.extend(disk_callers);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(serde_yaml::to_string(&callers)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AnalyzeImpactHandler;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl McpTool for AnalyzeImpactHandler {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"analyze_impact"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schema(&self) -> Value {
|
||||||
|
crate::mcp::tool_def::<crate::tools::AnalyzeImpactTool>(
|
||||||
|
"analyze_impact",
|
||||||
|
"Analyze the potential downstream breaking impact of modifying a function, struct, or file.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||||
|
let req: crate::tools::AnalyzeImpactTool =
|
||||||
|
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||||
|
let sym = req.target_symbol.clone();
|
||||||
|
|
||||||
|
let mut callers = Vec::new();
|
||||||
|
state.code.snippets.read_with(|snippets| {
|
||||||
|
for snippet in snippets {
|
||||||
|
if snippet.code.contains(&sym) {
|
||||||
|
callers.push(snippet.name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let custom_dir = req
|
||||||
|
.file_path
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| std::path::Path::new(p).parent().map(|p| p.to_path_buf()));
|
||||||
|
|
||||||
|
let sym_clone = sym.clone();
|
||||||
|
let disk_refs = tokio::task::spawn_blocking(move || {
|
||||||
|
scan_workspace_for_symbol(&sym_clone, 20, false, custom_dir)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
for r in &disk_refs {
|
||||||
|
if let Some(path) = r.get("file_path").and_then(|p| p.as_str()) {
|
||||||
|
let line = r.get("line").and_then(|l| l.as_u64()).unwrap_or(0);
|
||||||
|
callers.push(format!("{}:{}", path, line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut kg_connected = Vec::new();
|
||||||
|
state.read_graph(|g| {
|
||||||
|
for rel in &g.relations {
|
||||||
|
if rel.from == sym {
|
||||||
|
kg_connected.push(format!("Outgoing: {} -> {}", rel.relation_type, rel.to));
|
||||||
|
} else if rel.to == sym {
|
||||||
|
kg_connected.push(format!("Incoming: {} <- {}", rel.relation_type, rel.from));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let caller_count = callers.len();
|
||||||
|
let graph_count = kg_connected.len();
|
||||||
|
let risk_level = if caller_count > 10 || graph_count > 5 {
|
||||||
|
"CRITICAL"
|
||||||
|
} else if caller_count > 3 || graph_count > 2 {
|
||||||
|
"HIGH"
|
||||||
|
} else if caller_count > 0 || graph_count > 0 {
|
||||||
|
"MEDIUM"
|
||||||
|
} else {
|
||||||
|
"LOW"
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = serde_json::json!({
|
||||||
|
"target_symbol": sym,
|
||||||
|
"risk_level": risk_level,
|
||||||
|
"ast_callers_count": caller_count,
|
||||||
|
"ast_callers_sample": callers.into_iter().take(10).collect::<Vec<_>>(),
|
||||||
|
"graph_relations_count": graph_count,
|
||||||
|
"graph_relations": kg_connected,
|
||||||
|
"recommendation": match risk_level {
|
||||||
|
"CRITICAL" | "HIGH" => "Requires comprehensive unit test verification and backwards compatibility checks before modifying.",
|
||||||
|
"MEDIUM" => "Verify direct call sites and run affected module tests.",
|
||||||
|
_ => "Safe to modify with standard unit test verification.",
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(serde_yaml::to_string(&result)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_read_file_skeleton() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||||
|
let file_path = dir.path().join("test_skeleton.rs");
|
||||||
|
|
||||||
|
let code = "fn my_func() {\n let x = 1;\n}\n\nstruct MyStruct {\n val: i32\n}";
|
||||||
|
std::fs::write(&file_path, code).unwrap();
|
||||||
|
|
||||||
|
let handler = ReadFileSkeletonHandler;
|
||||||
|
let args = json!({
|
||||||
|
"file_path": file_path.to_str().unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
|
let res = handler.execute(args, state.clone()).await.unwrap();
|
||||||
|
assert!(res.contains("fn my_func()"));
|
||||||
|
assert!(res.contains("struct MyStruct"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_replace_ast_node() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||||
|
let file_path = dir.path().join("test_replace.rs");
|
||||||
|
|
||||||
|
let code = "fn my_func() {\n let x = 1;\n}\n\nstruct MyStruct {\n val: i32\n}";
|
||||||
|
std::fs::write(&file_path, code).unwrap();
|
||||||
|
|
||||||
|
let handler = ReplaceAstNodeHandler;
|
||||||
|
let args = json!({
|
||||||
|
"file_path": file_path.to_str().unwrap(),
|
||||||
|
"node_type": "function_item",
|
||||||
|
"node_name": "my_func",
|
||||||
|
"new_content": "fn my_func() {\n let x = 2;\n}"
|
||||||
|
});
|
||||||
|
|
||||||
|
let res = handler.execute(args, state.clone()).await.unwrap();
|
||||||
|
assert!(res.contains("Successfully replaced node"));
|
||||||
|
|
||||||
|
let new_code = std::fs::read_to_string(&file_path).unwrap();
|
||||||
|
assert!(new_code.contains("let x = 2;"));
|
||||||
|
assert!(!new_code.contains("let x = 1;"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+166
-159
@@ -5,162 +5,127 @@ use async_trait::async_trait;
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub struct UpdateEnvFingerprintHandler;
|
pub struct EnvironmentHandler;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl McpTool for UpdateEnvFingerprintHandler {
|
impl McpTool for EnvironmentHandler {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
"update_env_fingerprint"
|
"environment"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn schema(&self) -> Value {
|
fn schema(&self) -> Value {
|
||||||
crate::mcp::tool_def::<UpdateEnvFingerprintTool>(
|
crate::mcp::tool_def::<EnvironmentTool>(
|
||||||
"update_env_fingerprint",
|
"environment",
|
||||||
"Execute update_env_fingerprint",
|
"Consolidated environment & infrastructure management",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
|
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||||
let req: UpdateEnvFingerprintTool =
|
let req: EnvironmentTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
let ns = req
|
||||||
state.env_fingerprints.modify(|fps| {
|
.namespace
|
||||||
fps.insert(
|
.unwrap_or_else(crate::models::default_namespace);
|
||||||
req.namespace.clone(),
|
match req.action {
|
||||||
crate::models::EnvFingerprint {
|
EnvAction::UpdateFingerprint => {
|
||||||
namespace: req.namespace.clone(),
|
let tool_versions = req.tool_versions.unwrap_or_default();
|
||||||
os: std::env::consts::OS.to_string(),
|
let detected_os = req.os.unwrap_or_else(|| std::env::consts::OS.to_string());
|
||||||
shell: std::env::var("SHELL").unwrap_or_else(|_| "unknown".to_string()),
|
let detected_shell = req
|
||||||
tool_versions: req.tool_versions,
|
.shell
|
||||||
updated_at: crate::handlers::utils::now_secs(),
|
.or_else(|| std::env::var("SHELL").ok())
|
||||||
},
|
.or_else(|| std::env::var("STARSHIP_SHELL").ok())
|
||||||
);
|
.or_else(|| {
|
||||||
});
|
if std::env::var("PSModulePath").is_ok() {
|
||||||
Ok("Env fingerprint updated".to_string())
|
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());
|
||||||
|
|
||||||
pub struct ReadEnvFingerprintHandler;
|
state.env.env_fingerprints.modify(|fps| {
|
||||||
|
fps.insert(
|
||||||
#[async_trait]
|
ns.clone(),
|
||||||
impl McpTool for ReadEnvFingerprintHandler {
|
crate::models::EnvFingerprint {
|
||||||
fn name(&self) -> &'static str {
|
namespace: ns.clone(),
|
||||||
"read_env_fingerprint"
|
os: detected_os,
|
||||||
}
|
shell: detected_shell,
|
||||||
|
tool_versions,
|
||||||
fn schema(&self) -> Value {
|
updated_at: crate::handlers::utils::now_secs(),
|
||||||
crate::mcp::tool_def::<ReadEnvFingerprintTool>(
|
..Default::default()
|
||||||
"read_env_fingerprint",
|
},
|
||||||
"Execute read_env_fingerprint",
|
);
|
||||||
)
|
});
|
||||||
}
|
Ok("Env fingerprint updated".to_string())
|
||||||
|
}
|
||||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
|
EnvAction::ReadFingerprint => {
|
||||||
let req: ReadEnvFingerprintTool =
|
let data = state
|
||||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
.env
|
||||||
let data = state
|
.env_fingerprints
|
||||||
.env_fingerprints
|
.read_with(|fps| fps.get(&ns).cloned());
|
||||||
.read_with(|fps| fps.get(&req.namespace).cloned());
|
if let Some(fp) = data {
|
||||||
if let Some(fp) = data {
|
let data = Ok::<String, crate::error::AppError>(serde_yaml::to_string(&fp)?)?;
|
||||||
let data = serde_json::to_string(&fp).map_err(|e| e.to_string())?;
|
Ok(data)
|
||||||
Ok(data)
|
} else {
|
||||||
} else {
|
Ok("{}".to_string())
|
||||||
Ok("{}".to_string())
|
}
|
||||||
|
}
|
||||||
|
EnvAction::LogRequirement => {
|
||||||
|
let key = req.key.ok_or_else(|| {
|
||||||
|
crate::error::AppError::Internal("Missing required parameter 'key' for action 'log_requirement'. Next step: Provide environment variable 'key' string in request and retry.".to_string())
|
||||||
|
})?;
|
||||||
|
let desc = req.description.unwrap_or_default();
|
||||||
|
let secret = req.is_secret.unwrap_or(false);
|
||||||
|
state.env.env_requirements.modify(|reqs| {
|
||||||
|
reqs.retain(|r| !(r.namespace == ns && r.key == key));
|
||||||
|
reqs.push(crate::models::EnvRequirement {
|
||||||
|
namespace: ns,
|
||||||
|
key,
|
||||||
|
description: desc,
|
||||||
|
is_secret: secret,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Ok("Env requirement logged".to_string())
|
||||||
|
}
|
||||||
|
EnvAction::Register => {
|
||||||
|
let name = req.name.ok_or_else(|| {
|
||||||
|
crate::error::AppError::Internal("Missing required parameter 'name' for action 'register'. Next step: Provide environment 'name' string in request and retry.".to_string())
|
||||||
|
})?;
|
||||||
|
let url = req.url.unwrap_or_default();
|
||||||
|
let desc = req.description.unwrap_or_default();
|
||||||
|
let vpn = req.requires_vpn.unwrap_or(false);
|
||||||
|
state.env.environments.modify(|envs| {
|
||||||
|
envs.retain(|e| !(e.namespace == ns && e.name == name));
|
||||||
|
envs.push(crate::models::EnvironmentDetail {
|
||||||
|
namespace: ns,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
description: desc,
|
||||||
|
requires_vpn: vpn,
|
||||||
|
updated_at: crate::handlers::utils::now_secs(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Ok("Environment registered".to_string())
|
||||||
|
}
|
||||||
|
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_yaml::to_string(&filtered)?)
|
||||||
|
})?;
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct LogEnvRequirementHandler;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl McpTool for LogEnvRequirementHandler {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"log_env_requirement"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn schema(&self) -> Value {
|
|
||||||
crate::mcp::tool_def::<LogEnvRequirementTool>(
|
|
||||||
"log_env_requirement",
|
|
||||||
"Execute log_env_requirement",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
|
|
||||||
let req: LogEnvRequirementTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
|
||||||
state.env_requirements.modify(|reqs| {
|
|
||||||
reqs.retain(|r| !(r.namespace == req.namespace && r.key == req.key));
|
|
||||||
reqs.push(crate::models::EnvRequirement {
|
|
||||||
namespace: req.namespace,
|
|
||||||
key: req.key,
|
|
||||||
description: req.description,
|
|
||||||
is_secret: req.is_secret,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Ok("Env requirement logged".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct RegisterEnvironmentHandler;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl McpTool for RegisterEnvironmentHandler {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"register_environment"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn schema(&self) -> Value {
|
|
||||||
crate::mcp::tool_def::<RegisterEnvironmentTool>(
|
|
||||||
"register_environment",
|
|
||||||
"Execute register_environment",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
|
|
||||||
let req: RegisterEnvironmentTool =
|
|
||||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
|
||||||
state.environments.modify(|envs| {
|
|
||||||
envs.retain(|e| !(e.namespace == req.namespace && e.name == req.name));
|
|
||||||
envs.push(crate::models::EnvironmentDetail {
|
|
||||||
namespace: req.namespace,
|
|
||||||
name: req.name,
|
|
||||||
url: req.url,
|
|
||||||
description: req.description,
|
|
||||||
requires_vpn: req.requires_vpn,
|
|
||||||
updated_at: crate::handlers::utils::now_secs(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Ok("Environment registered".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct GetEnvironmentDetailsHandler;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl McpTool for GetEnvironmentDetailsHandler {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"get_environment_details"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn schema(&self) -> Value {
|
|
||||||
crate::mcp::tool_def::<GetEnvironmentDetailsTool>(
|
|
||||||
"get_environment_details",
|
|
||||||
"Execute get_environment_details",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
|
|
||||||
let req: GetEnvironmentDetailsTool =
|
|
||||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
|
||||||
let data = state.environments.read_with(|envs| {
|
|
||||||
let filtered: Vec<_> = envs
|
|
||||||
.iter()
|
|
||||||
.filter(|e| e.namespace == req.namespace)
|
|
||||||
.collect();
|
|
||||||
serde_json::to_string(&filtered).map_err(|e| e.to_string())
|
|
||||||
})?;
|
|
||||||
Ok(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -171,22 +136,30 @@ mod tests {
|
|||||||
async fn test_env_fingerprint() {
|
async fn test_env_fingerprint() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||||
|
let handler = EnvironmentHandler;
|
||||||
|
|
||||||
let update_handler = UpdateEnvFingerprintHandler;
|
|
||||||
let args = json!({
|
let args = json!({
|
||||||
|
"action": "update_fingerprint",
|
||||||
"namespace": "global",
|
"namespace": "global",
|
||||||
"tool_versions": {
|
"tool_versions": {
|
||||||
"rustc": "1.70.0"
|
"rustc": "1.70.0"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let res = update_handler.execute(args, state.clone()).await.unwrap();
|
let res = handler
|
||||||
|
.execute(args, state.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||||
|
.unwrap();
|
||||||
assert_eq!(res, "Env fingerprint updated");
|
assert_eq!(res, "Env fingerprint updated");
|
||||||
|
|
||||||
let read_handler = ReadEnvFingerprintHandler;
|
let res2 = handler
|
||||||
let res2 = read_handler
|
.execute(
|
||||||
.execute(json!({"namespace": "global"}), state.clone())
|
json!({"action": "read_fingerprint", "namespace": "global"}),
|
||||||
|
state.clone(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(res2.contains("rustc"));
|
assert!(res2.contains("rustc"));
|
||||||
assert!(res2.contains("1.70.0"));
|
assert!(res2.contains("1.70.0"));
|
||||||
@@ -198,7 +171,7 @@ mod tests {
|
|||||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||||
|
|
||||||
// Ensure namespace is present in test setup
|
// Ensure namespace is present in test setup
|
||||||
state.environments.modify(|e| {
|
state.env.environments.modify(|e| {
|
||||||
e.push(crate::models::EnvironmentDetail {
|
e.push(crate::models::EnvironmentDetail {
|
||||||
namespace: "global".to_string(),
|
namespace: "global".to_string(),
|
||||||
name: "test".to_string(),
|
name: "test".to_string(),
|
||||||
@@ -206,13 +179,18 @@ mod tests {
|
|||||||
description: "Test env".to_string(),
|
description: "Test env".to_string(),
|
||||||
requires_vpn: false,
|
requires_vpn: false,
|
||||||
updated_at: crate::handlers::utils::now_secs(),
|
updated_at: crate::handlers::utils::now_secs(),
|
||||||
|
..Default::default()
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
let handler = GetEnvironmentDetailsHandler;
|
let handler = EnvironmentHandler;
|
||||||
let res = handler
|
let res = handler
|
||||||
.execute(json!({"namespace": "global"}), state.clone())
|
.execute(
|
||||||
|
json!({"action": "get_details", "namespace": "global"}),
|
||||||
|
state.clone(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(res.contains("global"));
|
assert!(res.contains("global"));
|
||||||
@@ -222,36 +200,65 @@ mod tests {
|
|||||||
async fn test_env_requirements_and_registration() {
|
async fn test_env_requirements_and_registration() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||||
|
let handler = EnvironmentHandler;
|
||||||
|
|
||||||
let req_handler = LogEnvRequirementHandler;
|
|
||||||
let args_req = json!({
|
let args_req = json!({
|
||||||
|
"action": "log_requirement",
|
||||||
"key": "vpn",
|
"key": "vpn",
|
||||||
"description": "Requires VPN",
|
"description": "Requires VPN",
|
||||||
"is_secret": false,
|
"is_secret": false,
|
||||||
"requirement": "Needs specific VPN",
|
|
||||||
"context": "For database access",
|
|
||||||
"namespace": "global"
|
"namespace": "global"
|
||||||
});
|
});
|
||||||
let res1 = req_handler.execute(args_req, state.clone()).await.unwrap();
|
let res1 = handler
|
||||||
|
.execute(args_req, state.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||||
|
.unwrap();
|
||||||
assert_eq!(res1, "Env requirement logged");
|
assert_eq!(res1, "Env requirement logged");
|
||||||
|
|
||||||
let reg_handler = RegisterEnvironmentHandler;
|
|
||||||
let args_reg = json!({
|
let args_reg = json!({
|
||||||
|
"action": "register",
|
||||||
"name": "prod",
|
"name": "prod",
|
||||||
"url": "https://prod.local",
|
"url": "https://prod.local",
|
||||||
"description": "Production",
|
"description": "Production",
|
||||||
"requires_vpn": true,
|
"requires_vpn": true,
|
||||||
"namespace": "global"
|
"namespace": "global"
|
||||||
});
|
});
|
||||||
let res2 = reg_handler.execute(args_reg, state.clone()).await.unwrap();
|
let res2 = handler
|
||||||
|
.execute(args_reg, state.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||||
|
.unwrap();
|
||||||
assert_eq!(res2, "Environment registered");
|
assert_eq!(res2, "Environment registered");
|
||||||
|
|
||||||
let get_handler = GetEnvironmentDetailsHandler;
|
let res3 = handler
|
||||||
let res3 = get_handler
|
.execute(
|
||||||
.execute(json!({"namespace": "global"}), state.clone())
|
json!({"action": "get_details", "namespace": "global"}),
|
||||||
|
state.clone(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(res3.contains("prod.local"));
|
assert!(res3.contains("prod.local"));
|
||||||
assert!(!res3.is_empty());
|
assert!(!res3.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_empty_namespace_in_update_env_fingerprint() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||||
|
let handler = EnvironmentHandler;
|
||||||
|
|
||||||
|
let args = serde_json::json!({
|
||||||
|
"action": "update_fingerprint",
|
||||||
|
"namespace": "",
|
||||||
|
"tool_versions": {
|
||||||
|
"rustc": "1.70.0"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let res = handler.execute(args, state.clone()).await;
|
||||||
|
|
||||||
|
assert!(res.is_ok());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+181
-42
@@ -2,9 +2,9 @@ use crate::router::McpTool;
|
|||||||
use crate::state::MemoryState;
|
use crate::state::MemoryState;
|
||||||
use crate::tools::GetActiveWorktreeContextTool;
|
use crate::tools::GetActiveWorktreeContextTool;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{Value, json};
|
||||||
use std::sync::Arc;
|
|
||||||
use std::env;
|
use std::env;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub struct GetActiveWorktreeContextHandler;
|
pub struct GetActiveWorktreeContextHandler;
|
||||||
|
|
||||||
@@ -21,58 +21,197 @@ impl McpTool for GetActiveWorktreeContextHandler {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, _args: Value, _state: Arc<MemoryState>) -> Result<String, String> {
|
async fn execute(
|
||||||
let result = tokio::task::spawn_blocking(move || -> Result<Value, String> {
|
&self,
|
||||||
let cwd = env::current_dir().map_err(|e| e.to_string())?;
|
_args: Value,
|
||||||
let repo = git2::Repository::discover(&cwd).map_err(|e| format!("Not in a git repository: {}", e))?;
|
_state: Arc<MemoryState>,
|
||||||
|
) -> crate::error::Result<String> {
|
||||||
|
let result =
|
||||||
|
tokio::task::spawn_blocking(move || -> crate::error::Result<serde_json::Value> {
|
||||||
|
let cwd = env::current_dir().map_err(|e| e.to_string())?;
|
||||||
|
let repo = git2::Repository::discover(&cwd).map_err(|e| {
|
||||||
|
crate::error::AppError::Internal(format!("Not in a git repository: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
let mut branch_name = String::new();
|
let mut branch_name = String::new();
|
||||||
if let Ok(head) = repo.head() {
|
if let Ok(head) = repo.head()
|
||||||
if let Some(name) = head.shorthand() {
|
&& let Some(name) = head.shorthand()
|
||||||
|
{
|
||||||
branch_name = name.to_string();
|
branch_name = name.to_string();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let mut opts = git2::DiffOptions::new();
|
let mut opts = git2::DiffOptions::new();
|
||||||
let mut diff = None;
|
let mut diff = None;
|
||||||
|
|
||||||
// Try to diff against HEAD
|
// Try to diff against HEAD
|
||||||
if let Ok(tree) = repo.head().and_then(|h| h.peel_to_tree()) {
|
if let Ok(tree) = repo.head().and_then(|h| h.peel_to_tree()) {
|
||||||
diff = repo.diff_tree_to_workdir_with_index(Some(&tree), Some(&mut opts)).ok();
|
diff = repo
|
||||||
}
|
.diff_tree_to_workdir_with_index(Some(&tree), Some(&mut opts))
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
let mut files = Vec::new();
|
let mut files = Vec::new();
|
||||||
let mut diff_output = String::new();
|
let mut diff_output = String::new();
|
||||||
|
|
||||||
if let Some(diff) = diff {
|
if let Some(diff) = diff {
|
||||||
let _ = diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
|
let _ = diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
|
||||||
let content = std::str::from_utf8(line.content()).unwrap_or("");
|
match line.origin() {
|
||||||
diff_output.push_str(content);
|
'+' | '-' | ' ' => diff_output.push(line.origin()),
|
||||||
true
|
_ => {}
|
||||||
});
|
}
|
||||||
|
let content = std::str::from_utf8(line.content()).unwrap_or("");
|
||||||
|
diff_output.push_str(content);
|
||||||
|
true
|
||||||
|
});
|
||||||
|
|
||||||
for delta in diff.deltas() {
|
for delta in diff.deltas() {
|
||||||
if let Some(path) = delta.new_file().path() {
|
if let Some(path) = delta.new_file().path() {
|
||||||
files.push(path.to_string_lossy().into_owned());
|
files.push(path.to_string_lossy().into_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate diff output if it's too large to save tokens
|
||||||
|
if diff_output.len() > 10000 {
|
||||||
|
let valid_boundary = diff_output.floor_char_boundary(10000);
|
||||||
|
diff_output.truncate(valid_boundary);
|
||||||
|
diff_output.push_str("\n... [Diff truncated due to size]");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"branch": branch_name,
|
||||||
|
"modified_files": files,
|
||||||
|
"diff": diff_output
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??;
|
||||||
|
|
||||||
|
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&result)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct QueryGitDiffsHandler;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl McpTool for QueryGitDiffsHandler {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"query_git_diffs"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schema(&self) -> Value {
|
||||||
|
crate::mcp::tool_def::<crate::tools::QueryGitDiffsTool>(
|
||||||
|
"query_git_diffs",
|
||||||
|
"Query recent git commit history, diffs, and change ledger entries.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||||
|
let req: crate::tools::QueryGitDiffsTool =
|
||||||
|
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||||
|
let limit = req.limit.unwrap_or(5);
|
||||||
|
let q = req.query.to_lowercase();
|
||||||
|
|
||||||
|
let mut matches = Vec::new();
|
||||||
|
|
||||||
|
state.code.ledger.read_with(|ledger| {
|
||||||
|
for change in ledger {
|
||||||
|
if change.file_path.to_lowercase().contains(&q)
|
||||||
|
|| change.description.to_lowercase().contains(&q)
|
||||||
|
|| change.git_commit.as_ref().is_some_and(|c| c.contains(&q))
|
||||||
|
{
|
||||||
|
matches.push(json!({
|
||||||
|
"file_path": change.file_path,
|
||||||
|
"description": change.description,
|
||||||
|
"commit": change.git_commit,
|
||||||
|
"branch": change.git_branch,
|
||||||
|
"timestamp": change.timestamp,
|
||||||
|
}));
|
||||||
|
if matches.len() >= limit {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Truncate diff output if it's too large to save tokens
|
if matches.len() < limit {
|
||||||
if diff_output.len() > 10000 {
|
let remaining = limit - matches.len();
|
||||||
diff_output.truncate(10000);
|
let git_matches = tokio::task::spawn_blocking(move || {
|
||||||
diff_output.push_str("\n... [Diff truncated due to size]");
|
let mut results = Vec::new();
|
||||||
}
|
let cwd = env::current_dir().unwrap_or_default();
|
||||||
|
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(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
matches.extend(git_matches);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(json!({
|
Ok(serde_yaml::to_string(&matches)?)
|
||||||
"branch": branch_name,
|
}
|
||||||
"modified_files": files,
|
}
|
||||||
"diff": diff_output
|
|
||||||
}))
|
#[cfg(test)]
|
||||||
})
|
mod tests {
|
||||||
.await
|
use super::*;
|
||||||
.map_err(|e| format!("Task panic: {}", e))??;
|
use serde_json::json;
|
||||||
|
use std::sync::Arc;
|
||||||
Ok(serde_json::to_string_pretty(&result).unwrap())
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_active_worktree_context() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||||
|
let handler = GetActiveWorktreeContextHandler;
|
||||||
|
|
||||||
|
let result = handler
|
||||||
|
.execute(json!({}), state)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to get worktree context: {}", e))
|
||||||
|
.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());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_active_worktree_context_empty_git_repo() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||||
|
|
||||||
|
let handler = GetActiveWorktreeContextHandler;
|
||||||
|
|
||||||
|
let result = handler
|
||||||
|
.execute(serde_json::json!({}), state)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to get worktree context: {}", e))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let parsed: serde_json::Value = serde_yaml::from_str(&result).unwrap();
|
||||||
|
assert!(parsed.get("branch").is_some() || parsed.is_object());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1002
-206
File diff suppressed because it is too large.
Load diff
+150
-44
@@ -1,76 +1,182 @@
|
|||||||
use crate::router::McpTool;
|
use crate::router::McpTool;
|
||||||
use crate::state::MemoryState;
|
use crate::state::MemoryState;
|
||||||
use crate::tools::{WatchProcessLogsTool, GetRecentLogsTool};
|
use crate::tools::{ProcessLogAction, ProcessLogsTool};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::sync::Arc;
|
|
||||||
use std::io::{Read, Seek, SeekFrom};
|
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub struct WatchProcessLogsHandler;
|
pub struct ProcessLogsHandler;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl McpTool for WatchProcessLogsHandler {
|
impl McpTool for ProcessLogsHandler {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
"watch_process_logs"
|
"process_logs"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn schema(&self) -> Value {
|
fn schema(&self) -> Value {
|
||||||
crate::mcp::tool_def::<WatchProcessLogsTool>(
|
crate::mcp::tool_def::<ProcessLogsTool>(
|
||||||
"watch_process_logs",
|
"process_logs",
|
||||||
"Tail a specific log file in the background.",
|
"Monitor, tail, and manage process logs: watch a log file, tail recent output, or clear log files.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> Result<String, String> {
|
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||||
let tool_args: WatchProcessLogsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
let tool_args: ProcessLogsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||||
if !std::path::Path::new(&tool_args.file_path).exists() {
|
let safe_path = crate::handlers::utils::validate_safe_path(&tool_args.file_path)?;
|
||||||
return Err(format!("File does not exist: {}", tool_args.file_path));
|
|
||||||
|
match tool_args.action {
|
||||||
|
ProcessLogAction::Watch => {
|
||||||
|
if !safe_path.exists() {
|
||||||
|
return Err(crate::error::AppError::Internal(format!(
|
||||||
|
"File does not exist: {}",
|
||||||
|
tool_args.file_path
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(format!("Started watching logs for {}", tool_args.file_path))
|
||||||
|
}
|
||||||
|
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 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 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(result)
|
||||||
|
}
|
||||||
|
ProcessLogAction::Clear => {
|
||||||
|
if safe_path.exists() {
|
||||||
|
std::fs::write(&safe_path, "").map_err(|e| {
|
||||||
|
crate::error::AppError::Internal(format!("Failed to clear log file: {}", e))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(format!("Cleared logs in {}", tool_args.file_path))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(format!("Started watching logs for {}", tool_args.file_path))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct GetRecentLogsHandler;
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
#[async_trait]
|
#[tokio::test]
|
||||||
impl McpTool for GetRecentLogsHandler {
|
async fn test_process_logs_watch() {
|
||||||
fn name(&self) -> &'static str {
|
let dir = tempdir().unwrap();
|
||||||
"get_recent_logs"
|
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||||
|
let handler = ProcessLogsHandler;
|
||||||
|
|
||||||
|
let log_file = dir.path().join("test.log");
|
||||||
|
std::fs::write(&log_file, "line1\nline2").unwrap();
|
||||||
|
|
||||||
|
let args = json!({
|
||||||
|
"action": "watch",
|
||||||
|
"file_path": log_file.to_str().unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = handler
|
||||||
|
.execute(args, state)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to watch logs: {}", e))
|
||||||
|
.unwrap();
|
||||||
|
assert!(result.contains("Started watching logs"));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn schema(&self) -> Value {
|
#[tokio::test]
|
||||||
crate::mcp::tool_def::<GetRecentLogsTool>(
|
async fn test_process_logs_get() {
|
||||||
"get_recent_logs",
|
let dir = tempdir().unwrap();
|
||||||
"Get the recent logs (last 100 lines) from a watched file.",
|
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||||
)
|
let handler = ProcessLogsHandler;
|
||||||
|
|
||||||
|
let log_file = dir.path().join("test_recent.log");
|
||||||
|
std::fs::write(&log_file, "line1\nline2\nline3").unwrap();
|
||||||
|
|
||||||
|
let args = json!({
|
||||||
|
"action": "get",
|
||||||
|
"file_path": log_file.to_str().unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = handler
|
||||||
|
.execute(args, state)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to get recent logs: {}", e))
|
||||||
|
.unwrap();
|
||||||
|
assert!(result.contains("line1"));
|
||||||
|
assert!(result.contains("line3"));
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> Result<String, String> {
|
#[tokio::test]
|
||||||
let tool_args: GetRecentLogsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
async fn test_process_logs_clear() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||||
|
let handler = ProcessLogsHandler;
|
||||||
|
|
||||||
let result = tokio::task::spawn_blocking(move || -> Result<String, String> {
|
let log_file = dir.path().join("test_clear.log");
|
||||||
let mut file = File::open(&tool_args.file_path).map_err(|e| format!("Failed to open file: {}", e))?;
|
std::fs::write(&log_file, "line1\nline2\nline3").unwrap();
|
||||||
let len = file.metadata().map_err(|e| e.to_string())?.len();
|
|
||||||
|
|
||||||
let read_size = std::cmp::min(16 * 1024, len);
|
let args = json!({
|
||||||
file.seek(SeekFrom::End(-(read_size as i64))).map_err(|e| e.to_string())?;
|
"action": "clear",
|
||||||
|
"file_path": log_file.to_str().unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
let mut buffer = String::new();
|
let result = handler.execute(args, state.clone()).await.unwrap();
|
||||||
file.read_to_string(&mut buffer).map_err(|e| e.to_string())?;
|
assert!(result.contains("Cleared logs"));
|
||||||
|
|
||||||
let lines: Vec<&str> = buffer.lines().collect();
|
let content = std::fs::read_to_string(&log_file).unwrap();
|
||||||
let recent_lines = if lines.len() > 100 {
|
assert_eq!(content, "");
|
||||||
lines[lines.len() - 100..].join("\n")
|
}
|
||||||
} else {
|
|
||||||
buffer
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(recent_lines)
|
#[tokio::test]
|
||||||
})
|
async fn test_process_logs_get_with_large_file() {
|
||||||
.await
|
let dir = tempfile::tempdir().unwrap();
|
||||||
.map_err(|e| format!("Task panic: {}", e))??;
|
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||||
|
let handler = ProcessLogsHandler;
|
||||||
|
|
||||||
Ok(result)
|
let log_file = dir.path().join("large_test.log");
|
||||||
|
let mut buffer = String::new();
|
||||||
|
for _ in 0..1000 {
|
||||||
|
buffer.push_str("line\n");
|
||||||
|
}
|
||||||
|
std::fs::write(&log_file, buffer).unwrap();
|
||||||
|
|
||||||
|
let args = serde_json::json!({
|
||||||
|
"action": "get",
|
||||||
|
"file_path": log_file.to_str().unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = handler
|
||||||
|
.execute(args, state)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to get recent logs: {}", e))
|
||||||
|
.unwrap();
|
||||||
|
assert!(result.contains("line"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2471
-408
File diff suppressed because it is too large.
Load diff
@@ -1,11 +1,12 @@
|
|||||||
|
pub mod ast;
|
||||||
pub mod env;
|
pub mod env;
|
||||||
|
pub mod git;
|
||||||
pub mod graph;
|
pub mod graph;
|
||||||
|
pub mod logs;
|
||||||
pub mod meta;
|
pub mod meta;
|
||||||
pub mod notes;
|
pub mod notes;
|
||||||
|
pub mod reconciliation;
|
||||||
pub mod tasks;
|
pub mod tasks;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
pub mod workspaces;
|
|
||||||
pub mod vision;
|
pub mod vision;
|
||||||
pub mod git;
|
pub mod workspaces;
|
||||||
pub mod logs;
|
|
||||||
pub mod ast;
|
|
||||||
+172
-234
@@ -7,196 +7,75 @@ use serde_json::Value;
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub struct AddStickyNoteHandler;
|
pub struct HandoffMemosHandler;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl McpTool for AddStickyNoteHandler {
|
impl McpTool for HandoffMemosHandler {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
"add_sticky_note"
|
"handoff_memos"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn schema(&self) -> Value {
|
fn schema(&self) -> Value {
|
||||||
crate::mcp::tool_def::<AddStickyNoteTool>("add_sticky_note", "Execute add_sticky_note")
|
crate::mcp::tool_def::<HandoffMemosTool>(
|
||||||
}
|
"handoff_memos",
|
||||||
|
"Handoff memos across sessions (leave, read, clear)",
|
||||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
|
|
||||||
let req: AddStickyNoteTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
|
||||||
state.sticky.modify(|notes| {
|
|
||||||
notes.push(StickyNote {
|
|
||||||
timestamp: crate::handlers::utils::now_secs(),
|
|
||||||
content: req.content,
|
|
||||||
expires_at: None,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Ok("Sticky note added.".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ReadStickyNotesHandler;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl McpTool for ReadStickyNotesHandler {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"read_sticky_notes"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn schema(&self) -> Value {
|
|
||||||
crate::mcp::tool_def::<ReadStickyNotesTool>(
|
|
||||||
"read_sticky_notes",
|
|
||||||
"Execute read_sticky_notes",
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, _args: Value, state: Arc<MemoryState>) -> Result<String, String> {
|
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||||
let data = state
|
let req: HandoffMemosTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||||
.sticky
|
match req.action {
|
||||||
.read_with(|s| serde_json::to_string(s).map_err(|e| e.to_string()))?;
|
HandoffMemoAction::Leave => {
|
||||||
Ok(data)
|
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
|
||||||
pub struct DeleteStickyNoteHandler;
|
.namespace
|
||||||
|
.unwrap_or_else(crate::models::default_namespace);
|
||||||
#[async_trait]
|
state.telemetry.handoff_memos.modify(|memos| {
|
||||||
impl McpTool for DeleteStickyNoteHandler {
|
memos.push(crate::models::HandoffMemo {
|
||||||
fn name(&self) -> &'static str {
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
"delete_sticky_note"
|
author: "agy".to_string(),
|
||||||
}
|
content,
|
||||||
|
namespace: ns,
|
||||||
fn schema(&self) -> Value {
|
timestamp: crate::handlers::utils::now_secs(),
|
||||||
crate::mcp::tool_def::<DeleteStickyNoteTool>(
|
expires_at: None,
|
||||||
"delete_sticky_note",
|
..Default::default()
|
||||||
"Execute delete_sticky_note",
|
});
|
||||||
)
|
if memos.len() > 200 {
|
||||||
}
|
memos.remove(0);
|
||||||
|
|
||||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
|
|
||||||
let req: DeleteStickyNoteTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
|
||||||
let mut success = false;
|
|
||||||
state.sticky.modify(|notes| {
|
|
||||||
if req.index > 0 && req.index <= notes.len() {
|
|
||||||
notes.remove(req.index - 1);
|
|
||||||
success = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if success {
|
|
||||||
Ok("Sticky note deleted.".to_string())
|
|
||||||
} else {
|
|
||||||
Err("Invalid sticky note index.".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ClearStickyNotesHandler;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl McpTool for ClearStickyNotesHandler {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"clear_sticky_notes"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn schema(&self) -> Value {
|
|
||||||
crate::mcp::tool_def::<ClearStickyNotesTool>(
|
|
||||||
"clear_sticky_notes",
|
|
||||||
"Execute clear_sticky_notes",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute(&self, _args: Value, state: Arc<MemoryState>) -> Result<String, String> {
|
|
||||||
state.sticky.modify(|notes| {
|
|
||||||
notes.clear();
|
|
||||||
});
|
|
||||||
Ok("All sticky notes cleared.".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct LeaveHandoffMemoHandler;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl McpTool for LeaveHandoffMemoHandler {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"leave_handoff_memo"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn schema(&self) -> Value {
|
|
||||||
crate::mcp::tool_def::<LeaveHandoffMemoTool>(
|
|
||||||
"leave_handoff_memo",
|
|
||||||
"Execute leave_handoff_memo",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
|
|
||||||
let req: LeaveHandoffMemoTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
|
||||||
state.handoff_memos.modify(|memos| {
|
|
||||||
memos.push(crate::models::HandoffMemo {
|
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
|
||||||
author: "agy".to_string(),
|
|
||||||
content: req.content,
|
|
||||||
namespace: req.namespace,
|
|
||||||
timestamp: crate::handlers::utils::now_secs(),
|
|
||||||
expires_at: None,
|
|
||||||
})
|
|
||||||
});
|
|
||||||
Ok("Handoff memo left".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ReadHandoffMemosHandler;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl McpTool for ReadHandoffMemosHandler {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"read_handoff_memos"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn schema(&self) -> Value {
|
|
||||||
crate::mcp::tool_def::<ReadHandoffMemosTool>(
|
|
||||||
"read_handoff_memos",
|
|
||||||
"Execute read_handoff_memos",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
|
|
||||||
let req: ReadHandoffMemosTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
|
||||||
let data = state.handoff_memos.read_with(|items| {
|
|
||||||
let filtered: Vec<_> = items
|
|
||||||
.iter()
|
|
||||||
.filter(|i| {
|
|
||||||
if let Some(ns) = &req.namespace {
|
|
||||||
&i.namespace == ns
|
|
||||||
} else {
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
.collect();
|
Ok("Handoff memo left.".to_string())
|
||||||
serde_json::to_string(&filtered).map_err(|e| e.to_string())
|
}
|
||||||
})?;
|
HandoffMemoAction::Read => {
|
||||||
Ok(data)
|
let data = state.telemetry.handoff_memos.read_with(|items| {
|
||||||
}
|
let filtered: Vec<_> = items
|
||||||
}
|
.iter()
|
||||||
|
.filter(|i| {
|
||||||
pub struct ClearHandoffMemosHandler;
|
if let Some(ns) = &req.namespace {
|
||||||
|
&i.namespace == ns
|
||||||
#[async_trait]
|
} else {
|
||||||
impl McpTool for ClearHandoffMemosHandler {
|
true
|
||||||
fn name(&self) -> &'static str {
|
}
|
||||||
"clear_handoff_memos"
|
})
|
||||||
}
|
.collect();
|
||||||
|
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&filtered)?)
|
||||||
fn schema(&self) -> Value {
|
})?;
|
||||||
crate::mcp::tool_def::<ClearHandoffMemosTool>(
|
Ok(data)
|
||||||
"clear_handoff_memos",
|
}
|
||||||
"Execute clear_handoff_memos",
|
HandoffMemoAction::Clear => {
|
||||||
)
|
let ids: HashSet<_> = req.ids.unwrap_or_default().into_iter().collect();
|
||||||
}
|
state.telemetry.handoff_memos.modify(|memos| {
|
||||||
|
if ids.is_empty() {
|
||||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
|
memos.clear();
|
||||||
let req: ClearHandoffMemosTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
} else {
|
||||||
let ids: HashSet<_> = req.ids.into_iter().collect();
|
memos.retain(|m| !ids.contains(&m.id));
|
||||||
state
|
}
|
||||||
.handoff_memos
|
});
|
||||||
.modify(|memos| memos.retain(|m| !ids.contains(&m.id)));
|
Ok("Handoff memos cleared.".to_string())
|
||||||
Ok("Handoff memos cleared".to_string())
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,15 +94,33 @@ impl McpTool for AddSessionSummaryHandler {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
|
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||||
let req: AddSessionSummaryTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
let req: AddSessionSummaryTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||||
state.session_summaries.modify(|summaries| {
|
state.telemetry.session_summaries.modify(|summaries| {
|
||||||
summaries.push(crate::models::SessionSummary {
|
summaries.push(crate::models::SessionSummary {
|
||||||
summary: req.summary,
|
summary: req.summary,
|
||||||
namespace: req.namespace,
|
namespace: req.namespace,
|
||||||
timestamp: crate::handlers::utils::now_secs(),
|
timestamp: crate::handlers::utils::now_secs(),
|
||||||
expires_at: None,
|
expires_at: None,
|
||||||
})
|
session_id: req.session_id,
|
||||||
|
repo_name: req.repo_name,
|
||||||
|
git_branch: req.git_branch,
|
||||||
|
git_commit: req.git_commit,
|
||||||
|
tasks_completed: req.tasks_completed.unwrap_or_default(),
|
||||||
|
next_steps: req.next_steps.unwrap_or_default(),
|
||||||
|
});
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
summaries.remove(idx_to_remove);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
Ok("Session summary added".to_string())
|
Ok("Session summary added".to_string())
|
||||||
}
|
}
|
||||||
@@ -244,83 +141,122 @@ impl McpTool for GenerateStandupReportHandler {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
|
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||||
let req: GenerateStandupReportTool =
|
let req: GenerateStandupReportTool =
|
||||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||||
let cutoff = crate::handlers::utils::now_secs().saturating_sub(req.hours_lookback * 3600);
|
let cutoff = crate::handlers::utils::now_secs().saturating_sub(req.hours_lookback * 3600);
|
||||||
|
|
||||||
let report_str = state.tasks.read_with(|items| {
|
let filtered_tasks = state.project.tasks.read_with(|items| {
|
||||||
state.ledger.read_with(|changes| {
|
items
|
||||||
state.session_summaries.read_with(|summaries| {
|
.iter()
|
||||||
let filtered_tasks: Vec<_> = items.iter().filter(|t| t.updated_at >= cutoff).collect();
|
.filter(|t| t.updated_at >= cutoff)
|
||||||
let filtered_changes: Vec<_> = changes.iter().filter(|c| c.timestamp >= cutoff).collect();
|
.cloned()
|
||||||
let filtered_summaries: Vec<_> = summaries.iter().filter(|s| s.namespace == req.namespace && s.timestamp >= cutoff).collect();
|
.collect::<Vec<_>>()
|
||||||
let report = serde_json::json!({ "tasks_updated": filtered_tasks, "code_changes": filtered_changes, "session_summaries": filtered_summaries });
|
|
||||||
report.to_string()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
let filtered_changes = state.code.ledger.read_with(|changes| {
|
||||||
|
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<_>>()
|
||||||
|
});
|
||||||
|
|
||||||
|
let report = serde_json::json!({
|
||||||
|
"tasks_updated": filtered_tasks,
|
||||||
|
"code_changes": filtered_changes,
|
||||||
|
"session_summaries": filtered_summaries
|
||||||
|
});
|
||||||
|
let report_str = report.to_string();
|
||||||
Ok(report_str)
|
Ok(report_str)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct PromoteToEntityHandler;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl McpTool for PromoteToEntityHandler {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"promote_to_entity"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schema(&self) -> Value {
|
||||||
|
crate::mcp::tool_def::<PromoteToEntityTool>(
|
||||||
|
"promote_to_entity",
|
||||||
|
"Promote a task observation or finding into a permanent Knowledge Graph entity.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 entity_name = req.entity_name.clone();
|
||||||
|
let obs = req.content.clone();
|
||||||
|
let entity_type = req.entity_type.clone();
|
||||||
|
let namespace = req.namespace.clone();
|
||||||
|
|
||||||
|
state.modify_graph(|g| {
|
||||||
|
let entity = g
|
||||||
|
.entities
|
||||||
|
.entry(entity_name.clone())
|
||||||
|
.or_insert_with(|| Entity {
|
||||||
|
name: entity_name.clone(),
|
||||||
|
entity_type,
|
||||||
|
observations: Vec::new(),
|
||||||
|
namespace,
|
||||||
|
git_branch: None,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
if !entity.observations.contains(&obs) {
|
||||||
|
entity.observations.push(obs.clone());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(format!(
|
||||||
|
"Promoted transient note into permanent entity '{}'.",
|
||||||
|
req.entity_name
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_notes_lifecycle() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
|
||||||
|
|
||||||
let add_handler = AddStickyNoteHandler;
|
|
||||||
let args = json!({
|
|
||||||
"content": "Buy milk",
|
|
||||||
});
|
|
||||||
|
|
||||||
let res = add_handler.execute(args, state.clone()).await.unwrap();
|
|
||||||
assert!(res.contains("Sticky note added"));
|
|
||||||
|
|
||||||
let read_handler = ReadStickyNotesHandler;
|
|
||||||
let res2 = read_handler
|
|
||||||
.execute(json!({}), state.clone())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(res2.contains("Buy milk"));
|
|
||||||
|
|
||||||
let delete_handler = DeleteStickyNoteHandler;
|
|
||||||
let args2 = json!({"index": 1});
|
|
||||||
let res3 = delete_handler.execute(args2, state.clone()).await.unwrap();
|
|
||||||
assert_eq!(res3, "Sticky note deleted.");
|
|
||||||
|
|
||||||
let res4 = read_handler
|
|
||||||
.execute(json!({}), state.clone())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(!res4.contains("Buy milk"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_handoff_and_summaries() {
|
async fn test_handoff_and_summaries() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||||
|
|
||||||
let handoff_handler = LeaveHandoffMemoHandler;
|
let handoff_handler = HandoffMemosHandler;
|
||||||
let args = json!({
|
let args = json!({
|
||||||
|
"action": "leave",
|
||||||
"content": "Finished implementing graph tests",
|
"content": "Finished implementing graph tests",
|
||||||
"author": "Antigravity",
|
|
||||||
"namespace": "global"
|
"namespace": "global"
|
||||||
});
|
});
|
||||||
|
|
||||||
let res = handoff_handler.execute(args, state.clone()).await.unwrap();
|
let res = handoff_handler
|
||||||
assert_eq!(res, "Handoff memo left");
|
.execute(args, state.clone())
|
||||||
|
|
||||||
let read_handoff = ReadHandoffMemosHandler;
|
|
||||||
let res2 = read_handoff
|
|
||||||
.execute(json!({"namespace": "global"}), state.clone())
|
|
||||||
.await
|
.await
|
||||||
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res, "Handoff memo left.");
|
||||||
|
|
||||||
|
let res2 = handoff_handler
|
||||||
|
.execute(
|
||||||
|
json!({"action": "read", "namespace": "global"}),
|
||||||
|
state.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(res2.contains("Finished implementing graph tests"));
|
assert!(res2.contains("Finished implementing graph tests"));
|
||||||
|
|
||||||
@@ -332,6 +268,7 @@ mod tests {
|
|||||||
let res3 = summary_handler
|
let res3 = summary_handler
|
||||||
.execute(args_sum, state.clone())
|
.execute(args_sum, state.clone())
|
||||||
.await
|
.await
|
||||||
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(res3, "Session summary added");
|
assert_eq!(res3, "Session summary added");
|
||||||
|
|
||||||
@@ -342,6 +279,7 @@ mod tests {
|
|||||||
state.clone(),
|
state.clone(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!res4.is_empty());
|
assert!(!res4.is_empty());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,554 @@
|
|||||||
|
use crate::models::{Adr, TaskEvent};
|
||||||
|
use crate::state::MemoryState;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, Default)]
|
||||||
|
pub struct GitCommitPayload {
|
||||||
|
pub commit: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub branch: Option<String>,
|
||||||
|
pub message: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub author: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub files: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub repo_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct ReconciliationResult {
|
||||||
|
pub implemented_adrs: Vec<String>,
|
||||||
|
pub resolved_tech_debts: Vec<String>,
|
||||||
|
pub completed_tasks: Vec<String>,
|
||||||
|
pub unblocked_tasks: Vec<String>,
|
||||||
|
pub updated_milestones: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts ADR identifiers such as "ADR-0102", "ADR-102", "adr-005", "ADR: 102"
|
||||||
|
pub fn extract_adrs(text: &str) -> Vec<String> {
|
||||||
|
let mut adrs = Vec::new();
|
||||||
|
let lower = text.to_lowercase();
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let lower_chars: Vec<char> = lower.chars().collect();
|
||||||
|
let len = chars.len();
|
||||||
|
|
||||||
|
let mut i = 0;
|
||||||
|
while i < len {
|
||||||
|
// Look for "adr"
|
||||||
|
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] == ' ')
|
||||||
|
{
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
// Collect consecutive digits
|
||||||
|
let digit_start = cursor;
|
||||||
|
while cursor < len && chars[cursor].is_ascii_digit() {
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
if cursor > digit_start {
|
||||||
|
let num_str: String = chars[digit_start..cursor].iter().collect();
|
||||||
|
if let Ok(num) = num_str.parse::<u32>() {
|
||||||
|
let formatted = format!("ADR-{:04}", num);
|
||||||
|
if !adrs.contains(&formatted) {
|
||||||
|
adrs.push(formatted);
|
||||||
|
}
|
||||||
|
let raw_formatted = format!("ADR-{}", num_str);
|
||||||
|
if !adrs.contains(&raw_formatted) {
|
||||||
|
adrs.push(raw_formatted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i = cursor;
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
adrs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts TechDebt identifiers such as "TD-001", "td-auth-cleanup", "TD: 12"
|
||||||
|
pub fn extract_tech_debts(text: &str) -> Vec<String> {
|
||||||
|
let mut debts = Vec::new();
|
||||||
|
let lower = text.to_lowercase();
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let lower_chars: Vec<char> = lower.chars().collect();
|
||||||
|
let len = chars.len();
|
||||||
|
|
||||||
|
let mut i = 0;
|
||||||
|
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] == ' ')
|
||||||
|
{
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
let start = cursor;
|
||||||
|
while cursor < len
|
||||||
|
&& (chars[cursor].is_ascii_alphanumeric()
|
||||||
|
|| chars[cursor] == '_'
|
||||||
|
|| chars[cursor] == '-')
|
||||||
|
{
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
if cursor > start {
|
||||||
|
let id_part: String = chars[start..cursor].iter().collect();
|
||||||
|
let full_id = format!("TD-{}", id_part);
|
||||||
|
if !debts.contains(&full_id) {
|
||||||
|
debts.push(full_id);
|
||||||
|
}
|
||||||
|
if !debts.contains(&id_part) {
|
||||||
|
debts.push(id_part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i = cursor;
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts Task identifiers such as "TASK-123", "task-01", or UUID strings
|
||||||
|
pub fn extract_tasks(text: &str) -> Vec<String> {
|
||||||
|
let mut tasks = Vec::new();
|
||||||
|
let lower = text.to_lowercase();
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let lower_chars: Vec<char> = lower.chars().collect();
|
||||||
|
let len = chars.len();
|
||||||
|
|
||||||
|
let mut i = 0;
|
||||||
|
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] == ' ')
|
||||||
|
{
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
let start = cursor;
|
||||||
|
while cursor < len
|
||||||
|
&& (chars[cursor].is_ascii_alphanumeric()
|
||||||
|
|| chars[cursor] == '_'
|
||||||
|
|| chars[cursor] == '-')
|
||||||
|
{
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
if cursor > start {
|
||||||
|
let id_part: String = chars[start..cursor].iter().collect();
|
||||||
|
let full_id = format!("TASK-{}", id_part);
|
||||||
|
if !tasks.contains(&full_id) {
|
||||||
|
tasks.push(full_id);
|
||||||
|
}
|
||||||
|
if !tasks.contains(&id_part) {
|
||||||
|
tasks.push(id_part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i = cursor;
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
fn adr_numeric_id(id: &str) -> Option<u32> {
|
||||||
|
let digits: String = id.chars().filter(|c| c.is_ascii_digit()).collect();
|
||||||
|
digits.parse::<u32>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Automatically cascades unblocking to tasks whose dependencies have all completed.
|
||||||
|
pub fn cascade_unblock_tasks(state: &MemoryState) -> Vec<String> {
|
||||||
|
let mut unblocked = Vec::new();
|
||||||
|
let tasks_snapshot = state.project.tasks.read_with(|tasks| tasks.clone());
|
||||||
|
|
||||||
|
state.project.tasks.modify(|tasks| {
|
||||||
|
for t in tasks.iter_mut() {
|
||||||
|
if t.status.eq_ignore_ascii_case("blocked") {
|
||||||
|
if t.dependencies.is_empty() {
|
||||||
|
t.status = "active".to_string();
|
||||||
|
t.updated_at = crate::handlers::utils::now_secs();
|
||||||
|
unblocked.push(t.id.clone());
|
||||||
|
} 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()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if all_deps_completed {
|
||||||
|
t.status = "active".to_string();
|
||||||
|
t.updated_at = crate::handlers::utils::now_secs();
|
||||||
|
unblocked.push(t.id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for tid in &unblocked {
|
||||||
|
state.record_activity(
|
||||||
|
"task_update",
|
||||||
|
&format!("Auto-unblocked Task {} (all dependencies met)", tid),
|
||||||
|
Some("active"),
|
||||||
|
);
|
||||||
|
state.broadcast_task_event(TaskEvent {
|
||||||
|
task_id: tid.clone(),
|
||||||
|
status: "active".to_string(),
|
||||||
|
action: Some("cascade_unblock".to_string()),
|
||||||
|
result: Some(serde_json::json!({ "status": "active" })),
|
||||||
|
error: None,
|
||||||
|
timestamp: crate::handlers::utils::now_secs(),
|
||||||
|
session_id: None,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
unblocked
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Automatically recalculates milestone progress and status based on task completion.
|
||||||
|
pub fn recalculate_milestones(state: &MemoryState) -> Vec<String> {
|
||||||
|
let mut updated = Vec::new();
|
||||||
|
let tasks_snapshot = state.project.tasks.read_with(|tasks| tasks.clone());
|
||||||
|
|
||||||
|
state.project.milestones.modify(|milestones| {
|
||||||
|
for m in milestones.iter_mut() {
|
||||||
|
if m.task_ids.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let total = m.task_ids.len();
|
||||||
|
let completed = m
|
||||||
|
.task_ids
|
||||||
|
.iter()
|
||||||
|
.filter(|tid| {
|
||||||
|
tasks_snapshot
|
||||||
|
.iter()
|
||||||
|
.any(|t| (t.id == **tid || t.title == **tid) && !t.is_active())
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
|
||||||
|
if completed == total {
|
||||||
|
if !m.status.eq_ignore_ascii_case("completed") {
|
||||||
|
m.status = "completed".to_string();
|
||||||
|
if m.completed_at.is_none() {
|
||||||
|
m.completed_at = Some(crate::handlers::utils::now_secs());
|
||||||
|
}
|
||||||
|
updated.push(m.id.clone());
|
||||||
|
}
|
||||||
|
} 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")
|
||||||
|
{
|
||||||
|
m.status = "in_progress".to_string();
|
||||||
|
updated.push(m.id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for mid in &updated {
|
||||||
|
state.record_activity(
|
||||||
|
"milestone",
|
||||||
|
&format!("Milestone {} status updated via task progress", mid),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconciles state deterministically from git commit messages or MCP `log_code_change` calls.
|
||||||
|
pub async fn reconcile_commit_or_code_change(
|
||||||
|
state: &Arc<MemoryState>,
|
||||||
|
text: &str,
|
||||||
|
file_path: Option<&str>,
|
||||||
|
git_commit: Option<&str>,
|
||||||
|
git_branch: Option<&str>,
|
||||||
|
) -> ReconciliationResult {
|
||||||
|
let mut result = ReconciliationResult::default();
|
||||||
|
let now = crate::handlers::utils::now_secs();
|
||||||
|
let text_lower = text.to_lowercase();
|
||||||
|
|
||||||
|
// 1. Reconcile ADRs
|
||||||
|
let adr_candidates = extract_adrs(text);
|
||||||
|
if !adr_candidates.is_empty() {
|
||||||
|
let mut adrs_to_index: Vec<Adr> = Vec::new();
|
||||||
|
state.code.adrs.modify(|adrs| {
|
||||||
|
for adr in adrs.iter_mut() {
|
||||||
|
let matches = adr_candidates.iter().any(|cand| {
|
||||||
|
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))
|
||||||
|
&& n1 == n2
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
});
|
||||||
|
|
||||||
|
if matches && !adr.status.eq_ignore_ascii_case("implemented") {
|
||||||
|
adr.status = "implemented".to_string();
|
||||||
|
adr.resolved_at = Some(now);
|
||||||
|
if let Some(rev) = git_commit {
|
||||||
|
adr.git_commit = Some(rev.to_string());
|
||||||
|
}
|
||||||
|
if let Some(br) = git_branch {
|
||||||
|
adr.git_branch = Some(br.to_string());
|
||||||
|
}
|
||||||
|
result.implemented_adrs.push(adr.id.clone());
|
||||||
|
adrs_to_index.push(adr.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if !adrs_to_index.is_empty() {
|
||||||
|
let idx = state.get_search_index().await;
|
||||||
|
for a in &adrs_to_index {
|
||||||
|
drop(idx.index_adr(a));
|
||||||
|
state.record_activity(
|
||||||
|
"decision",
|
||||||
|
&format!("Auto-implemented ADR {}: {}", a.id, a.title),
|
||||||
|
Some("implemented"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Reconcile Tech Debts
|
||||||
|
let td_candidates = extract_tech_debts(text);
|
||||||
|
let mentions_debt_resolution = text_lower.contains("fix")
|
||||||
|
|| text_lower.contains("resolve")
|
||||||
|
|| text_lower.contains("refactor")
|
||||||
|
|| text_lower.contains("clean")
|
||||||
|
|| text_lower.contains("address");
|
||||||
|
|
||||||
|
state.code.tech_debts.modify(|debts| {
|
||||||
|
for d in debts.iter_mut() {
|
||||||
|
if d.is_resolved {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
&& mentions_debt_resolution
|
||||||
|
&& (text_lower.contains("debt") || text_lower.contains("td-"));
|
||||||
|
|
||||||
|
if matches_id || matches_file {
|
||||||
|
d.is_resolved = true;
|
||||||
|
if let Some(rev) = git_commit {
|
||||||
|
d.git_commit = Some(rev.to_string());
|
||||||
|
}
|
||||||
|
if let Some(br) = git_branch {
|
||||||
|
d.git_branch = Some(br.to_string());
|
||||||
|
}
|
||||||
|
result.resolved_tech_debts.push(d.id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for tdid in &result.resolved_tech_debts {
|
||||||
|
state.record_activity(
|
||||||
|
"tech_debt",
|
||||||
|
&format!("Auto-resolved TechDebt {} via commit", tdid),
|
||||||
|
Some("resolved"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Reconcile Tasks
|
||||||
|
let task_candidates = extract_tasks(text);
|
||||||
|
let mentions_task_close = text_lower.contains("fix")
|
||||||
|
|| text_lower.contains("close")
|
||||||
|
|| text_lower.contains("complete")
|
||||||
|
|| text_lower.contains("resolve")
|
||||||
|
|| text_lower.contains("finish")
|
||||||
|
|| text_lower.contains("implement");
|
||||||
|
|
||||||
|
if mentions_task_close && !task_candidates.is_empty() {
|
||||||
|
state.project.tasks.modify(|tasks| {
|
||||||
|
for t in tasks.iter_mut() {
|
||||||
|
if !t.is_active() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let matches = task_candidates.iter().any(|cand| {
|
||||||
|
t.id.eq_ignore_ascii_case(cand)
|
||||||
|
|| cand.trim_start_matches("TASK-").eq_ignore_ascii_case(&t.id)
|
||||||
|
});
|
||||||
|
if matches {
|
||||||
|
t.status = "completed".to_string();
|
||||||
|
t.updated_at = now;
|
||||||
|
result.completed_tasks.push(t.id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for tid in &result.completed_tasks {
|
||||||
|
state.record_activity(
|
||||||
|
"task_update",
|
||||||
|
&format!("Auto-completed Task {} via commit", tid),
|
||||||
|
Some("completed"),
|
||||||
|
);
|
||||||
|
state.broadcast_task_event(TaskEvent {
|
||||||
|
task_id: tid.clone(),
|
||||||
|
status: "completed".to_string(),
|
||||||
|
action: Some("commit_reconciliation".to_string()),
|
||||||
|
result: Some(serde_json::json!({ "status": "completed" })),
|
||||||
|
error: None,
|
||||||
|
timestamp: now,
|
||||||
|
session_id: None,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Cascade Unblock & Recalculate Milestones if any tasks completed
|
||||||
|
if !result.completed_tasks.is_empty() {
|
||||||
|
result.unblocked_tasks = cascade_unblock_tasks(state);
|
||||||
|
result.updated_milestones = recalculate_milestones(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::models::{Milestone, Task};
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_adrs() {
|
||||||
|
let text = "feat(api): implement ADR-0102 and ADR-103 with pagination";
|
||||||
|
let adrs = extract_adrs(text);
|
||||||
|
assert!(adrs.contains(&"ADR-0102".to_string()));
|
||||||
|
assert!(adrs.contains(&"ADR-0103".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_tech_debts() {
|
||||||
|
let text = "refactor: resolve TD-cleanup_old_buffers and TD-002";
|
||||||
|
let debts = extract_tech_debts(text);
|
||||||
|
assert!(debts.contains(&"TD-cleanup_old_buffers".to_string()));
|
||||||
|
assert!(debts.contains(&"TD-002".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_tasks() {
|
||||||
|
let text = "fix(core): close task-551 and TASK-abc";
|
||||||
|
let tasks = extract_tasks(text);
|
||||||
|
assert!(tasks.contains(&"TASK-551".to_string()));
|
||||||
|
assert!(tasks.contains(&"TASK-abc".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_reconcile_adr_implementation() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||||
|
|
||||||
|
state.code.adrs.modify(|adrs| {
|
||||||
|
adrs.push(Adr {
|
||||||
|
id: "ADR-0102".to_string(),
|
||||||
|
title: "Dynamic Neovim Projection Buffer".to_string(),
|
||||||
|
status: "accepted".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = reconcile_commit_or_code_change(
|
||||||
|
&state,
|
||||||
|
"feat: implement ADR-102 projection buffer",
|
||||||
|
None,
|
||||||
|
Some("commit123"),
|
||||||
|
Some("master"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(result.implemented_adrs, vec!["ADR-0102"]);
|
||||||
|
|
||||||
|
let adr = state.code.adrs.read_with(|adrs| adrs[0].clone());
|
||||||
|
assert_eq!(adr.status, "implemented");
|
||||||
|
assert_eq!(adr.git_commit.as_deref(), Some("commit123"));
|
||||||
|
assert_eq!(adr.git_branch.as_deref(), Some("master"));
|
||||||
|
assert!(adr.resolved_at.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cascade_unblock_and_milestone_recalculation() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||||
|
|
||||||
|
let task1 = Task {
|
||||||
|
id: "task-1".to_string(),
|
||||||
|
title: "Task 1".to_string(),
|
||||||
|
status: "active".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let task2 = Task {
|
||||||
|
id: "task-2".to_string(),
|
||||||
|
title: "Task 2".to_string(),
|
||||||
|
status: "blocked".to_string(),
|
||||||
|
dependencies: vec!["task-1".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
state.project.tasks.modify(|t| {
|
||||||
|
t.push(task1);
|
||||||
|
t.push(task2);
|
||||||
|
});
|
||||||
|
|
||||||
|
let ms = Milestone {
|
||||||
|
id: "ms-1".to_string(),
|
||||||
|
title: "Sprint 1".to_string(),
|
||||||
|
status: "pending".to_string(),
|
||||||
|
task_ids: vec!["task-1".to_string(), "task-2".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
state.project.milestones.modify(|m| m.push(ms));
|
||||||
|
|
||||||
|
// Complete task-1 via commit message
|
||||||
|
let result = reconcile_commit_or_code_change(
|
||||||
|
&state,
|
||||||
|
"fix: close task-1 core functionality",
|
||||||
|
None,
|
||||||
|
Some("c1"),
|
||||||
|
Some("master"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(result.completed_tasks, vec!["task-1"]);
|
||||||
|
assert_eq!(result.unblocked_tasks, vec!["task-2"]);
|
||||||
|
assert_eq!(result.updated_milestones, vec!["ms-1"]);
|
||||||
|
|
||||||
|
// Task 2 should now be active
|
||||||
|
let t2 = state.project.tasks.read_with(|t| t[1].clone());
|
||||||
|
assert_eq!(t2.status, "active");
|
||||||
|
|
||||||
|
// Milestone should now be in_progress
|
||||||
|
let m = state.project.milestones.read_with(|m| m[0].clone());
|
||||||
|
assert_eq!(m.status, "in_progress");
|
||||||
|
|
||||||
|
// Now complete task-2
|
||||||
|
let result2 = reconcile_commit_or_code_change(
|
||||||
|
&state,
|
||||||
|
"fix: close task-2 follow-up",
|
||||||
|
None,
|
||||||
|
Some("c2"),
|
||||||
|
Some("master"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(result2.completed_tasks, vec!["task-2"]);
|
||||||
|
let m2 = state.project.milestones.read_with(|m| m[0].clone());
|
||||||
|
assert_eq!(m2.status, "completed");
|
||||||
|
assert!(m2.completed_at.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
+577
-489
File diff suppressed because it is too large.
Load diff
@@ -14,6 +14,19 @@ pub fn now_secs() -> u64 {
|
|||||||
.as_secs()
|
.as_secs()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn validate_safe_path(path_str: &str) -> crate::error::Result<std::path::PathBuf> {
|
||||||
|
let path = std::path::Path::new(path_str);
|
||||||
|
for component in path.components() {
|
||||||
|
if component == std::path::Component::ParentDir {
|
||||||
|
return Err(crate::error::AppError::BadRequest(format!(
|
||||||
|
"Path traversal forbidden: '{}' contains relative parent directory components ('..')",
|
||||||
|
path_str
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(path.to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+1073
-161
File diff suppressed because it is too large.
Load diff
+465
-535
File diff suppressed because it is too large.
Load diff
+216
-43
@@ -1,6 +1,6 @@
|
|||||||
use crate::state::MemoryState;
|
|
||||||
use crate::models::Snippet;
|
|
||||||
use crate::embedding::generate_embeddings_async;
|
use crate::embedding::generate_embeddings_async;
|
||||||
|
use crate::models::Snippet;
|
||||||
|
use crate::state::MemoryState;
|
||||||
use ignore::WalkBuilder;
|
use ignore::WalkBuilder;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tree_sitter::{Node, Parser};
|
use tree_sitter::{Node, Parser};
|
||||||
@@ -13,30 +13,40 @@ pub async fn start_background_indexer(state: Arc<MemoryState>) {
|
|||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
tracing::info!("Starting background indexer in {:?}", workspace_root);
|
tracing::info!("Starting background indexer in {:?}", workspace_root);
|
||||||
|
|
||||||
let walker = WalkBuilder::new(&workspace_root)
|
let root_clone = workspace_root.clone();
|
||||||
.hidden(true)
|
let files_to_process = tokio::task::spawn_blocking(move || {
|
||||||
.git_ignore(true)
|
let walker = WalkBuilder::new(&root_clone)
|
||||||
.build();
|
.hidden(true)
|
||||||
|
.git_ignore(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
let mut files_to_process = Vec::new();
|
let mut files = Vec::new();
|
||||||
for result in walker {
|
for result in walker {
|
||||||
match result {
|
match result {
|
||||||
Ok(entry) => {
|
Ok(entry) => {
|
||||||
if entry.file_type().map_or(false, |ft| ft.is_file()) {
|
if entry.file_type().is_some_and(|ft| ft.is_file()) {
|
||||||
let path = entry.path().to_path_buf();
|
let path = entry.path().to_path_buf();
|
||||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||||
if ["rs", "ts", "js", "jsx", "tsx", "py", "java", "c", "cpp", "go"].contains(&ext) {
|
if [
|
||||||
files_to_process.push(path);
|
"rs", "ts", "js", "jsx", "tsx", "py", "java", "c", "cpp", "go",
|
||||||
|
]
|
||||||
|
.contains(&ext)
|
||||||
|
{
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Err(e) => {
|
||||||
Err(e) => {
|
tracing::warn!("Error walking directory: {}", e);
|
||||||
tracing::warn!("Error walking directory: {}", e);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
files
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
let idx = state.get_search_index();
|
let idx = state.get_search_index().await;
|
||||||
|
|
||||||
for file_path in files_to_process {
|
for file_path in files_to_process {
|
||||||
if let Ok(content) = std::fs::read_to_string(&file_path) {
|
if let Ok(content) = std::fs::read_to_string(&file_path) {
|
||||||
@@ -61,34 +71,76 @@ pub async fn start_background_indexer(state: Arc<MemoryState>) {
|
|||||||
let mut chunks = Vec::new();
|
let mut chunks = Vec::new();
|
||||||
extract_chunks(tree.root_node(), &content, &mut chunks, ext);
|
extract_chunks(tree.root_node(), &content, &mut chunks, ext);
|
||||||
|
|
||||||
for (name, code, desc) in chunks {
|
// Gold Standard: Batch generate embeddings in chunks of 16 to eliminate sequential HTTP overhead
|
||||||
// Generate embedding
|
for chunk_batch in chunks.chunks(16) {
|
||||||
if let Ok(mut emb) = generate_embeddings_async(vec![code.clone()]).await {
|
let texts: Vec<String> = chunk_batch
|
||||||
let embedding = emb.pop();
|
.iter()
|
||||||
|
.map(|(_, code, _)| code.clone())
|
||||||
|
.collect();
|
||||||
|
let embeddings = generate_embeddings_async(texts).await.unwrap_or_default();
|
||||||
|
|
||||||
let file_name = file_path.file_name().unwrap_or_default().to_string_lossy();
|
let mut new_snippets = Vec::with_capacity(chunk_batch.len());
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
for (i, (name, code, desc)) in chunk_batch.iter().enumerate() {
|
||||||
|
let embedding = embeddings.get(i).cloned();
|
||||||
|
let file_name =
|
||||||
|
file_path.file_name().unwrap_or_default().to_string_lossy();
|
||||||
let snippet_name = format!("{}:{}", file_name, name);
|
let snippet_name = format!("{}:{}", file_name, name);
|
||||||
|
|
||||||
let snippet = Snippet {
|
let snippet = Snippet {
|
||||||
name: snippet_name.to_string(),
|
name: snippet_name,
|
||||||
language: ext.to_string(),
|
language: ext.to_string(),
|
||||||
code: code.clone(),
|
code: code.clone(),
|
||||||
description: format!("{} in {}", desc, file_path.display()),
|
description: format!("{} in {}", desc, file_path.display()),
|
||||||
updated_at: std::time::SystemTime::now()
|
updated_at: now,
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
tags: vec![],
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs(),
|
|
||||||
embedding,
|
embedding,
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
new_snippets.push(snippet);
|
||||||
|
}
|
||||||
|
|
||||||
state.snippets.modify(|snippets| {
|
// Gold Standard: Modify store ONCE per batch with zero-copy HashSet<&str> lookup
|
||||||
// Prevent duplicates if already indexed
|
let mut snippets_to_index = Vec::new();
|
||||||
if !snippets.iter().any(|s| s.name == snippet.name) {
|
state.code.snippets.modify(|snippets| {
|
||||||
snippets.push(snippet.clone());
|
let existing_names: std::collections::HashSet<&str> =
|
||||||
|
snippets.iter().map(|s| s.name.as_str()).collect();
|
||||||
|
|
||||||
|
let mut filtered_new = Vec::with_capacity(new_snippets.len());
|
||||||
|
let mut seen_in_batch = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
for snippet in new_snippets {
|
||||||
|
if !existing_names.contains(snippet.name.as_str())
|
||||||
|
&& seen_in_batch.insert(snippet.name.clone())
|
||||||
|
{
|
||||||
|
filtered_new.push(snippet);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
let _ = idx.index_snippet(&snippet).await;
|
for snippet in filtered_new {
|
||||||
|
snippets.push(snippet.clone());
|
||||||
|
snippets_to_index.push(snippet);
|
||||||
|
}
|
||||||
|
|
||||||
|
if snippets.len() > 1000 {
|
||||||
|
let overflow = snippets.len() - 1000;
|
||||||
|
snippets.drain(0..overflow);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let threshold: usize = std::env::var("MCP_MEMORY_CONDENSE_THRESHOLD")
|
||||||
|
.unwrap_or_else(|_| "100".to_string())
|
||||||
|
.parse()
|
||||||
|
.unwrap_or(100);
|
||||||
|
if state.code.snippets.read_with(|s| s.len()) > threshold {
|
||||||
|
state.condense_notify.notify_one();
|
||||||
|
}
|
||||||
|
|
||||||
|
for snippet in &snippets_to_index {
|
||||||
|
let _ = idx.index_snippet(snippet).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,8 +150,48 @@ pub async fn start_background_indexer(state: Arc<MemoryState>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_chunks(node: Node, code: &str, chunks: &mut Vec<(String, String, String)>, ext: &str) {
|
pub fn extract_chunks(
|
||||||
|
node: Node,
|
||||||
|
code: &str,
|
||||||
|
chunks: &mut Vec<(String, String, String)>,
|
||||||
|
ext: &str,
|
||||||
|
) {
|
||||||
|
extract_chunks_with_parent(node, code, chunks, ext, None, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_chunks_with_parent(
|
||||||
|
node: Node,
|
||||||
|
code: &str,
|
||||||
|
chunks: &mut Vec<(String, String, String)>,
|
||||||
|
_ext: &str,
|
||||||
|
parent_scope: Option<&str>,
|
||||||
|
depth: usize,
|
||||||
|
) {
|
||||||
|
// Stack overflow protection: Cap recursion depth at 100
|
||||||
|
if depth > 100 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let kind = node.kind();
|
let kind = node.kind();
|
||||||
|
let is_impl_or_class = matches!(kind, "impl_item" | "class_declaration" | "class_definition");
|
||||||
|
|
||||||
|
let current_scope: Option<&str> = if is_impl_or_class {
|
||||||
|
let mut cursor = node.walk();
|
||||||
|
let mut type_name = None;
|
||||||
|
for child in node.children(&mut cursor) {
|
||||||
|
if child.kind() == "type_identifier"
|
||||||
|
|| child.kind() == "name"
|
||||||
|
|| child.kind() == "identifier"
|
||||||
|
{
|
||||||
|
type_name = child.utf8_text(code.as_bytes()).ok();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type_name.or(parent_scope)
|
||||||
|
} else {
|
||||||
|
parent_scope
|
||||||
|
};
|
||||||
|
|
||||||
let is_structural = matches!(
|
let is_structural = matches!(
|
||||||
kind,
|
kind,
|
||||||
"function_item"
|
"function_item"
|
||||||
@@ -111,24 +203,105 @@ fn extract_chunks(node: Node, code: &str, chunks: &mut Vec<(String, String, Stri
|
|||||||
);
|
);
|
||||||
|
|
||||||
if is_structural {
|
if is_structural {
|
||||||
let code_text = node.utf8_text(code.as_bytes()).unwrap_or("").to_string();
|
let mut raw_text = node.utf8_text(code.as_bytes()).unwrap_or("").to_string();
|
||||||
|
|
||||||
let mut name = "unknown".to_string();
|
let mut name = "unknown";
|
||||||
let mut cursor = node.walk();
|
let mut cursor = node.walk();
|
||||||
for child in node.children(&mut cursor) {
|
for child in node.children(&mut cursor) {
|
||||||
let child_kind = child.kind();
|
let child_kind = child.kind();
|
||||||
if child_kind == "identifier" || child_kind == "name" || child_kind == "type_identifier" {
|
if child_kind == "identifier" || child_kind == "name" || child_kind == "type_identifier"
|
||||||
name = child.utf8_text(code.as_bytes()).unwrap_or("unknown").to_string();
|
{
|
||||||
|
if let Ok(text) = child.utf8_text(code.as_bytes()) {
|
||||||
|
name = text;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut final_name = name.to_string();
|
||||||
|
if let Some(scope) = current_scope {
|
||||||
|
raw_text = format!("// Parent Scope: {}\n{}", scope, raw_text);
|
||||||
|
final_name = format!("{}::{}", scope, name);
|
||||||
|
}
|
||||||
|
|
||||||
let desc = format!("{} AST node", kind);
|
let desc = format!("{} AST node", kind);
|
||||||
chunks.push((name, code_text, desc));
|
chunks.push((final_name, raw_text, desc));
|
||||||
} else {
|
} else {
|
||||||
let mut cursor = node.walk();
|
let mut cursor = node.walk();
|
||||||
for child in node.named_children(&mut cursor) {
|
for child in node.named_children(&mut cursor) {
|
||||||
extract_chunks(child, code, chunks, ext);
|
extract_chunks_with_parent(child, code, chunks, _ext, current_scope, depth + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tree_sitter::Parser;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_chunks_rust_function() {
|
||||||
|
let code = "fn test_func() { println!(\"hello\"); }";
|
||||||
|
let mut parser = Parser::new();
|
||||||
|
parser
|
||||||
|
.set_language(&tree_sitter_rust::LANGUAGE.into())
|
||||||
|
.unwrap();
|
||||||
|
let tree = parser.parse(code, None).unwrap();
|
||||||
|
|
||||||
|
let mut chunks = Vec::new();
|
||||||
|
extract_chunks(tree.root_node(), code, &mut chunks, "rs");
|
||||||
|
|
||||||
|
assert_eq!(chunks.len(), 1);
|
||||||
|
assert_eq!(chunks[0].0, "test_func");
|
||||||
|
assert!(chunks[0].1.contains("println"));
|
||||||
|
assert_eq!(chunks[0].2, "function_item AST node");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_chunks_python_function() {
|
||||||
|
let code = "def my_python_func():\n pass\n";
|
||||||
|
let mut parser = Parser::new();
|
||||||
|
parser
|
||||||
|
.set_language(&tree_sitter_python::LANGUAGE.into())
|
||||||
|
.unwrap();
|
||||||
|
let tree = parser.parse(code, None).unwrap();
|
||||||
|
|
||||||
|
let mut chunks = Vec::new();
|
||||||
|
extract_chunks(tree.root_node(), code, &mut chunks, "py");
|
||||||
|
|
||||||
|
assert_eq!(chunks.len(), 1);
|
||||||
|
assert_eq!(chunks[0].0, "my_python_func");
|
||||||
|
assert_eq!(chunks[0].2, "function_definition AST node");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_start_background_indexer_lifecycle() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let state = Arc::new(MemoryState::new(temp_dir.path().to_str().unwrap()));
|
||||||
|
start_background_indexer(state).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_chunks_rust_impl_block() {
|
||||||
|
let code = "impl MyStruct { fn my_method(&self) {} }";
|
||||||
|
let mut parser = Parser::new();
|
||||||
|
parser
|
||||||
|
.set_language(&tree_sitter_rust::LANGUAGE.into())
|
||||||
|
.unwrap();
|
||||||
|
let tree = parser.parse(code, None).unwrap();
|
||||||
|
|
||||||
|
let mut chunks = Vec::new();
|
||||||
|
extract_chunks(tree.root_node(), code, &mut chunks, "rs");
|
||||||
|
|
||||||
|
assert_eq!(chunks.len(), 1);
|
||||||
|
assert_eq!(chunks[0].0, "MyStruct::my_method");
|
||||||
|
assert!(chunks[0].1.contains("// Parent Scope: MyStruct"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_start_background_indexer_empty_dir() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let state = Arc::new(MemoryState::new(temp_dir.path().to_str().unwrap()));
|
||||||
|
start_background_indexer(state).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
+306
-19
@@ -1,24 +1,311 @@
|
|||||||
# Antigravity Memory MCP Instructions
|
# Memory MCP Strategic Guidelines
|
||||||
|
|
||||||
You are Antigravity, connected to the mcp-memory persistence layer. This server provides a persistent knowledge graph, task management, and environment state tracking.
|
This document outlines the STRATEGY, SEMANTICS, RESOURCE SCHEMAS, and CASING STANDARDS for using the MCP Memory Server.
|
||||||
|
You do not need to memorize individual JSON schemas for every tool; they are strictly defined and typed in the `tools/list` endpoint. Focus on WHEN, WHY, and HOW to leverage them effectively.
|
||||||
|
|
||||||
## Core Directives
|
---
|
||||||
1. **Always Log Code Changes**: Before completing any coding task or pull request, you MUST invoke the `MemoryLibrarian` subagent to execute `log_code_change`. The server uses `git2` to automatically detect your branch and hash.
|
|
||||||
2. **Always Log Error Fixes**: If you spend more than one turn fixing an error or bug, call log_error_fix with the stack trace and the exact solution you discovered.
|
|
||||||
3. **Omni Search (Hybrid Vector)**: When starting a session or looking for context, use omni_search. It uses Vector Embeddings for semantic matching, so you can query conceptually (e.g., "how does auth work") without needing exact keyword matches. It searches the knowledge graph, tasks, snippets, ADRs, and tech debt.
|
|
||||||
4. **Architectural Entities**: When refactoring or creating new files, delegate to the `MemoryLibrarian` to define structural components via `create_entities` and `create_relations`.
|
|
||||||
5. **Tech Debt**: Do NOT ignore tech debt. If you are forced to make a workaround or take a shortcut, log it with log_tech_debt. When you fix it later, use resolve_tech_debt.
|
|
||||||
6. **Decisions**: Use log_decision when you make an architectural choice (e.g. choosing a specific library or pattern).
|
|
||||||
7. **Workspaces & Context**: When pausing work or shifting focus, use `save_context_workspace` to snapshot your active tasks and pinned files. When resuming, use `list_context_workspaces` and `load_context_workspace`. Keep your working files pinned (`pin_file`)!
|
|
||||||
8. **Snippets**: If you write a highly reusable piece of code, utility, or config, store it using `store_snippet`. Before writing boilerplate, try `search_snippets`. If outdated, use `delete_snippet`.
|
|
||||||
9. **PR Checklists**: Aggressively use `add_pr_checklist_item` to build up a list of manual verification steps. Once the PR is merged, use `clear_pr_checklist`.
|
|
||||||
10. **Tasks & Milestones**: Always track the user's larger goals! Invoke the `ScrumMaster` subagent to manage the board (`add_task`, `update_task_status`, `set_acceptance_criteria`). Use `list_active_tasks` to check what's next.
|
|
||||||
11. **Handoff Memos**: If you need to stop your session or hand off work to a subagent, use `leave_handoff_memo`. When starting, use `read_handoff_memos` and `clear_handoff_memos` once read.
|
|
||||||
12. **Preferences**: If the user tells you how they like things done (e.g., "always use fastify", "never use sed"), use `learn_preference`.
|
|
||||||
13. **Sticky Notes**: Use `add_sticky_note` for ephemeral, temporary scratchpad info (like IP addresses, temporary URLs, or pending command outputs).
|
|
||||||
|
|
||||||
Be aggressive about logging state changes in the background! You MUST delegate this heavy lifting to the `MemoryLibrarian`, `ScrumMaster`, and `DevOpsSRE` subagents in the background.
|
## 1. Casing & Naming Standards (CRITICAL)
|
||||||
|
|
||||||
## Tool Schema Discovery
|
To prevent graph fragmentation and ensure seamless LLM context retrieval:
|
||||||
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/mcp-memory/<tool_name>.json`. Do not waste tokens inspecting the Rust server code for schemas.
|
* **Entity Types (`entity_type`)**: MUST ALWAYS be **`PascalCase`** (e.g. `DatabaseTable`, `McpTool`, `ArchitectureComponent`, `File`, `DataStructure`).
|
||||||
|
* **Relation Types (`relation_type`)**: MUST ALWAYS be **`snake_case`** (e.g. `depends_on`, `calls`, `implements`, `uses`, `contains`).
|
||||||
|
* **Field Keys & Properties**: MUST ALWAYS be **`snake_case`** (e.g. `file_path`, `git_commit`, `created_at`).
|
||||||
|
* **Relation Tool Parameters**: `create_relations` supports Serde field aliases (`source` -> `from`, `target` -> `to`, `relationType`/`type` -> `relation_type`) so LLM tool calls succeed seamlessly regardless of parameter naming.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> The server automatically enforces and migrates incoming entity and relation types to these canonical casing rules on every read and write operation.
|
||||||
|
> Store operations enforce atomic lock scope for serialization/deserialization and enter Quarantine Mode upon database corruption to prevent data overwrite hazards.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The Two-Tier Context Paradigm
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> * **Tier 1 (Static Markdown)**: Repository rules, constraints, architectural patterns, and developer preferences are maintained directly in static git-tracked files (`agent-rules/*.md`, `instructions.md`) and system prompts. This guarantees 0ms turn-0 availability without relying on proactive agent tool retrieval.
|
||||||
|
> * **Tier 2 (Telemetry & Ephemeral DB)**: High-frequency operational history—the Code Change Ledger (`audit_ledger`), terminal command history, compiler error fixes (`log_error_fix`), active tasks, and preflight context—is handled by the MCP Memory Server and surfaced via the Brain Monitor Web UI and MCP resources.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Passive MCP Resources (`resources/list`)
|
||||||
|
|
||||||
|
The server exposes 9 real-time, read-only MCP resources. Agents should read these resources directly to regain context without incurring tool call latency:
|
||||||
|
|
||||||
|
| Resource URI | Resource Name | Description & Usage |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| `memory://graph/entities` | Graph Entities | All nodes and entities in the knowledge graph (top 100 with pagination guidance). |
|
||||||
|
| `memory://graph/relations` | Graph Relations | All relationships between graph entities (top 200 with subgraph guidance). |
|
||||||
|
| `memory://tasks/active` | Active Tasks | Current active tasks with status, priority, and assigned subagents. |
|
||||||
|
| `memory://decisions/active` | Active ADR Decisions | Architectural decisions currently in `accepted` status. |
|
||||||
|
| `memory://tech_debt/unresolved` | Unresolved Tech Debt | All open engineering debt items requiring future refactoring. |
|
||||||
|
| `memory://session/delta` | Session Delta | Code modifications, commits, active tasks, and notes created in the last 2 hours. |
|
||||||
|
| `memory://terminal/recent` | Terminal History | Recent terminal commands, interpreters (`pwsh`, `bash`, `nu`), working dirs, and exit codes. |
|
||||||
|
| `memory://activity/recent` | Recent Activity | Real-time IDE and developer activity event stream. |
|
||||||
|
| `memory://milestones` | Milestones | Project milestones, deliverables, target dates, and progress. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. MCP Workflow Prompts (`prompts/list`)
|
||||||
|
|
||||||
|
The server registers 5 high-signal workflow prompts to initiate standardized agent routines:
|
||||||
|
|
||||||
|
1. **`context_warmup`**: Executed at session startup. Prompts the agent to read `memory://tasks/active` and `memory://session/delta`, inspect the workspace worktree, and assemble immediate working context.
|
||||||
|
2. **`analyze_tech_debt`**: Prompts the agent to review unresolved technical debt from `memory://tech_debt/unresolved` and generate a prioritized remediation plan.
|
||||||
|
3. **`summarize_architecture`**: Synthesizes active ADRs from `memory://decisions/active` and graph entities from `memory://graph/entities` into an architectural overview.
|
||||||
|
4. **`handoff_routine`**: Triggers the `DevOpsSRE` subagent at session end to generate a standup report, audit active tasks, and record a handoff memo for future sessions.
|
||||||
|
5. **`archive_routine`**: Compresses historical session summaries into a dense milestone retrospective entity and purges pruned entries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Consolidated Smart Tools Architecture (11 Primary Tools)
|
||||||
|
|
||||||
|
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`).
|
||||||
|
- `action: "update"`: Update task status (requires `id`, `status: "pending" | "completed" | "cancelled"`).
|
||||||
|
- `action: "delete"`: Delete task and child tasks (requires `id`).
|
||||||
|
- `action: "list"`: List active tasks (optional `git_branch`, `summary_level: "compact" | "detailed" | "full"`, `max_tokens`).
|
||||||
|
- `action: "set_criteria"`: Set acceptance criteria (requires `id`, `criteria: Vec<String>`).
|
||||||
|
- `action: "verify"`: Verify criteria met (requires `id`, optional `proof`).
|
||||||
|
|
||||||
|
* **`milestones`**: Milestone tracking.
|
||||||
|
- `action: "add"`: Create milestone (requires `title`, optional `namespace`, `target_date`, `description`, `deliverables: Vec<String>`, `repo_name`).
|
||||||
|
- `action: "update"`: Update milestone status (requires `id`, `status: "active" | "completed" | "cancelled"`).
|
||||||
|
- `action: "list"`: List milestones (optional `namespace`).
|
||||||
|
|
||||||
|
* **`handoff_memos`**: Session handoff notes for future agents.
|
||||||
|
- `action: "leave"`: Leave a memo (requires `content`, optional `vcs_revision`, `repo_name`, `git_branch`, `blockers: Vec<String>`, `action_items: Vec<String>`, `expires_at`).
|
||||||
|
- `action: "read"`: Read active handoff memos.
|
||||||
|
- `action: "clear"`: Clear memos.
|
||||||
|
|
||||||
|
* **`snippets`**: Reusable code snippet vault.
|
||||||
|
- `action: "store"`: Store snippet (requires `query` as name, optional `language`, `code`, `description`, `tags`, `origin_file`, `line_range`, `repo_name`).
|
||||||
|
- `action: "search"`: Search snippet vault (optional `query`, `tags`, `hybrid: true`).
|
||||||
|
- `action: "delete"`: Delete snippet (requires `id`).
|
||||||
|
- `action: "tag"`: Attach classification tags (requires `id`, `tags: Vec<String>`).
|
||||||
|
|
||||||
|
* **`decisions`**: Architectural Decision Records (ADRs).
|
||||||
|
- `action: "log"`: Log ADR (requires `title`, optional `status: "accepted" | "proposed" | "deprecated" | "superseded" | "implemented"`, `context`, `decision`, `consequence`, `author`, `affected_components: Vec<String>`, `alternatives_considered: Vec<String>`, `supersedes`, `repo_name`).
|
||||||
|
- `action: "update"`: Update ADR status and metadata (requires `id`, optional `status`, `git_commit`, `git_branch`).
|
||||||
|
- `action: "query"`: Query ADRs (optional `query`).
|
||||||
|
- `action: "delete"`: Delete ADR (requires `id`).
|
||||||
|
|
||||||
|
* **`tech_debt`**: Engineering debt backlog.
|
||||||
|
- `action: "log"`: Log debt item (requires `description`, optional `ideal_solution`, `file_path`, `line_range`, `workaround`, `effort_estimate`, `severity: "low" | "medium" | "high" | "critical"`, `git_commit`, `git_branch`, `symbol_references`, `repo_name`).
|
||||||
|
- `action: "resolve"`: Resolve debt item (requires `id`).
|
||||||
|
- `action: "list"`: List debt items (optional `include_resolved`).
|
||||||
|
|
||||||
|
* **`environment`**: Infrastructure and requirements tracking.
|
||||||
|
- `action: "update_fingerprint"`: Update tool versions (requires `tool_versions: Map<String, String>`, optional `repo_name`).
|
||||||
|
- `action: "read_fingerprint"`: Read tool versions fingerprint.
|
||||||
|
- `action: "log_requirement"`: Log environment variable requirement (requires `key`, `description`, `is_secret`, optional `default_value`, `validation_regex`, `repo_name`).
|
||||||
|
- `action: "register"`: Register target environment (requires `name`, `url`, optional `description`, `requires_vpn`, `env_type: "dev" | "staging" | "qa" | "prod"`, `healthcheck_endpoint`, `ssh_host`, `repo_name`).
|
||||||
|
- `action: "get_details"`: Read full environment details.
|
||||||
|
|
||||||
|
* **`clipboard`**: OS Clipboard management.
|
||||||
|
- `action: "read"`: Read OS clipboard.
|
||||||
|
- `action: "write"`: Write text/html/files/image to clipboard.
|
||||||
|
|
||||||
|
* **`hypotheses`**: Diagnostic hypothesis memory.
|
||||||
|
- `action: "log"`: Record diagnostic hypothesis (requires `hypothesis`, optional `status: "open" | "verified" | "disproven"`, `evidence: Vec<String>`, `test_command`, `git_branch`, `repo_name`).
|
||||||
|
- `action: "query"`: Query hypotheses (optional `status`, `git_branch`, `repo_name`).
|
||||||
|
|
||||||
|
* **`agent_signals`**: Inter-agent signal bus.
|
||||||
|
- `action: "broadcast"`: Broadcast signal to other agents (requires `signal_type`, `payload`, optional `target_agent`, `ttl_seconds`).
|
||||||
|
- `action: "query"`: Query active signals (optional `signal_type`, `include_expired: bool`).
|
||||||
|
|
||||||
|
* **`process_logs`**: Process and daemon log management.
|
||||||
|
- `action: "watch"`: Register or update log file watcher (requires `label`, `file_path`, optional `description`).
|
||||||
|
- `action: "get"`: Retrieve recent log lines from watched process log (requires `label`, optional `tail_lines: usize`).
|
||||||
|
- `action: "clear"`: Clear or truncate watched process log file (requires `label`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Complete Tool Catalog (All 53 Tools)
|
||||||
|
|
||||||
|
The server exposes 53 specialized and smart MCP tools organized into 7 functional domains:
|
||||||
|
|
||||||
|
### 1. Knowledge Graph Core (18 Tools)
|
||||||
|
1. `create_entities`: Batch-create entities with `name`, `entity_type`, and `observations`.
|
||||||
|
2. `create_relations`: Batch-create relationships (`from`, `to`, `relation_type`).
|
||||||
|
3. `add_observations`: Append observations to existing entities.
|
||||||
|
4. `delete_entities`: Delete entity nodes and cascading relations.
|
||||||
|
5. `delete_relations`: Delete specific relation edges between entities.
|
||||||
|
6. `delete_observations`: Remove specific observations from an entity.
|
||||||
|
7. `read_graph`: Return full or namespace-filtered knowledge graph.
|
||||||
|
8. `search_nodes`: Search entity names and observations using Tantivy BM25.
|
||||||
|
9. `open_nodes`: Inspect full details of specified entity nodes by name.
|
||||||
|
10. `visualize_graph`: Generate Mermaid markdown or SVG diagram of the graph.
|
||||||
|
11. `condense_entity`: Summarize entity observations into dense summaries.
|
||||||
|
12. `merge_entities`: Merge source entity into target entity, re-pointing relations and pruning self-loops.
|
||||||
|
13. `find_orphans`: Detect entities with zero relationships for pruning.
|
||||||
|
14. `get_subgraph`: BFS graph traversal expanding $N$ hops from a root node.
|
||||||
|
15. `sweep_graph_health`: Audit graph for orphans, calculate name similarity, and recommend merges.
|
||||||
|
16. `resolve_stale_symbols`: Cross-reference graph symbols against the workspace AST to remove deleted code nodes.
|
||||||
|
17. `summarize_subgraph`: LLM-ready concise synthesis of a localized subgraph.
|
||||||
|
18. `query_graph_path`: BFS shortest path between two entities in the knowledge graph.
|
||||||
|
|
||||||
|
### 2. Task & Milestone Operations (2 Tools)
|
||||||
|
19. `tasks`: Consolidated task board manager (`add`, `update`, `delete`, `list`, `set_criteria`, `verify`).
|
||||||
|
20. `milestones`: Milestone lifecycle management (`add`, `update`, `list`).
|
||||||
|
|
||||||
|
### 3. Notes, Handoffs & Reporting (4 Tools)
|
||||||
|
21. `handoff_memos`: Cross-session scratchpad and handoff memos (`leave`, `read`, `clear`).
|
||||||
|
22. `add_session_summary`: Record session summary notes and highlights.
|
||||||
|
23. `generate_standup_report`: Synthesize tasks, ledger changes, and session summaries into a standup report.
|
||||||
|
24. `promote_to_entity`: Promote an ephemeral note or memo into a permanent knowledge graph entity.
|
||||||
|
|
||||||
|
### 4. Meta, Audit & Intelligence (15 Tools)
|
||||||
|
25. `decisions`: Consolidated Architectural Decision Records (ADRs) manager (`log`, `update`, `query`, `delete`).
|
||||||
|
26. `tech_debt`: Consolidated technical debt backlog manager (`log`, `resolve`, `list`).
|
||||||
|
27. `log_error_fix`: Record an error resolution with stack trace, root cause, and git commit.
|
||||||
|
28. `search_error_fixes`: Embedding-based and keyword search over past error resolutions.
|
||||||
|
29. `log_code_change`: Record a file modification in the VCS-agnostic audit ledger.
|
||||||
|
30. `query_recent_changes`: Retrieve recent code changes with lookback time filters.
|
||||||
|
31. `omni_search`: Reciprocal Rank Fusion (RRF) search across all graph entities, snippets, ADRs, debt, and fixes.
|
||||||
|
32. `get_project_health`: Health dashboard summarizing task completion, debt backlog, and graph consistency.
|
||||||
|
33. `manage_checkpoint`: Create or restore named memory snapshots for safe rollback.
|
||||||
|
34. `query_lineage`: Causal lineage tracker linking tasks, ADRs, commits, and error fixes.
|
||||||
|
35. `get_next_actionable_tasks`: Topologically resolved list of unblocked tasks ready for execution.
|
||||||
|
36. `hypotheses`: Structured diagnostic hypothesis tracker (`log`, `query`).
|
||||||
|
37. `get_preflight_context`: Aggregated operational context at session start (tasks, debt, recent changes).
|
||||||
|
38. `agent_signals`: Inter-agent signal bus (`broadcast`, `query`).
|
||||||
|
39. `auto_session_checkpoint`: Automatic session boundary checkpointing.
|
||||||
|
|
||||||
|
### 5. System, Environment & Telemetry (4 Tools)
|
||||||
|
40. `environment`: Tool fingerprinting, requirements, and environment registry (`update_fingerprint`, `read_fingerprint`, `log_requirement`, `register`, `get_details`).
|
||||||
|
41. `snippets`: Reusable code snippet vault with hybrid search (`store`, `search`, `delete`, `tag`).
|
||||||
|
42. `clipboard`: Pure native Rust OS clipboard interface (`read`, `write`).
|
||||||
|
43. `process_logs`: Live process and daemon log watcher and tailer (`watch`, `get`, `clear`).
|
||||||
|
|
||||||
|
### 6. Git & Worktree Context (2 Tools)
|
||||||
|
44. `get_active_worktree_context`: Inspect git status, modified files, diff summary, and current branch.
|
||||||
|
45. `query_git_diffs`: Retrieve detailed git diffs for specific files or commit ranges.
|
||||||
|
|
||||||
|
### 7. AST & Code Intelligence (8 Tools)
|
||||||
|
46. `read_file_skeleton`: Tree-sitter AST structural outline of functions, structs, and methods without implementation bodies.
|
||||||
|
47. `replace_ast_node`: Precise AST node replacement preserving indentation and comments.
|
||||||
|
48. `find_symbol_references`: Search for symbol references across snippets and disk source files.
|
||||||
|
49. `get_callers`: Find call sites and callers of a specified function or method across the codebase.
|
||||||
|
50. `analyze_impact`: Blast-radius impact analysis of modifying a symbol or file.
|
||||||
|
51. `read_directory_architecture`: Recursive directory structure analysis capped at depth 10.
|
||||||
|
52. `semantic_code_search`: Dense vector semantic code search over indexed source code.
|
||||||
|
53. `manage_subagent_namespace`: Create, isolate, or merge subagent-scoped memory namespaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. VCS & SVN Agnosticism & Multi-Repo Provenance
|
||||||
|
|
||||||
|
To support diverse enterprise repositories (Git, Subversion / SVN, Mercurial / Hg, Monorepos):
|
||||||
|
* **`vcs_type`**: Designates the VCS engine (`"git"`, `"svn"`, `"hg"`, `"perforce"`, or `"none"`).
|
||||||
|
* **`vcs_revision`**: Agnostic commit hash or SVN revision identifier (e.g., `"r12458"`, `"3e4f7a9"`).
|
||||||
|
* **`upstream_url`**: Canonical remote repository URL (e.g. `https://svn.corp/repo/trunk`, `git@bitbucket.org:org/repo.git`).
|
||||||
|
* **`repo_name`**: Logical project identifier allowing multiple repositories to share or partition memory namespaces cleanly without collision.
|
||||||
|
* **Audit Ledger (`log_code_change`)**: Enriched with `vcs_type`, `vcs_revision`, `upstream_url`, `author`, `diff_summary`, and extensible `metadata: HashMap<String, String>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Terminal & Process Telemetry
|
||||||
|
|
||||||
|
The server ingests and tracks active terminal commands and sessions:
|
||||||
|
* **Active Terminals**: Tracks PIDs, shell interpreters (`pwsh`, `bash`, `nu`, `zsh`), current working directories (`cwd`), command exit codes, and timestamps.
|
||||||
|
* **Terminal History Endpoint & Resource**: `/terminal/history` and `memory://terminal/recent` expose recent shell commands and output streams to dashboard and LLMs to prevent lost shell context.
|
||||||
|
* **Zero-Latency UDP Streams**: Terminal and IDE telemetry stream over UDP (`MCP_UDP_PORT1`, `MCP_UDP_PORT2`) with zero disk I/O bottlenecks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Automated Error Fix Auto-Matcher
|
||||||
|
- **Tools:** `log_error_fix`, `search_error_fixes` (and alias `suggest_error_fix`)
|
||||||
|
- **When to use:** When encountering a build error, test failure, or stack trace. Call `search_error_fixes` with either a text `query` or `stack_trace` before attempting a fix from scratch.
|
||||||
|
- **Behavior:** Computes cosine similarity between error trace embeddings and past resolution logs when `stack_trace` is provided, or keyword filtering when `query` is provided, returning top matched solutions, modified files, and git commits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Memory State Checkpointing & Rollbacks
|
||||||
|
- **Tool:** `manage_checkpoint` (action: `"create"` | `"restore"`)
|
||||||
|
- **When to use:** Before initiating a large refactor, running experimental subagent tasks, or executing destructive batch operations.
|
||||||
|
- **Behavior:** Saves or restores a point-in-time snapshot of graph entities, active tasks, and tech debt backlogs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Self-Healing Graph Health Sweeper
|
||||||
|
- **Tool:** `sweep_graph_health`
|
||||||
|
- **When to use:** Periodically or before committing major graph changes to audit entity consistency.
|
||||||
|
- **Behavior:** Detects orphaned nodes (0 relations), computes name similarity using pre-computed lowercase keys to identify near-duplicates (e.g., `APIGateway` vs `ApiGateway`), and provides structured `merge_entities` recommendations or auto-prunes orphans.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Causal Lineage & Provenance Tracker
|
||||||
|
- **Tool:** `query_lineage`
|
||||||
|
- **When to use:** When asking *"Why was this component modified?"* or *"What task or ADR led to this code change?"*
|
||||||
|
- **Behavior:** Searches across tasks, ADRs, audit ledger entries, and error fixes to assemble a unified chronological timeline explaining the provenance behind any file, symbol, or commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. ADR Lifecycle & Automated Git Post-Commit Reconciliation
|
||||||
|
|
||||||
|
- **The Golden ADR Rule**: When code implementing an ADR is committed, you MUST IMMEDIATELY update the ADR status to `implemented`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "update",
|
||||||
|
"id": "ADR-XXXX",
|
||||||
|
"status": "implemented",
|
||||||
|
"git_commit": "<commit_hash>",
|
||||||
|
"git_branch": "<branch>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Automated Post-Commit Hook**: The repository provides an automated reconciliation script (`scripts/git-reconcile.py`) installed via `just install-git-hooks`. Upon every `git commit`, the hook scans the commit message for `ADR-XXXX` or task identifiers and reconciles their status in the persistent store.
|
||||||
|
- **Current Architecture Status**: 100% of defined ADRs (ADR-0080 through ADR-0104) are fully implemented and reconciled in the store.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Native Rust Invariants & Subprocess Prohibition (CRITICAL)
|
||||||
|
|
||||||
|
To maintain maximum security, speed, and cross-platform reliability:
|
||||||
|
* **Zero Subprocess Fallbacks**: System and server tools (`clipboard`, `ast`, `search`, `db`) MUST strictly use pure native Rust crates (`arboard`, `tree-sitter`, `tantivy`, `psycopg`). Invocations of external shell commands (`powershell.exe`, `wl-paste`, `xclip`, `cmd.exe`) are strictly prohibited in native handlers.
|
||||||
|
* **Transient Lock Recovery**: Transient OS handle collisions (such as Win32 OLE `OpenClipboard` lock contention) must be handled using native retry loops with backoffs directly in Rust.
|
||||||
|
* **Automated Static Regression Gates**: Automated AST/source audit tests (`test_no_subprocess_clipboard_regression`) verify at test time that forbidden subprocess patterns are absent from handler implementations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. High-Performance Concurrency & Resilience Guarantees
|
||||||
|
|
||||||
|
* **Explicit Fail-Fast Persistence Safety**: Replaced silent fallback to temporary databases (`/tmp/mcp_store_fallback_*`) with an explicit open retry and fail-fast panic unless `MCP_ALLOW_TMP_FALLBACK=1` is explicitly set, preventing silent data loss.
|
||||||
|
* **Async Mutex Deadlock Elimination**: Converted shared state and Neovim connection locks (`shutdown_tx`, `NVIM_CONN`, `ACTIVE_SOCKET`, `HEADLESS_PROC`) to `tokio::sync::Mutex` to prevent worker thread pool starvation across `.await` points.
|
||||||
|
* **Telemetry Session Deduplication & Channel Pruning**: Added `LAST_SESSION` in-memory state deduplication for UDP telemetry writes (eliminating disk I/O thrashing) and distinguished WebSocket `TrySendError::Full` backpressure vs `TrySendError::Closed` client pruning.
|
||||||
|
* **Graph Adjacency Indexing**: Leverages `KnowledgeGraph::build_adjacency_map` to build $O(1)$ lookup adjacency lists for fast BFS shortest path graph queries.
|
||||||
|
* **Atomic Store Write Lock Minimization**: `Store::modify` and `Store::modify_async` release write guards immediately after in-memory state mutations, serializing JSON payloads under read locks to allow non-blocking concurrent readers.
|
||||||
|
* **Async Commit Index Reader Auto-Reload**: `MemoryIndex::commit()` automatically triggers `reader.reload()` upon completing background commits, guaranteeing immediate visibility of newly indexed document terms.
|
||||||
|
* **Zero-Allocation NDJSON Reader**: `mcp-stdio` reclaims line buffers via `std::mem::take` and in-place trimming to eliminate heap allocations during high-frequency NDJSON message parsing.
|
||||||
|
* **Dynamic Character Micro-Batched Fastembed Inference**: `generate_embeddings_async` dynamically batches text arrays using a 16,000 character budget ceiling inside `spawn_blocking`, preventing heap spikes during vector indexing while keeping ONNX SIMD execution saturated.
|
||||||
|
* **Bounded Telemetry Detail Records**: Activity and terminal telemetry buffers enforce a 4,000 character truncation ceiling on log details (`ActivityRecord`, `TerminalHistory`) to prevent unbounded RAM growth under high RPC throughput.
|
||||||
|
* **Zero-Allocation Stream Formatting**: Graph condensation loops (`condense_graph_worker`) format node/relation subgraphs into stream buffers via `std::fmt::Write`, eliminating intermediate String allocation overhead.
|
||||||
|
* **Single-Handle OS Clipboard Retries & Image Downsampling**: `WriteClipboardHandler` initializes OS clipboard handles once per operation and downsamples images exceeding $2048 \times 2048$ resolution before writing raw RGBA bytes.
|
||||||
|
* **Zero Transaction Drop Persistence Guarantee**: `Store::modify` automatically spawns an async task to execute `push_async` with channel backpressure if `push` encounters queue saturation, ensuring zero data loss under spike write loads.
|
||||||
|
* **Token-Budgeted Query Projections**: Decision queries (`query_decisions`) support `limit` caps and compact `include_body: false` projections for token budget optimization.
|
||||||
|
* **Serde Parameter & Enum Ergonomics**: Action enums (`SnippetSearchMode`, `Relation`) support case-insensitive variants and common synonyms (`create`/`add`, `remove`/`delete`, `list`/`read`, `source`/`from`, `target`/`to`, `relationType`/`relation_type`) ensuring seamless LLM tool execution.
|
||||||
|
* **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.
|
||||||
|
* **Non-Blocking Tantivy Search Queries**: `MemoryIndex::search` queries current index searcher snapshots without executing synchronous disk commit locks, preventing query stalls during heavy background indexing.
|
||||||
|
* **Zero-Allocation HashSet<&str> Snippet Deduplication**: `indexer.rs` utilizes borrowed `HashSet<&str>` name lookups during snippet batch modifications, eliminating heap string re-allocations inside the store write lock.
|
||||||
|
* **AST Recursion Depth Safeguard & Zero-Copy Borrowing**: Tree-sitter AST traversal caps recursion depth at 100 to prevent thread stack overflows and borrows string slices (`&str`) during AST node walking.
|
||||||
|
* **Strongly-Typed SearchResult & Pre-Allocated Search Vectors**: `search.rs` uses a strongly-typed `SearchResult` struct with named fields and pre-allocates result vector capacity (`Vec::with_capacity(top_docs.len())`).
|
||||||
|
* **BFS Graph Traversal Pre-allocation & Visited Node Upper Bound**: `GraphQueryBuilder::find_shortest_path` pre-allocates adjacency map capacity (`HashMap::with_capacity(relations.len() * 2)`) and enforces a visited node upper bound (10,000 max) to guarantee deterministic BFS runtime.
|
||||||
|
* **Filesystem Event Debouncing & Proactive State Refresh**: `spawn_watcher` implements a sliding 250ms debouncing window per file path, ignores `.git`, `target`, `.gemini`, and `node_modules`, and broadcasts activity events to `MemoryState`.
|
||||||
|
* **Buffered Line-by-Line AST Workspace Symbol Scanning**: `scan_workspace_for_symbol` reads workspace files via `BufReader` line streams instead of loading entire files into heap strings, preventing memory spikes when traversing source trees.
|
||||||
|
* **AST Node Type Aliasing & Skeleton Preallocation**: `replace_ast_node` documents friendly node aliases (`function`, `fn`, `method`, `struct`, `class`, `enum`, `trait`, `type`), and `read_file_skeleton` preallocates string buffer capacity (`code.len() / 2`).
|
||||||
|
* **Batch Vector Indexing & Similarity Score Guidance**: `VectorDB` provides `index_documents_batch` for single-request multi-point vector upserts and explicit score calibration notes ($\ge 0.75$ high confidence match).
|
||||||
|
* **Compact JSON MCP Resources & UTF-8 Activity Truncation**: MCP resources serialize using compact JSON (`to_string`), `TerminalHistoryResource` / `MilestonesResource` enforce output bounds, and `format_tool_activity_description` uses `floor_char_boundary` for guaranteed UTF-8 safety.
|
||||||
|
* **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 (`process_logs`, action: "get") 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.
|
||||||
|
* **Task Summary UTF-8 Truncation Safety**: `tasks` tool (`action = "list"`) truncates serialized task text strictly along UTF-8 character boundaries using `floor_char_boundary` when enforcing `max_tokens`.
|
||||||
|
* **Directory Tree Depth Safeguard**: `ReadDirectoryArchitectureHandler` caps directory recursion at depth 10 to prevent stack overflow on deep or cyclic directory structures.
|
||||||
|
* **Deterministic Total-Order Score Ranking**: `OmniSearchHandler` uses `f64::total_cmp` for Reciprocal Rank Fusion (RRF) score sorting, guaranteeing deterministic NaN-safe search result ordering.
|
||||||
|
* **RPC Timeout Memory Hygiene**: `nvim-core` maintains request hygiene by removing pending request entries from static RPC maps upon timeout or channel drop, eliminating orphan memory leaks.
|
||||||
|
* **Path Traversal Security Guards**: `validate_safe_path` enforces path canonicalization and rejects relative parent traversal components (`..`) across file and process log handlers (`process_logs` / `ProcessLogsTool`).
|
||||||
|
* **Watcher Map Memory Eviction**: Proactive daemon file watcher in `watcher.rs` caps `last_processed` map size at 1,000 entries and purges entries older than 10 minutes to prevent monotonic memory leakage.
|
||||||
|
* **Comprehensive Serde Casing Aliases**: All 11 consolidated tool action enums (TaskAction, MilestoneAction, SnippetAction, DecisionAction, TechDebtAction, EnvAction, ClipboardAction, HandoffMemoAction, HypothesisAction, AgentSignalAction, ProcessLogAction) include serde alias attributes supporting `snake_case`, `camelCase`, `PascalCase`, and uppercase variants for maximum LLM casing resilience.
|
||||||
|
* **Two-Phase Graph Condensation**: `condense_graph_worker` uses a 2-phase commit (non-destructive `read_with` -> graph insert -> prune by timestamp/content) to prevent data loss if summarization or graph insertion fails.
|
||||||
|
* **Redb Database Lock Retry Backoff**: `init_db` retries transient Redb lock contention with exponential backoff (3 attempts, 150ms delay) before falling back.
|
||||||
|
* **Offloaded Background Index Rebuilds**: `MemoryState::rebuild_index` offloads graph snapshot cloning and Tantivy document re-indexing into `tokio::task::spawn_blocking` to avoid stalling async event loops.
|
||||||
|
* **Broadcast Watch-Based Shutdown Channels**: Background workers utilize `tokio::sync::watch` for broadcast shutdown notifications without consuming cancellation signals.
|
||||||
@@ -0,0 +1,963 @@
|
|||||||
|
#![cfg_attr(
|
||||||
|
not(target_os = "windows"),
|
||||||
|
allow(dead_code, unused_imports, unreachable_code)
|
||||||
|
)]
|
||||||
|
|
||||||
|
pub mod api;
|
||||||
|
pub mod config;
|
||||||
|
pub mod db;
|
||||||
|
pub mod embedding;
|
||||||
|
pub mod error;
|
||||||
|
pub mod handlers;
|
||||||
|
pub mod indexer;
|
||||||
|
pub mod mcp;
|
||||||
|
pub mod models;
|
||||||
|
pub mod ollama;
|
||||||
|
pub mod router;
|
||||||
|
pub mod search;
|
||||||
|
pub mod state;
|
||||||
|
pub mod store;
|
||||||
|
pub mod tools;
|
||||||
|
pub mod watcher;
|
||||||
|
|
||||||
|
use crate::api::rest::GateSetReq;
|
||||||
|
use crate::router::MemoryHandler;
|
||||||
|
use crate::state::MemoryState;
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::AtomicUsize;
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(author, version = env!("APP_VERSION"), about = "Antigravity MCP Memory Server", long_about = None)]
|
||||||
|
pub struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Option<Commands>,
|
||||||
|
/// Target URL for the proxy to connect to (e.g., http://127.0.0.1:3000)
|
||||||
|
#[arg(long)]
|
||||||
|
pub target: Option<String>,
|
||||||
|
/// Send a shutdown request to the currently running server
|
||||||
|
#[arg(long)]
|
||||||
|
pub exit: bool,
|
||||||
|
/// Send a shutdown request to the existing server and wait for it to exit
|
||||||
|
#[arg(long)]
|
||||||
|
pub restart: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub enum Commands {
|
||||||
|
/// Manage authorization gates and verification for actions
|
||||||
|
Gate {
|
||||||
|
#[command(subcommand)]
|
||||||
|
subcmd: GateCommands,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub enum GateCommands {
|
||||||
|
Set {
|
||||||
|
#[arg(long)]
|
||||||
|
action: String,
|
||||||
|
#[arg(long)]
|
||||||
|
target: String,
|
||||||
|
#[arg(long)]
|
||||||
|
namespace: Option<String>,
|
||||||
|
#[arg(short = 'p', long = "param")]
|
||||||
|
params: Vec<String>,
|
||||||
|
#[arg(long, conflicts_with = "block")]
|
||||||
|
authorize: bool,
|
||||||
|
#[arg(long, conflicts_with = "authorize")]
|
||||||
|
block: bool,
|
||||||
|
#[arg(long)]
|
||||||
|
reason: Option<String>,
|
||||||
|
},
|
||||||
|
Verify {
|
||||||
|
#[arg(long)]
|
||||||
|
action: String,
|
||||||
|
#[arg(long)]
|
||||||
|
target: String,
|
||||||
|
#[arg(long)]
|
||||||
|
namespace: Option<String>,
|
||||||
|
#[arg(short = 'p', long = "param")]
|
||||||
|
params: Vec<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
consume: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AppState {
|
||||||
|
pub handler: Arc<MemoryHandler>,
|
||||||
|
pub clients: RwLock<HashMap<String, mpsc::Sender<String>>>,
|
||||||
|
pub next_id: AtomicUsize,
|
||||||
|
pub shutdown_tx: std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ttl_sweeper_worker(state: Arc<MemoryState>) {
|
||||||
|
loop {
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
let mut next_expiry: Option<u64> = None;
|
||||||
|
|
||||||
|
state.project.tasks.read_with(|tasks| {
|
||||||
|
for t in tasks.iter() {
|
||||||
|
if let Some(exp) = t.expires_at
|
||||||
|
&& t.is_active()
|
||||||
|
{
|
||||||
|
next_expiry = Some(next_expiry.map_or(exp, |curr| curr.min(exp)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
state.telemetry.handoff_memos.read_with(|memos| {
|
||||||
|
for m in memos.iter() {
|
||||||
|
if let Some(exp) = m.expires_at {
|
||||||
|
next_expiry = Some(next_expiry.map_or(exp, |curr| curr.min(exp)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
state.telemetry.session_summaries.read_with(|summaries| {
|
||||||
|
for s in summaries.iter() {
|
||||||
|
if let Some(exp) = s.expires_at {
|
||||||
|
next_expiry = Some(next_expiry.map_or(exp, |curr| curr.min(exp)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
state.env.gates.read_with(|gates| {
|
||||||
|
for g in gates.iter() {
|
||||||
|
if let Some(exp) = g.expires_at {
|
||||||
|
next_expiry = Some(next_expiry.map_or(exp, |curr| curr.min(exp)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let sleep_duration = match next_expiry {
|
||||||
|
Some(exp) if exp > now => {
|
||||||
|
let diff = exp - now;
|
||||||
|
std::time::Duration::from_secs(diff.clamp(1, 60))
|
||||||
|
}
|
||||||
|
Some(_) => std::time::Duration::from_millis(50),
|
||||||
|
None => std::time::Duration::from_secs(60),
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = state.shutdown_notify.notified() => break,
|
||||||
|
_ = state.ttl_notify.notified() => {},
|
||||||
|
_ = tokio::time::sleep(sleep_duration) => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
let mut expired_tasks = Vec::new();
|
||||||
|
state.project.tasks.modify(|tasks| {
|
||||||
|
for t in tasks.iter_mut() {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for tid in expired_tasks {
|
||||||
|
state.record_activity(
|
||||||
|
"task_expired",
|
||||||
|
&format!("Task {} expired due to TTL", tid),
|
||||||
|
Some("expired"),
|
||||||
|
);
|
||||||
|
state.broadcast_task_event(crate::models::TaskEvent {
|
||||||
|
task_id: tid,
|
||||||
|
status: "expired".to_string(),
|
||||||
|
action: Some("ttl_expire".to_string()),
|
||||||
|
result: Some(serde_json::json!({ "status": "expired" })),
|
||||||
|
error: None,
|
||||||
|
timestamp: now,
|
||||||
|
session_id: None,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
state.telemetry.handoff_memos.modify(|memos| {
|
||||||
|
memos.retain(|m| m.expires_at.is_none_or(|exp| exp > now));
|
||||||
|
});
|
||||||
|
state.telemetry.session_summaries.modify(|summaries| {
|
||||||
|
summaries.retain(|s| s.expires_at.is_none_or(|exp| exp > now));
|
||||||
|
});
|
||||||
|
state.env.gates.modify(|gates| {
|
||||||
|
gates.retain(|g| g.expires_at.is_none_or(|exp| exp > now));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn index_committer_worker(state: Arc<MemoryState>) {
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = state.shutdown_notify.notified() => break,
|
||||||
|
_ = state.index_commit_notify.notified() => {},
|
||||||
|
}
|
||||||
|
let idx = state.search_index.read().await.clone();
|
||||||
|
let _ = idx.commit().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn condense_graph_worker(state: Arc<MemoryState>) {
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = state.shutdown_notify.notified() => break,
|
||||||
|
_ = state.condense_notify.notified() => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
let threshold: usize = std::env::var("MCP_MEMORY_CONDENSE_THRESHOLD")
|
||||||
|
.unwrap_or_else(|_| "100".to_string())
|
||||||
|
.parse()
|
||||||
|
.unwrap_or(100);
|
||||||
|
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
let snippet_condensation = state.code.snippets.read_with(|snippets| {
|
||||||
|
if snippets.len() > threshold {
|
||||||
|
let mut sorted = snippets.clone();
|
||||||
|
sorted.sort_by_key(|s| s.updated_at);
|
||||||
|
let to_remove = sorted.len() - (threshold / 2);
|
||||||
|
let removed: Vec<_> = sorted.into_iter().take(to_remove).collect();
|
||||||
|
let mut content = String::new();
|
||||||
|
let mut names = Vec::new();
|
||||||
|
for r in &removed {
|
||||||
|
content.push_str(&format!(
|
||||||
|
"Name: {}\nDesc: {}\nCode: {}\n",
|
||||||
|
r.name, r.description, r.code
|
||||||
|
));
|
||||||
|
names.push(r.name.clone());
|
||||||
|
}
|
||||||
|
Some((content, names))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let state_for_index = Arc::clone(&state);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
state_for_index.rebuild_index().await;
|
||||||
|
tracing::info!("Index rebuild complete.");
|
||||||
|
});
|
||||||
|
|
||||||
|
crate::indexer::start_background_indexer(Arc::clone(&state)).await;
|
||||||
|
|
||||||
|
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();
|
||||||
|
let app_state = Arc::new(AppState {
|
||||||
|
handler: Arc::new(MemoryHandler::new(Arc::clone(&state))),
|
||||||
|
clients: RwLock::new(HashMap::new()),
|
||||||
|
next_id: AtomicUsize::new(1),
|
||||||
|
shutdown_tx: std::sync::Mutex::new(Some(shutdown_tx)),
|
||||||
|
});
|
||||||
|
|
||||||
|
let app_state_clone = Arc::clone(&app_state);
|
||||||
|
let mut rx = state.activity_tx.subscribe();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(msg) => {
|
||||||
|
let mut closed_ids = Vec::new();
|
||||||
|
{
|
||||||
|
let clients_guard = app_state_clone
|
||||||
|
.clients
|
||||||
|
.read()
|
||||||
|
.unwrap_or_else(|e| e.into_inner());
|
||||||
|
for (id, tx) in clients_guard.iter() {
|
||||||
|
if tx.try_send(msg.clone()).is_err() && tx.is_closed() {
|
||||||
|
closed_ids.push(id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !closed_ids.is_empty() {
|
||||||
|
let mut write_guard = app_state_clone
|
||||||
|
.clients
|
||||||
|
.write()
|
||||||
|
.unwrap_or_else(|e| e.into_inner());
|
||||||
|
for id in closed_ids {
|
||||||
|
write_guard.remove(&id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let udp_state = Arc::clone(&app_state);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let port1 = std::env::var("MCP_UDP_PORT1").unwrap_or_else(|_| "3001".to_string());
|
||||||
|
if let Ok(socket) = tokio::net::UdpSocket::bind(format!("127.0.0.1:{}", port1)).await {
|
||||||
|
let socket = Arc::new(socket);
|
||||||
|
let socket_rx = socket.clone();
|
||||||
|
|
||||||
|
let mut subscribers: HashMap<(String, String), std::net::SocketAddr> = HashMap::new();
|
||||||
|
let mut buf = vec![0u8; 65536];
|
||||||
|
let mut event_rx = udp_state.handler.state.event_bus_tx.subscribe();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
recv_res = socket_rx.recv_from(&mut buf) => {
|
||||||
|
if let Ok((len, addr)) = recv_res {
|
||||||
|
if let Ok(payload) = serde_json::from_slice::<crate::models::TerminalHistory>(&buf[..len]) {
|
||||||
|
udp_state
|
||||||
|
.handler
|
||||||
|
.state
|
||||||
|
.record_terminal_history(payload.clone());
|
||||||
|
|
||||||
|
let ws_msg = serde_json::json!({
|
||||||
|
"type": "terminal_telemetry",
|
||||||
|
"data": payload
|
||||||
|
});
|
||||||
|
let msg_str = ws_msg.to_string();
|
||||||
|
|
||||||
|
let senders: Vec<_> = udp_state
|
||||||
|
.clients
|
||||||
|
.read()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.values()
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
for tx in senders {
|
||||||
|
let _ = tx.try_send(msg_str.clone());
|
||||||
|
}
|
||||||
|
} 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")
|
||||||
|
&& 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"
|
||||||
|
&& 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()),
|
||||||
|
)
|
||||||
|
&& (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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let nvim_udp_state = Arc::clone(&app_state);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let port2 = std::env::var("MCP_UDP_PORT2").unwrap_or_else(|_| "3002".to_string());
|
||||||
|
if let Ok(socket) = tokio::net::UdpSocket::bind(format!("127.0.0.1:{}", port2)).await {
|
||||||
|
let mut buf = vec![0u8; 65536];
|
||||||
|
loop {
|
||||||
|
if let Ok((len, _addr)) = socket.recv_from(&mut buf).await
|
||||||
|
&& let Ok(payload) =
|
||||||
|
serde_json::from_slice::<crate::api::telemetry::NvimTelemetry>(&buf[..len])
|
||||||
|
{
|
||||||
|
if payload.event == "FocusGained"
|
||||||
|
|| payload.event == "BufEnter"
|
||||||
|
|| payload.event == "VimEnter"
|
||||||
|
{
|
||||||
|
let session = &payload.session_id;
|
||||||
|
let _is_unix_socket = session.starts_with('/') || session.starts_with('~');
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
let nvim_dir = home.join(".gemini");
|
||||||
|
let nvim_txt_path = nvim_dir.join("active_nvim.txt");
|
||||||
|
let tmp_path =
|
||||||
|
nvim_dir.join(format!("active_nvim_{}.tmp", std::process::id()));
|
||||||
|
if tokio::fs::create_dir_all(&nvim_dir).await.is_ok()
|
||||||
|
&& tokio::fs::write(&tmp_path, session).await.is_ok()
|
||||||
|
{
|
||||||
|
let _ = tokio::fs::rename(&tmp_path, &nvim_txt_path).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (tech_debts, adrs) = if let Some(ref f) = payload.file {
|
||||||
|
crate::api::telemetry::find_projected_knowledge(
|
||||||
|
&nvim_udp_state.handler.state,
|
||||||
|
f,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(Vec::new(), Vec::new())
|
||||||
|
};
|
||||||
|
|
||||||
|
let ws_msg = serde_json::json!({
|
||||||
|
"type": "nvim_telemetry",
|
||||||
|
"data": payload,
|
||||||
|
"tech_debts": tech_debts,
|
||||||
|
"adrs": adrs
|
||||||
|
});
|
||||||
|
let msg_str = ws_msg.to_string();
|
||||||
|
|
||||||
|
let senders: Vec<_> = nvim_udp_state
|
||||||
|
.clients
|
||||||
|
.read()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.values()
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
for tx in senders {
|
||||||
|
let _ = tx.try_send(msg_str.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.event == "BufWritePost"
|
||||||
|
&& let Some(ref file_path) = payload.file
|
||||||
|
{
|
||||||
|
let normalized_file = file_path.replace("\\", "/");
|
||||||
|
let topic = format!("nvim:save:{}", normalized_file);
|
||||||
|
let event = crate::state::GenericEvent {
|
||||||
|
topic,
|
||||||
|
session_id: Some(payload.session_id.clone()),
|
||||||
|
payload: serde_json::json!(&payload),
|
||||||
|
};
|
||||||
|
let _ = nvim_udp_state.handler.state.event_bus_tx.send(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// 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 app = api::setup::create_router(app_state);
|
||||||
|
|
||||||
|
let port_str = std::env::var("MCP_PORT").unwrap_or_else(|_| "3000".to_string());
|
||||||
|
tracing::info!(
|
||||||
|
"MCP Memory Server running on http://127.0.0.1:{}/ws",
|
||||||
|
port_str
|
||||||
|
);
|
||||||
|
let addr: std::net::SocketAddr = format!("127.0.0.1:{}", port_str)
|
||||||
|
.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())
|
||||||
|
.with_graceful_shutdown(async move {
|
||||||
|
let _ = shutdown_rx.await;
|
||||||
|
})
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("axum::serve graceful shutdown complete.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init_logging(app_name: &str) -> Option<tracing_appender::non_blocking::WorkerGuard> {
|
||||||
|
let base_dir = std::env::var("MCP_MEMORY_STORE_DIR").unwrap_or_else(|_| {
|
||||||
|
dirs::home_dir()
|
||||||
|
.map(|mut h| {
|
||||||
|
h.push(".gemini/mcp_memory");
|
||||||
|
h.to_string_lossy().to_string()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| ".gemini/mcp_memory".into())
|
||||||
|
});
|
||||||
|
let log_dir = std::path::PathBuf::from(base_dir).join("logs");
|
||||||
|
std::fs::create_dir_all(&log_dir).unwrap_or_default();
|
||||||
|
|
||||||
|
let file_appender = tracing_appender::rolling::daily(log_dir, format!("{}.log", app_name));
|
||||||
|
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
|
||||||
|
|
||||||
|
let _ = tracing_subscriber::fmt()
|
||||||
|
.with_writer(non_blocking)
|
||||||
|
.with_ansi(false)
|
||||||
|
.with_max_level(tracing::Level::INFO)
|
||||||
|
.with_thread_ids(true)
|
||||||
|
.with_thread_names(true)
|
||||||
|
.try_init();
|
||||||
|
|
||||||
|
Some(guard)
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
let base_dir = std::env::var("MCP_MEMORY_STORE_DIR").unwrap_or_else(|_| {
|
||||||
|
dirs::home_dir()
|
||||||
|
.map(|mut h| {
|
||||||
|
h.push(".gemini/mcp_memory");
|
||||||
|
h.to_string_lossy().into_owned()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| ".gemini/mcp_memory".into())
|
||||||
|
});
|
||||||
|
let base = PathBuf::from(base_dir);
|
||||||
|
|
||||||
|
if cli.exit {
|
||||||
|
let port = std::env::var("MCP_PORT").unwrap_or_else(|_| "3000".to_string());
|
||||||
|
let token = std::fs::read_to_string(base.join("admin.token")).unwrap_or_default();
|
||||||
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
|
rt.block_on(async {
|
||||||
|
let client = reqwest::Client::builder().build().unwrap_or_default();
|
||||||
|
let mut req = client.post(format!("http://127.0.0.1:{}/shutdown", port));
|
||||||
|
if !token.trim().is_empty() {
|
||||||
|
req = req.header("Authorization", format!("Bearer {}", token.trim()));
|
||||||
|
}
|
||||||
|
let _ = req.send().await;
|
||||||
|
});
|
||||||
|
|
||||||
|
if cli.restart {
|
||||||
|
std::thread::sleep(Duration::from_secs(2));
|
||||||
|
} else {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(Commands::Gate { subcmd }) = cli.command {
|
||||||
|
let port = std::env::var("MCP_PORT").unwrap_or_else(|_| "3000".to_string());
|
||||||
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
|
match subcmd {
|
||||||
|
GateCommands::Set {
|
||||||
|
action,
|
||||||
|
target,
|
||||||
|
namespace,
|
||||||
|
params,
|
||||||
|
authorize,
|
||||||
|
block,
|
||||||
|
reason,
|
||||||
|
} => {
|
||||||
|
let mut pmap = HashMap::new();
|
||||||
|
for p in params {
|
||||||
|
if let Some((k, v)) = p.split_once('=') {
|
||||||
|
pmap.insert(k.to_string(), v.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let req = GateSetReq {
|
||||||
|
action,
|
||||||
|
target,
|
||||||
|
namespace,
|
||||||
|
params: pmap,
|
||||||
|
authorize: if authorize { Some(true) } else { None },
|
||||||
|
block: if block { Some(true) } else { None },
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
rt.block_on(async {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let res = client
|
||||||
|
.post(format!("http://127.0.0.1:{}/gate/set", port))
|
||||||
|
.json(&req)
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
match res {
|
||||||
|
Ok(r) if r.status().is_success() => println!("Gate updated successfully"),
|
||||||
|
Ok(r) => println!("Failed to update gate: {}", r.status()),
|
||||||
|
Err(e) => println!("Error connecting to server: {}", e),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
GateCommands::Verify {
|
||||||
|
action,
|
||||||
|
target,
|
||||||
|
namespace,
|
||||||
|
params: _,
|
||||||
|
consume,
|
||||||
|
} => {
|
||||||
|
let mut url = format!(
|
||||||
|
"http://127.0.0.1:{}/gate/verify?action={}&target={}&consume={}",
|
||||||
|
port, action, target, consume
|
||||||
|
);
|
||||||
|
if let Some(ns) = namespace {
|
||||||
|
url.push_str(&format!("&namespace={}", ns));
|
||||||
|
}
|
||||||
|
rt.block_on(async {
|
||||||
|
let res = reqwest::get(&url).await;
|
||||||
|
match res {
|
||||||
|
Ok(r) if r.status().is_success() => std::process::exit(0),
|
||||||
|
Ok(r) if r.status() == reqwest::StatusCode::FORBIDDEN => {
|
||||||
|
let text = r.text().await.unwrap_or_default();
|
||||||
|
eprintln!("{}", text);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
Ok(r) if r.status() == reqwest::StatusCode::NOT_FOUND => {
|
||||||
|
eprintln!("Action not yet authorized.");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
Ok(r) => {
|
||||||
|
eprintln!("Unexpected status: {}", r.status());
|
||||||
|
std::process::exit(3);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error connecting to server: {}", e);
|
||||||
|
std::process::exit(4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = uuid::Uuid::new_v4().to_string();
|
||||||
|
std::fs::write(base.join("admin.token"), &token).unwrap_or_default();
|
||||||
|
|
||||||
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
rt.block_on(async {
|
||||||
|
let state = Arc::new(MemoryState::new(&base.to_string_lossy()));
|
||||||
|
|
||||||
|
if let Err(e) = run_server(state).await {
|
||||||
|
tracing::error!("Server error: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cli_parsing_default() {
|
||||||
|
let cli = Cli::try_parse_from(["mcp-memory-server"]).unwrap();
|
||||||
|
assert!(cli.command.is_none());
|
||||||
|
assert!(!cli.exit);
|
||||||
|
assert!(!cli.restart);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cli_parsing_exit_and_target() {
|
||||||
|
let cli = Cli::try_parse_from([
|
||||||
|
"mcp-memory-server",
|
||||||
|
"--exit",
|
||||||
|
"--target",
|
||||||
|
"http://localhost:3000",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert!(cli.exit);
|
||||||
|
assert_eq!(cli.target.as_deref(), Some("http://localhost:3000"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cli_parsing_gate_set() {
|
||||||
|
let cli = Cli::try_parse_from([
|
||||||
|
"mcp-memory-server",
|
||||||
|
"gate",
|
||||||
|
"set",
|
||||||
|
"--action",
|
||||||
|
"git_push",
|
||||||
|
"--target",
|
||||||
|
"master",
|
||||||
|
"--authorize",
|
||||||
|
"--reason",
|
||||||
|
"Approved by lead",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if let Some(Commands::Gate {
|
||||||
|
subcmd:
|
||||||
|
GateCommands::Set {
|
||||||
|
action,
|
||||||
|
target,
|
||||||
|
authorize,
|
||||||
|
reason,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
}) = cli.command
|
||||||
|
{
|
||||||
|
assert_eq!(action, "git_push");
|
||||||
|
assert_eq!(target, "master");
|
||||||
|
assert!(authorize);
|
||||||
|
assert_eq!(reason.as_deref(), Some("Approved by lead"));
|
||||||
|
} else {
|
||||||
|
panic!("Expected Gate Set subcommand");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cli_parsing_gate_verify() {
|
||||||
|
let cli = Cli::try_parse_from([
|
||||||
|
"mcp-memory-server",
|
||||||
|
"gate",
|
||||||
|
"verify",
|
||||||
|
"--action",
|
||||||
|
"deploy",
|
||||||
|
"--target",
|
||||||
|
"prod",
|
||||||
|
"--consume",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if let Some(Commands::Gate {
|
||||||
|
subcmd:
|
||||||
|
GateCommands::Verify {
|
||||||
|
action,
|
||||||
|
target,
|
||||||
|
consume,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
}) = cli.command
|
||||||
|
{
|
||||||
|
assert_eq!(action, "deploy");
|
||||||
|
assert_eq!(target, "prod");
|
||||||
|
assert!(consume);
|
||||||
|
} else {
|
||||||
|
panic!("Expected Gate Verify subcommand");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_init_logging_helper() {
|
||||||
|
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("MCP_MEMORY_STORE_DIR", temp_dir.path().to_str().unwrap());
|
||||||
|
}
|
||||||
|
let guard = init_logging("test_app");
|
||||||
|
assert!(guard.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_background_workers_one_tick() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let state = Arc::new(MemoryState::new(temp_dir.path().to_str().unwrap()));
|
||||||
|
|
||||||
|
// Test worker functions by spawning them briefly
|
||||||
|
let handle1 = tokio::spawn(ttl_sweeper_worker(state.clone()));
|
||||||
|
let handle2 = tokio::spawn(index_committer_worker(state.clone()));
|
||||||
|
let handle3 = tokio::spawn(condense_graph_worker(state.clone()));
|
||||||
|
|
||||||
|
state.ttl_notify.notify_one();
|
||||||
|
state.index_commit_notify.notify_one();
|
||||||
|
state.condense_notify.notify_one();
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
|
||||||
|
handle1.abort();
|
||||||
|
handle2.abort();
|
||||||
|
handle3.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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();
|
||||||
|
let state = Arc::new(MemoryState::new(temp_dir.path().to_str().unwrap()));
|
||||||
|
|
||||||
|
// Bind to a free port to avoid conflicts
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
drop(listener);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("MCP_PORT", port.to_string());
|
||||||
|
std::env::set_var("MCP_UDP_PORT1", (port + 1).to_string());
|
||||||
|
std::env::set_var("MCP_UDP_PORT2", (port + 2).to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let server_handle = tokio::spawn(async move {
|
||||||
|
let _ = run_server(state).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
|
||||||
|
let _ = reqwest::Client::new()
|
||||||
|
.get(format!("http://127.0.0.1:{}/ping", port))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
server_handle.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-475
@@ -1,477 +1,3 @@
|
|||||||
#![cfg_attr(
|
|
||||||
not(target_os = "windows"),
|
|
||||||
allow(dead_code, unused_imports, unreachable_code)
|
|
||||||
)]
|
|
||||||
|
|
||||||
mod api;
|
|
||||||
pub mod db;
|
|
||||||
pub mod error;
|
|
||||||
mod handlers;
|
|
||||||
mod mcp;
|
|
||||||
mod models;
|
|
||||||
mod router;
|
|
||||||
mod search;
|
|
||||||
pub mod embedding;
|
|
||||||
pub mod indexer;
|
|
||||||
mod state;
|
|
||||||
mod store;
|
|
||||||
mod tools;
|
|
||||||
mod clipboard_watcher;
|
|
||||||
mod watcher;
|
|
||||||
|
|
||||||
use crate::api::rest::GateSetReq;
|
|
||||||
use crate::router::MemoryHandler;
|
|
||||||
use crate::state::MemoryState;
|
|
||||||
use clap::{Parser, Subcommand};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::atomic::AtomicUsize;
|
|
||||||
use std::sync::{Arc, RwLock};
|
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
|
||||||
#[command(author, version = env!("APP_VERSION"), about = "Antigravity MCP Memory Server", long_about = None)]
|
|
||||||
struct Cli {
|
|
||||||
#[command(subcommand)]
|
|
||||||
command: Option<Commands>,
|
|
||||||
/// Target URL for the proxy to connect to (e.g., http://127.0.0.1:3000)
|
|
||||||
#[arg(long)]
|
|
||||||
target: Option<String>,
|
|
||||||
/// Send a shutdown request to the currently running server
|
|
||||||
#[arg(long)]
|
|
||||||
exit: bool,
|
|
||||||
/// Send a shutdown request to the existing server and wait for it to exit
|
|
||||||
#[arg(long)]
|
|
||||||
restart: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
|
||||||
enum Commands {
|
|
||||||
/// Manage authorization gates and verification for actions
|
|
||||||
Gate {
|
|
||||||
#[command(subcommand)]
|
|
||||||
subcmd: GateCommands,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
|
||||||
enum GateCommands {
|
|
||||||
Set {
|
|
||||||
#[arg(long)]
|
|
||||||
action: String,
|
|
||||||
#[arg(long)]
|
|
||||||
target: String,
|
|
||||||
#[arg(long)]
|
|
||||||
namespace: Option<String>,
|
|
||||||
#[arg(short = 'p', long = "param")]
|
|
||||||
params: Vec<String>,
|
|
||||||
#[arg(long, conflicts_with = "block")]
|
|
||||||
authorize: bool,
|
|
||||||
#[arg(long, conflicts_with = "authorize")]
|
|
||||||
block: bool,
|
|
||||||
#[arg(long)]
|
|
||||||
reason: Option<String>,
|
|
||||||
},
|
|
||||||
Verify {
|
|
||||||
#[arg(long)]
|
|
||||||
action: String,
|
|
||||||
#[arg(long)]
|
|
||||||
target: String,
|
|
||||||
#[arg(long)]
|
|
||||||
namespace: Option<String>,
|
|
||||||
#[arg(short = 'p', long = "param")]
|
|
||||||
params: Vec<String>,
|
|
||||||
#[arg(long)]
|
|
||||||
consume: bool,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct AppState {
|
|
||||||
handler: Arc<MemoryHandler>,
|
|
||||||
clients: RwLock<HashMap<String, mpsc::Sender<String>>>,
|
|
||||||
next_id: AtomicUsize,
|
|
||||||
pub shutdown_tx: std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ttl_sweeper_worker(state: Arc<MemoryState>) {
|
|
||||||
loop {
|
|
||||||
tokio::time::sleep(Duration::from_secs(3600)).await;
|
|
||||||
let now = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs();
|
|
||||||
|
|
||||||
state.tasks.modify(|tasks| {
|
|
||||||
tasks.retain(|t| t.expires_at.is_none_or(|exp| exp > now));
|
|
||||||
});
|
|
||||||
state.sticky.modify(|notes| {
|
|
||||||
notes.retain(|n| n.expires_at.is_none_or(|exp| exp > now));
|
|
||||||
});
|
|
||||||
state.handoff_memos.modify(|memos| {
|
|
||||||
memos.retain(|m| m.expires_at.is_none_or(|exp| exp > now));
|
|
||||||
});
|
|
||||||
state.session_summaries.modify(|summaries| {
|
|
||||||
summaries.retain(|s| s.expires_at.is_none_or(|exp| exp > now));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async fn index_committer_worker(state: Arc<MemoryState>) {
|
|
||||||
loop {
|
|
||||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
|
||||||
// Periodically commit the search index to persist inline indexing operations
|
|
||||||
let idx_opt = state.search_index.read().ok().map(|idx| idx.clone());
|
|
||||||
if let Some(idx) = idx_opt {
|
|
||||||
let _ = idx.commit().await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let state_for_index = Arc::clone(&state);
|
|
||||||
tokio::spawn(async move {
|
|
||||||
state_for_index.rebuild_index().await;
|
|
||||||
tracing::info!("Index rebuild complete.");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the global codebase indexer
|
|
||||||
crate::indexer::start_background_indexer(Arc::clone(&state)).await;
|
|
||||||
|
|
||||||
tokio::spawn(index_committer_worker(Arc::clone(&state)));
|
|
||||||
tokio::spawn(ttl_sweeper_worker(Arc::clone(&state)));
|
|
||||||
crate::clipboard_watcher::spawn_watcher(Arc::clone(&state));
|
|
||||||
crate::watcher::spawn_watcher(Arc::clone(&state));
|
|
||||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
|
|
||||||
let app_state = Arc::new(AppState {
|
|
||||||
handler: Arc::new(MemoryHandler::new(Arc::clone(&state))),
|
|
||||||
clients: RwLock::new(HashMap::new()),
|
|
||||||
next_id: AtomicUsize::new(1),
|
|
||||||
shutdown_tx: std::sync::Mutex::new(Some(shutdown_tx)),
|
|
||||||
});
|
|
||||||
|
|
||||||
let app_state_clone = Arc::clone(&app_state);
|
|
||||||
let mut rx = state.activity_tx.subscribe();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
match rx.recv().await {
|
|
||||||
Ok(msg) => {
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// UDP Telemetry Listener
|
|
||||||
let udp_state = Arc::clone(&app_state);
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let port1 = std::env::var("MCP_UDP_PORT1").unwrap_or_else(|_| "3001".to_string());
|
|
||||||
if let Ok(socket) = tokio::net::UdpSocket::bind(format!("127.0.0.1:{}", port1)).await {
|
|
||||||
let mut buf = [0; 4096];
|
|
||||||
loop {
|
|
||||||
if let Ok((len, _addr)) = socket.recv_from(&mut buf).await
|
|
||||||
&& let Ok(payload) = serde_json::from_slice::<crate::models::TerminalHistory>(&buf[..len])
|
|
||||||
{
|
|
||||||
udp_state.handler.state.terminal_history.modify(|history| {
|
|
||||||
history.push_front(payload.clone());
|
|
||||||
if history.len() > 100 {
|
|
||||||
history.pop_back();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let ws_msg = serde_json::json!({
|
|
||||||
"type": "terminal_telemetry",
|
|
||||||
"data": payload
|
|
||||||
});
|
|
||||||
let msg_str = ws_msg.to_string();
|
|
||||||
|
|
||||||
let senders: Vec<_> = udp_state
|
|
||||||
.clients
|
|
||||||
.read()
|
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
|
||||||
.values()
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
for tx in senders {
|
|
||||||
let _ = tx.try_send(msg_str.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// UDP Neovim Telemetry Listener
|
|
||||||
let nvim_udp_state = Arc::clone(&app_state);
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let port2 = std::env::var("MCP_UDP_PORT2").unwrap_or_else(|_| "3002".to_string());
|
|
||||||
if let Ok(socket) = tokio::net::UdpSocket::bind(format!("127.0.0.1:{}", port2)).await {
|
|
||||||
let mut buf = [0; 4096];
|
|
||||||
loop {
|
|
||||||
if let Ok((len, _addr)) = socket.recv_from(&mut buf).await
|
|
||||||
&& let Ok(payload) = serde_json::from_slice::<crate::api::telemetry::NvimTelemetry>(&buf[..len])
|
|
||||||
{
|
|
||||||
// 1. Legacy disk write for active_nvim.txt
|
|
||||||
if payload.event == "FocusGained" || payload.event == "BufEnter" || payload.event == "VimEnter" {
|
|
||||||
let session = &payload.session_id;
|
|
||||||
let is_unix_socket = session.starts_with('/') || session.starts_with('~');
|
|
||||||
if is_unix_socket {
|
|
||||||
let wsl_path = "\\\\wsl.localhost\\Ubuntu\\home\\riz\\.gemini\\active_nvim.txt";
|
|
||||||
let _ = tokio::fs::write(wsl_path, session).await;
|
|
||||||
} else {
|
|
||||||
let profile = std::env::var("USERPROFILE").unwrap_or_else(|_| "C:\\Users\\reazul.ashraf".into());
|
|
||||||
let win_path = format!("{}\\.gemini\\active_nvim.txt", profile);
|
|
||||||
let _ = tokio::fs::write(&win_path, session).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Broadcast to UI
|
|
||||||
let ws_msg = serde_json::json!({
|
|
||||||
"type": "nvim_telemetry",
|
|
||||||
"data": payload
|
|
||||||
});
|
|
||||||
let msg_str = ws_msg.to_string();
|
|
||||||
|
|
||||||
let senders: Vec<_> = nvim_udp_state
|
|
||||||
.clients
|
|
||||||
.read()
|
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
|
||||||
.values()
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
for tx in senders {
|
|
||||||
let _ = tx.try_send(msg_str.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Event bus trigger for auto-save hook
|
|
||||||
if payload.event == "BufWritePost"
|
|
||||||
&& let Some(ref file_path) = payload.file
|
|
||||||
{
|
|
||||||
let normalized_file = file_path.replace("\\", "/");
|
|
||||||
let topic = format!("nvim:save:{}", normalized_file);
|
|
||||||
let event = crate::state::GenericEvent {
|
|
||||||
topic,
|
|
||||||
session_id: Some(payload.session_id.clone()),
|
|
||||||
payload: serde_json::json!(&payload),
|
|
||||||
};
|
|
||||||
let _ = nvim_udp_state.handler.state.event_bus_tx.send(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Interactive Agent UI Events
|
|
||||||
if payload.event.starts_with("agent_") {
|
|
||||||
let topic = format!("nvim:ui:{}", payload.event);
|
|
||||||
let event = crate::state::GenericEvent {
|
|
||||||
topic,
|
|
||||||
session_id: Some(payload.session_id.clone()),
|
|
||||||
payload: serde_json::json!(&payload),
|
|
||||||
};
|
|
||||||
let _ = nvim_udp_state.handler.state.event_bus_tx.send(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let app = api::setup::create_router(app_state);
|
|
||||||
|
|
||||||
let port_str = std::env::var("MCP_PORT").unwrap_or_else(|_| "3000".to_string());
|
|
||||||
tracing::info!("MCP Memory Server running on http://127.0.0.1:{}/ws", port_str);
|
|
||||||
let addr: std::net::SocketAddr = format!("127.0.0.1:{}", port_str)
|
|
||||||
.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())
|
|
||||||
.with_graceful_shutdown(async move {
|
|
||||||
let _ = shutdown_rx.await;
|
|
||||||
})
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init_logging(app_name: &str) -> Option<tracing_appender::non_blocking::WorkerGuard> {
|
|
||||||
let base_dir = std::env::var("MCP_MEMORY_STORE_DIR").unwrap_or_else(|_| {
|
|
||||||
dirs::home_dir()
|
|
||||||
.map(|mut h| {
|
|
||||||
h.push(".gemini/mcp_memory");
|
|
||||||
h.to_string_lossy().to_string()
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| ".gemini/mcp_memory".into())
|
|
||||||
});
|
|
||||||
let log_dir = std::path::PathBuf::from(base_dir).join("logs");
|
|
||||||
std::fs::create_dir_all(&log_dir).unwrap_or_default();
|
|
||||||
|
|
||||||
let file_appender = tracing_appender::rolling::daily(log_dir, format!("{}.log", app_name));
|
|
||||||
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
|
|
||||||
|
|
||||||
let _ = tracing_subscriber::fmt()
|
|
||||||
.with_writer(non_blocking)
|
|
||||||
.with_ansi(false)
|
|
||||||
.with_max_level(tracing::Level::INFO)
|
|
||||||
.with_thread_ids(true)
|
|
||||||
.with_thread_names(true)
|
|
||||||
.try_init();
|
|
||||||
|
|
||||||
Some(guard)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let _guard = init_logging("mcp-memory-server");
|
mcp_memory_server::run_cli()
|
||||||
let cli = Cli::parse();
|
|
||||||
|
|
||||||
let base_dir = std::env::var("MCP_MEMORY_STORE_DIR").unwrap_or_else(|_| {
|
|
||||||
dirs::home_dir()
|
|
||||||
.map(|mut h| {
|
|
||||||
h.push(".gemini/mcp_memory");
|
|
||||||
h.to_string_lossy().into_owned()
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| ".gemini/mcp_memory".into())
|
|
||||||
});
|
|
||||||
let base = PathBuf::from(base_dir);
|
|
||||||
|
|
||||||
if cli.exit {
|
|
||||||
let port = std::env::var("MCP_PORT").unwrap_or_else(|_| "3000".to_string());
|
|
||||||
let token = std::fs::read_to_string(base.join("admin.token")).unwrap_or_default();
|
|
||||||
let mut cmd = std::process::Command::new("curl");
|
|
||||||
cmd.arg("-k").arg("-X").arg("POST");
|
|
||||||
if !token.is_empty() {
|
|
||||||
cmd.arg("-H")
|
|
||||||
.arg(format!("Authorization: Bearer {}", token.trim()));
|
|
||||||
}
|
|
||||||
let _ = cmd
|
|
||||||
.arg(format!("http://127.0.0.1:{}/shutdown", port))
|
|
||||||
.output();
|
|
||||||
|
|
||||||
if cli.restart {
|
|
||||||
std::thread::sleep(Duration::from_secs(2));
|
|
||||||
} else {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(Commands::Gate { subcmd }) = cli.command {
|
|
||||||
let port = std::env::var("MCP_PORT").unwrap_or_else(|_| "3000".to_string());
|
|
||||||
let rt = tokio::runtime::Runtime::new()?;
|
|
||||||
match subcmd {
|
|
||||||
GateCommands::Set {
|
|
||||||
action,
|
|
||||||
target,
|
|
||||||
namespace,
|
|
||||||
params,
|
|
||||||
authorize,
|
|
||||||
block,
|
|
||||||
reason,
|
|
||||||
} => {
|
|
||||||
let mut pmap = HashMap::new();
|
|
||||||
for p in params {
|
|
||||||
if let Some((k, v)) = p.split_once('=') {
|
|
||||||
pmap.insert(k.to_string(), v.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let req = GateSetReq {
|
|
||||||
action,
|
|
||||||
target,
|
|
||||||
namespace,
|
|
||||||
params: pmap,
|
|
||||||
authorize: if authorize { Some(true) } else { None },
|
|
||||||
block: if block { Some(true) } else { None },
|
|
||||||
reason,
|
|
||||||
};
|
|
||||||
rt.block_on(async {
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
let res = client
|
|
||||||
.post(format!("http://127.0.0.1:{}/gate/set", port))
|
|
||||||
.json(&req)
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
match res {
|
|
||||||
Ok(r) if r.status().is_success() => println!("Gate updated successfully"),
|
|
||||||
Ok(r) => println!("Failed to update gate: {}", r.status()),
|
|
||||||
Err(e) => println!("Error connecting to server: {}", e),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
GateCommands::Verify {
|
|
||||||
action,
|
|
||||||
target,
|
|
||||||
namespace,
|
|
||||||
params: _,
|
|
||||||
consume,
|
|
||||||
} => {
|
|
||||||
let mut url = format!(
|
|
||||||
"http://127.0.0.1:{}/gate/verify?action={}&target={}&consume={}",
|
|
||||||
port, action, target, consume
|
|
||||||
);
|
|
||||||
if let Some(ns) = namespace {
|
|
||||||
url.push_str(&format!("&namespace={}", ns));
|
|
||||||
}
|
|
||||||
rt.block_on(async {
|
|
||||||
let res = reqwest::get(&url).await;
|
|
||||||
match res {
|
|
||||||
Ok(r) if r.status().is_success() => std::process::exit(0),
|
|
||||||
Ok(r) if r.status() == reqwest::StatusCode::FORBIDDEN => {
|
|
||||||
let text = r.text().await.unwrap_or_default();
|
|
||||||
eprintln!("{}", text);
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
Ok(r) if r.status() == reqwest::StatusCode::NOT_FOUND => {
|
|
||||||
eprintln!("Action not yet authorized.");
|
|
||||||
std::process::exit(2);
|
|
||||||
}
|
|
||||||
Ok(r) => {
|
|
||||||
eprintln!("Unexpected status: {}", r.status());
|
|
||||||
std::process::exit(3);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Error connecting to server: {}", e);
|
|
||||||
std::process::exit(4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let token = uuid::Uuid::new_v4().to_string();
|
|
||||||
std::fs::write(base.join("admin.token"), &token).unwrap_or_default();
|
|
||||||
|
|
||||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
rt.block_on(async {
|
|
||||||
let state = Arc::new(MemoryState::new(&base.to_string_lossy()));
|
|
||||||
if let Err(e) = run_server(state).await {
|
|
||||||
tracing::error!("Server error: {}", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
@@ -27,6 +27,7 @@ pub fn tool_def<T: JsonSchema>(name: &str, fallback_desc: &str) -> serde_json::V
|
|||||||
let desc = schema_val
|
let desc = schema_val
|
||||||
.get("description")
|
.get("description")
|
||||||
.and_then(|d| d.as_str())
|
.and_then(|d| d.as_str())
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
.unwrap_or(fallback_desc)
|
.unwrap_or(fallback_desc)
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
@@ -38,6 +39,18 @@ pub fn tool_def<T: JsonSchema>(name: &str, fallback_desc: &str) -> serde_json::V
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn parse_args<T: serde::de::DeserializeOwned>(
|
||||||
|
args: serde_json::Value,
|
||||||
|
tool_name: &str,
|
||||||
|
) -> crate::error::Result<T> {
|
||||||
|
serde_json::from_value::<T>(args).map_err(|e| {
|
||||||
|
crate::error::AppError::BadRequest(format!(
|
||||||
|
"Invalid arguments provided for tool '{}': {}. Next step: Verify parameter names, required fields, and types against the tool input schema.",
|
||||||
|
tool_name, e
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+511
-36
@@ -2,27 +2,190 @@ use schemars::JsonSchema;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
#[derive(Default)]
|
||||||
|
pub enum ChangeKind {
|
||||||
|
Added,
|
||||||
|
#[default]
|
||||||
|
Modified,
|
||||||
|
Deleted,
|
||||||
|
Renamed,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct CodeChange {
|
pub struct CodeChange {
|
||||||
pub timestamp: u64,
|
pub timestamp: u64,
|
||||||
pub file_path: String,
|
pub file_path: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub git_commit: Option<String>,
|
|
||||||
pub git_branch: Option<String>,
|
|
||||||
}
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct StickyNote {
|
|
||||||
pub timestamp: u64,
|
|
||||||
pub content: String,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub expires_at: Option<u64>,
|
pub git_commit: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub git_branch: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub repo_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub repo_url: Option<String>,
|
||||||
|
#[serde(default = "default_namespace")]
|
||||||
|
pub namespace: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub change_kind: ChangeKind,
|
||||||
|
#[serde(default)]
|
||||||
|
pub symbols: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub line_range: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub author: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub vcs_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub revision: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub branch: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub repository_root: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl CodeChange {
|
||||||
|
pub fn effective_vcs(&self) -> &str {
|
||||||
|
if let Some(vcs) = &self.vcs_type {
|
||||||
|
return vcs.as_str();
|
||||||
|
}
|
||||||
|
if let Some(rev) = self.revision.as_deref().or(self.git_commit.as_deref())
|
||||||
|
&& 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())
|
||||||
|
&& (br.eq_ignore_ascii_case("trunk")
|
||||||
|
|| br.starts_with("branches/")
|
||||||
|
|| br.starts_with("tags/"))
|
||||||
|
{
|
||||||
|
return "svn";
|
||||||
|
}
|
||||||
|
"git"
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn effective_revision(&self) -> Option<&str> {
|
||||||
|
self.revision.as_deref().or(self.git_commit.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn effective_branch(&self) -> Option<&str> {
|
||||||
|
self.branch.as_deref().or(self.git_branch.as_deref())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_uuid() -> String {
|
||||||
|
uuid::Uuid::new_v4().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn default_namespace() -> String {
|
pub fn default_namespace() -> String {
|
||||||
"global".to_string()
|
"global".to_string()
|
||||||
}
|
}
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
|
||||||
|
pub fn normalize_entity_type(input: &str) -> String {
|
||||||
|
let trimmed = input.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return "Concept".to_string();
|
||||||
|
}
|
||||||
|
let clean = trimmed.replace(['_', '-'], " ");
|
||||||
|
let words: Vec<&str> = clean.split_whitespace().collect();
|
||||||
|
if words.is_empty() {
|
||||||
|
return "Concept".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let pascal: String = words
|
||||||
|
.into_iter()
|
||||||
|
.map(|w| {
|
||||||
|
let mut chars = w.chars();
|
||||||
|
match chars.next() {
|
||||||
|
None => String::new(),
|
||||||
|
Some(f) => f.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
match pascal.as_str() {
|
||||||
|
"File" => "File".to_string(),
|
||||||
|
"McpTool" | "Mcptool" | "Mcp" => "McpTool".to_string(),
|
||||||
|
"Databasetable" | "DatabaseTable" => "DatabaseTable".to_string(),
|
||||||
|
"Datastructure" | "DataStore" | "Datastore" => "DataStructure".to_string(),
|
||||||
|
"Architecturecomponent" | "ArchitectureComponent" | "Architecture" => {
|
||||||
|
"ArchitectureComponent".to_string()
|
||||||
|
}
|
||||||
|
"Frontendcomponent" | "FrontendComponent" => "FrontendComponent".to_string(),
|
||||||
|
"Globalrule" | "GlobalRule" => "GlobalRule".to_string(),
|
||||||
|
"Securitypolicy" | "SecurityPolicy" => "SecurityPolicy".to_string(),
|
||||||
|
"Serviceaccount" | "ServiceAccount" => "ServiceAccount".to_string(),
|
||||||
|
"Testframework" | "TestFramework" => "TestFramework".to_string(),
|
||||||
|
"Toolhandler" | "ToolHandler" => "ToolHandler".to_string(),
|
||||||
|
"Historicalsummary" | "HistoricalSummary" => "HistoricalSummary".to_string(),
|
||||||
|
"Externalapi" | "ExternalApi" => "ExternalAPI".to_string(),
|
||||||
|
"Testsuite" | "TestSuite" => "TestSuite".to_string(),
|
||||||
|
"Confluencepage" | "ConfluencePage" => "ConfluencePage".to_string(),
|
||||||
|
"Containerimage" | "ContainerImage" => "ContainerImage".to_string(),
|
||||||
|
"Buildsystem" | "BuildSystem" => "BuildSystem".to_string(),
|
||||||
|
_ => pascal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_relation_type(input: &str) -> String {
|
||||||
|
let trimmed = input.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return "related_to".to_string();
|
||||||
|
}
|
||||||
|
trimmed.to_lowercase().replace([' ', '-'], "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TaskStatus {
|
||||||
|
Pending,
|
||||||
|
#[default]
|
||||||
|
Active,
|
||||||
|
InProgress,
|
||||||
|
Blocked,
|
||||||
|
Completed,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskStatus {
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
TaskStatus::Pending | TaskStatus::Active | TaskStatus::InProgress | TaskStatus::Blocked
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_completed(&self) -> bool {
|
||||||
|
matches!(self, TaskStatus::Completed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
TaskStatus::Pending => "pending",
|
||||||
|
TaskStatus::Active => "active",
|
||||||
|
TaskStatus::InProgress => "in_progress",
|
||||||
|
TaskStatus::Blocked => "blocked",
|
||||||
|
TaskStatus::Completed => "completed",
|
||||||
|
TaskStatus::Cancelled => "cancelled",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for TaskStatus {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
|
||||||
pub struct Entity {
|
pub struct Entity {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
#[serde(alias = "entityType", alias = "type", alias = "category")]
|
||||||
pub entity_type: String,
|
pub entity_type: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub observations: Vec<String>,
|
pub observations: Vec<String>,
|
||||||
@@ -30,15 +193,58 @@ pub struct Entity {
|
|||||||
pub namespace: String,
|
pub namespace: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub git_branch: Option<String>,
|
pub git_branch: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub repo_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub file_path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub embedding: Option<Vec<f32>>,
|
||||||
}
|
}
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, JsonSchema)]
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
|
||||||
pub struct Relation {
|
pub struct Relation {
|
||||||
|
#[serde(alias = "source", alias = "from_node", alias = "source_node")]
|
||||||
pub from: String,
|
pub from: String,
|
||||||
|
#[serde(alias = "target", alias = "to_node", alias = "target_node")]
|
||||||
pub to: String,
|
pub to: String,
|
||||||
|
#[serde(alias = "relationType", alias = "type", alias = "relation")]
|
||||||
pub relation_type: String,
|
pub relation_type: String,
|
||||||
#[serde(default = "default_namespace")]
|
#[serde(default = "default_namespace")]
|
||||||
pub namespace: String,
|
pub namespace: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub repo_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub weight: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_bidirectional: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl PartialEq for Relation {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.from == other.from
|
||||||
|
&& self.to == other.to
|
||||||
|
&& self.relation_type == other.relation_type
|
||||||
|
&& self.namespace == other.namespace
|
||||||
|
&& self.repo_name == other.repo_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Eq for Relation {}
|
||||||
|
|
||||||
|
impl std::hash::Hash for Relation {
|
||||||
|
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||||
|
self.from.hash(state);
|
||||||
|
self.to.hash(state);
|
||||||
|
self.relation_type.hash(state);
|
||||||
|
self.namespace.hash(state);
|
||||||
|
self.repo_name.hash(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct KnowledgeGraph {
|
pub struct KnowledgeGraph {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -46,11 +252,49 @@ pub struct KnowledgeGraph {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub relations: Vec<Relation>,
|
pub relations: Vec<Relation>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl KnowledgeGraph {
|
||||||
|
/// Builds an adjacency index mapping node names to lists of outgoing/incoming neighbors and relation types.
|
||||||
|
pub fn build_adjacency_map<'a>(
|
||||||
|
&'a self,
|
||||||
|
relation_filter: Option<&'a str>,
|
||||||
|
) -> HashMap<&'a str, Vec<(&'a str, &'a str, bool)>> {
|
||||||
|
let mut adj: HashMap<&'a str, Vec<(&'a str, &'a str, bool)>> =
|
||||||
|
HashMap::with_capacity(self.relations.len() * 2);
|
||||||
|
for rel in &self.relations {
|
||||||
|
if let Some(rf) = relation_filter
|
||||||
|
&& rel.relation_type != rf
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
adj.entry(rel.from.as_str()).or_default().push((
|
||||||
|
rel.to.as_str(),
|
||||||
|
rel.relation_type.as_str(),
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
adj.entry(rel.to.as_str()).or_default().push((
|
||||||
|
rel.from.as_str(),
|
||||||
|
rel.relation_type.as_str(),
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
adj
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct AcceptanceCriteria {
|
pub struct AcceptanceCriteria {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub is_met: bool,
|
pub is_met: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub verification_command: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub verification_output: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub verified_at: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub verified_by: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
@@ -70,7 +314,28 @@ pub struct Task {
|
|||||||
pub dependencies: Vec<String>,
|
pub dependencies: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub acceptance_criteria: Vec<AcceptanceCriteria>,
|
pub acceptance_criteria: Vec<AcceptanceCriteria>,
|
||||||
|
#[serde(default = "default_namespace")]
|
||||||
|
pub namespace: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub repo_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub priority: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub assigned_agent: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub verification_command: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Task {
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
let s = self.status.trim();
|
||||||
|
!s.eq_ignore_ascii_case("completed")
|
||||||
|
&& !s.eq_ignore_ascii_case("done")
|
||||||
|
&& !s.eq_ignore_ascii_case("cancelled")
|
||||||
|
&& !s.eq_ignore_ascii_case("canceled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct Snippet {
|
pub struct Snippet {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -79,31 +344,56 @@ pub struct Snippet {
|
|||||||
pub description: String,
|
pub description: String,
|
||||||
pub updated_at: u64,
|
pub updated_at: u64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub embedding: Option<Vec<f32>>,
|
pub embedding: Option<Vec<f32>>,
|
||||||
|
#[serde(default = "default_namespace")]
|
||||||
|
pub namespace: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub repo_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub origin_file: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub line_range: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct Adr {
|
pub struct Adr {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub context: String,
|
pub context: String,
|
||||||
pub decision: String,
|
pub decision: String,
|
||||||
|
#[serde(alias = "consequences")]
|
||||||
pub consequence: String,
|
pub consequence: String,
|
||||||
#[serde(default = "default_adr_status")]
|
#[serde(default = "default_adr_status")]
|
||||||
pub status: String,
|
pub status: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub supersedes: Option<String>,
|
pub supersedes: Option<String>,
|
||||||
pub timestamp: u64,
|
pub timestamp: u64,
|
||||||
|
#[serde(default = "default_namespace")]
|
||||||
|
pub namespace: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub repo_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub alternatives_considered: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub affected_components: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub author: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub git_commit: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub git_branch: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub resolved_at: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub task_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_adr_status() -> String {
|
fn default_adr_status() -> String {
|
||||||
"accepted".to_string()
|
"accepted".to_string()
|
||||||
}
|
}
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct Preference {
|
|
||||||
pub key: String,
|
|
||||||
pub value: String,
|
|
||||||
pub updated_at: u64,
|
|
||||||
}
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct ErrorFix {
|
pub struct ErrorFix {
|
||||||
pub signature: String,
|
pub signature: String,
|
||||||
@@ -113,14 +403,18 @@ pub struct ErrorFix {
|
|||||||
pub git_branch: Option<String>,
|
pub git_branch: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub embedding: Option<Vec<f32>>,
|
pub embedding: Option<Vec<f32>>,
|
||||||
}
|
#[serde(default = "default_namespace")]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct PinnedFile {
|
|
||||||
pub namespace: String,
|
pub namespace: String,
|
||||||
pub file_path: String,
|
#[serde(default)]
|
||||||
pub timestamp: u64,
|
pub repo_name: Option<String>,
|
||||||
pub git_branch: Option<String>,
|
#[serde(default)]
|
||||||
|
pub error_category: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub stack_trace: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub toolchain: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct SessionSummary {
|
pub struct SessionSummary {
|
||||||
pub timestamp: u64,
|
pub timestamp: u64,
|
||||||
@@ -128,7 +422,20 @@ pub struct SessionSummary {
|
|||||||
pub namespace: String,
|
pub namespace: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub expires_at: Option<u64>,
|
pub expires_at: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub repo_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub git_branch: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub git_commit: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tasks_completed: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub next_steps: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct HandoffMemo {
|
pub struct HandoffMemo {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -138,7 +445,20 @@ pub struct HandoffMemo {
|
|||||||
pub expires_at: Option<u64>,
|
pub expires_at: Option<u64>,
|
||||||
pub namespace: String,
|
pub namespace: String,
|
||||||
pub timestamp: u64,
|
pub timestamp: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub repo_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub git_branch: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub vcs_revision: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub blockers: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub action_items: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct EnvFingerprint {
|
pub struct EnvFingerprint {
|
||||||
pub namespace: String,
|
pub namespace: String,
|
||||||
@@ -146,14 +466,24 @@ pub struct EnvFingerprint {
|
|||||||
pub shell: String,
|
pub shell: String,
|
||||||
pub tool_versions: HashMap<String, String>,
|
pub tool_versions: HashMap<String, String>,
|
||||||
pub updated_at: u64,
|
pub updated_at: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub repo_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct EnvRequirement {
|
pub struct EnvRequirement {
|
||||||
pub namespace: String,
|
pub namespace: String,
|
||||||
pub key: String,
|
pub key: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub is_secret: bool,
|
pub is_secret: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub repo_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_value: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub validation_regex: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct Milestone {
|
pub struct Milestone {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -161,7 +491,18 @@ pub struct Milestone {
|
|||||||
pub status: String,
|
pub status: String,
|
||||||
pub namespace: String,
|
pub namespace: String,
|
||||||
pub target_date: Option<u64>,
|
pub target_date: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub repo_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub task_ids: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub deliverables: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub completed_at: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct EnvironmentDetail {
|
pub struct EnvironmentDetail {
|
||||||
pub namespace: String,
|
pub namespace: String,
|
||||||
@@ -170,13 +511,16 @@ pub struct EnvironmentDetail {
|
|||||||
pub description: String,
|
pub description: String,
|
||||||
pub requires_vpn: bool,
|
pub requires_vpn: bool,
|
||||||
pub updated_at: u64,
|
pub updated_at: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub repo_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub env_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub healthcheck_endpoint: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ssh_host: Option<String>,
|
||||||
}
|
}
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct PrChecklistItem {
|
|
||||||
pub namespace: String,
|
|
||||||
pub id: String,
|
|
||||||
pub description: String,
|
|
||||||
}
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct TechDebt {
|
pub struct TechDebt {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -189,14 +533,18 @@ pub struct TechDebt {
|
|||||||
pub git_branch: Option<String>,
|
pub git_branch: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub embedding: Option<Vec<f32>>,
|
pub embedding: Option<Vec<f32>>,
|
||||||
}
|
#[serde(default)]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
pub repo_name: Option<String>,
|
||||||
pub struct ContextWorkspace {
|
#[serde(default)]
|
||||||
pub name: String,
|
pub severity: Option<String>,
|
||||||
pub namespace: String,
|
#[serde(default)]
|
||||||
pub pinned_files: Vec<String>,
|
pub file_path: Option<String>,
|
||||||
pub active_task_ids: Vec<String>,
|
#[serde(default)]
|
||||||
pub saved_at: u64,
|
pub line_range: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub workaround: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub effort_estimate: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
@@ -209,13 +557,140 @@ pub struct GateRecord {
|
|||||||
pub status: String,
|
pub status: String,
|
||||||
pub reason: Option<String>,
|
pub reason: Option<String>,
|
||||||
pub timestamp: u64,
|
pub timestamp: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub repo_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gate_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub enforcer: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub validation_log: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub expires_at: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct TerminalHistory {
|
pub struct TerminalHistory {
|
||||||
|
#[serde(default = "generate_uuid")]
|
||||||
|
pub id: String,
|
||||||
pub command: String,
|
pub command: String,
|
||||||
pub exit_code: i32,
|
pub exit_code: i32,
|
||||||
pub cwd: String,
|
pub cwd: String,
|
||||||
|
#[serde(default)]
|
||||||
pub os: String,
|
pub os: String,
|
||||||
pub timestamp: u64,
|
pub timestamp: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub error_output: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub shell: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub duration_ms: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub stdout_summary: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub status_reason: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub invoker: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub repo_name: Option<String>,
|
||||||
|
#[serde(default = "default_namespace")]
|
||||||
|
pub namespace: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ActivityRecord {
|
||||||
|
pub timestamp: u64,
|
||||||
|
pub category: String, // e.g. "code_change", "task_update", "telemetry", "error_fix"
|
||||||
|
pub summary: String, // Brief human-readable description of what is being worked on
|
||||||
|
pub details: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub actor: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub repo_name: Option<String>,
|
||||||
|
#[serde(default = "default_namespace")]
|
||||||
|
pub namespace: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub associated_file: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct StateSnapshot {
|
||||||
|
pub id: String,
|
||||||
|
pub timestamp: u64,
|
||||||
|
pub description: String,
|
||||||
|
pub namespace: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub repo_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub git_branch: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub vcs_revision: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub active_tasks_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct Hypothesis {
|
||||||
|
pub id: String,
|
||||||
|
pub task_id: Option<String>,
|
||||||
|
pub hypothesis: String,
|
||||||
|
pub status: String,
|
||||||
|
pub evidence: Option<String>,
|
||||||
|
pub timestamp: u64,
|
||||||
|
#[serde(default = "default_namespace")]
|
||||||
|
pub namespace: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub repo_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub validation_steps: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub diagnostician: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct AgentSignal {
|
||||||
|
pub id: String,
|
||||||
|
pub sender: String,
|
||||||
|
pub signal_type: String,
|
||||||
|
pub payload: String,
|
||||||
|
pub timestamp: u64,
|
||||||
|
pub ttl_seconds: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub recipient: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub correlation_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub priority: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct TaskEvent {
|
||||||
|
pub task_id: String,
|
||||||
|
pub status: String,
|
||||||
|
pub action: Option<String>,
|
||||||
|
pub result: Option<serde_json::Value>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub timestamp: u64,
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub namespace: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub repo_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub actor: Option<String>,
|
||||||
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::error::AppError;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_app_error_bad_request_invalid_json() {
|
||||||
|
let err = AppError::BadRequest("invalid json".into());
|
||||||
|
let res = err.into_response();
|
||||||
|
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
use crate::error::AppError;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::env;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct OllamaClient {
|
||||||
|
pub base_url: String,
|
||||||
|
pub coder_model: String,
|
||||||
|
pub reasoning_model: String,
|
||||||
|
pub vision_model: String,
|
||||||
|
pub embed_model: String,
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct GenerateRequest<'a> {
|
||||||
|
model: &'a str,
|
||||||
|
prompt: &'a str,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
system: Option<&'a str>,
|
||||||
|
stream: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
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)]
|
||||||
|
struct GenerateResponse {
|
||||||
|
response: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct EmbeddingRequest<'a> {
|
||||||
|
model: &'a str,
|
||||||
|
prompt: &'a str,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
keep_alive: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct EmbeddingResponse {
|
||||||
|
embedding: Vec<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OllamaClient {
|
||||||
|
pub fn new_from_env() -> Self {
|
||||||
|
let base_url =
|
||||||
|
env::var("OLLAMA_URL").unwrap_or_else(|_| "http://192.168.1.30:11434".to_string());
|
||||||
|
let coder_model =
|
||||||
|
env::var("OLLAMA_CODER_MODEL").unwrap_or_else(|_| "qwen2.5-coder:1.5b".to_string());
|
||||||
|
let reasoning_model =
|
||||||
|
env::var("OLLAMA_REASONING_MODEL").unwrap_or_else(|_| "deepseek-r1:1.5b".to_string());
|
||||||
|
let vision_model =
|
||||||
|
env::var("OLLAMA_VISION_MODEL").unwrap_or_else(|_| "moondream".to_string());
|
||||||
|
let embed_model = env::var("OLLAMA_EMBED_MODEL")
|
||||||
|
.unwrap_or_else(|_| "nomic-embed-text:latest".to_string());
|
||||||
|
|
||||||
|
let timeout_sec = env::var("OLLAMA_TIMEOUT_SEC")
|
||||||
|
.unwrap_or_else(|_| "60".to_string())
|
||||||
|
.parse::<u64>()
|
||||||
|
.unwrap_or(60);
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(timeout_sec))
|
||||||
|
.build()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
base_url,
|
||||||
|
coder_model,
|
||||||
|
reasoning_model,
|
||||||
|
vision_model,
|
||||||
|
embed_model,
|
||||||
|
client,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Health probe check with a strict 1.5-second connection timeout.
|
||||||
|
pub async fn is_available(&self) -> bool {
|
||||||
|
let probe_url = format!("{}/api/tags", self.base_url.trim_end_matches('/'));
|
||||||
|
let probe_client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_millis(1500))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let client = match probe_client {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
match client.get(&probe_url).send().await {
|
||||||
|
Ok(res) if res.status().is_success() => {
|
||||||
|
debug!("Ollama host at {} is online and responsive.", self.base_url);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Ok(res) => {
|
||||||
|
debug!("Ollama host returned status {}", res.status());
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
debug!("Ollama host probe failed (offline/timeout): {}", e);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn generate(
|
||||||
|
&self,
|
||||||
|
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('/'));
|
||||||
|
|
||||||
|
let body = GenerateRequest {
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
system,
|
||||||
|
stream: false,
|
||||||
|
images: None,
|
||||||
|
options: Some(serde_json::json!({
|
||||||
|
"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
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Ollama connection error: {}", e)))?;
|
||||||
|
|
||||||
|
if !res.status().is_success() {
|
||||||
|
return Err(AppError::Internal(format!(
|
||||||
|
"Ollama API returned HTTP {}",
|
||||||
|
res.status()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp_json: GenerateResponse = res.json().await.map_err(|e| {
|
||||||
|
AppError::Internal(format!("Failed to parse Ollama JSON response: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(resp_json.response)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn generate_vision(
|
||||||
|
&self,
|
||||||
|
prompt: &str,
|
||||||
|
image_base64: &str,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let url = format!("{}/api/generate", self.base_url.trim_end_matches('/'));
|
||||||
|
|
||||||
|
let body = GenerateRequest {
|
||||||
|
model: &self.vision_model,
|
||||||
|
prompt,
|
||||||
|
system: Some(
|
||||||
|
"You are a vision AI assistant. Describe or convert the image provided to code/text as requested.",
|
||||||
|
),
|
||||||
|
stream: false,
|
||||||
|
images: Some(vec![image_base64]),
|
||||||
|
options: Some(serde_json::json!({
|
||||||
|
"num_ctx": 8192,
|
||||||
|
"num_predict": 1024
|
||||||
|
})),
|
||||||
|
keep_alive: Some("1h"),
|
||||||
|
format: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Ollama Vision error: {}", e)))?;
|
||||||
|
|
||||||
|
if !res.status().is_success() {
|
||||||
|
return Err(AppError::Internal(format!(
|
||||||
|
"Ollama Vision API returned HTTP {}",
|
||||||
|
res.status()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp_json: GenerateResponse = res.json().await.map_err(|e| {
|
||||||
|
AppError::Internal(format!("Failed to parse Ollama Vision response: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(resp_json.response)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn embeddings(&self, text: &str) -> Result<Vec<f32>, AppError> {
|
||||||
|
let url = format!("{}/api/embeddings", self.base_url.trim_end_matches('/'));
|
||||||
|
|
||||||
|
let body = EmbeddingRequest {
|
||||||
|
model: &self.embed_model,
|
||||||
|
prompt: text,
|
||||||
|
keep_alive: Some("1h"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Ollama Embeddings error: {}", e)))?;
|
||||||
|
|
||||||
|
if !res.status().is_success() {
|
||||||
|
return Err(AppError::Internal(format!(
|
||||||
|
"Ollama Embeddings API returned HTTP {}",
|
||||||
|
res.status()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp_json: EmbeddingResponse = res.json().await.map_err(|e| {
|
||||||
|
AppError::Internal(format!("Failed to parse Ollama Embeddings response: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(resp_json.embedding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ollama_client_new_from_env() {
|
||||||
|
let client = OllamaClient::new_from_env();
|
||||||
|
assert!(!client.base_url.is_empty());
|
||||||
|
assert!(!client.coder_model.is_empty());
|
||||||
|
assert!(!client.reasoning_model.is_empty());
|
||||||
|
assert!(!client.vision_model.is_empty());
|
||||||
|
assert!(!client.embed_model.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_ollama_client_invalid_host_is_available() {
|
||||||
|
let client = OllamaClient {
|
||||||
|
base_url: "http://127.0.0.1:59999".to_string(),
|
||||||
|
coder_model: "qwen2.5-coder:3b".to_string(),
|
||||||
|
reasoning_model: "deepseek-r1:1.5b".to_string(),
|
||||||
|
vision_model: "qwen3-vl:2b".to_string(),
|
||||||
|
embed_model: "nomic-embed-text:latest".to_string(),
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!client.is_available().await);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_ollama_client_invalid_api_key() {
|
||||||
|
let mut client = OllamaClient::new_from_env();
|
||||||
|
client.base_url = "http://invalid-api-key:11434".to_string();
|
||||||
|
assert!(!client.is_available().await);
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
pub fn patch() {}
|
|
||||||
+827
-132
File diff suppressed because it is too large.
Load diff
+442
-87
@@ -1,10 +1,41 @@
|
|||||||
|
use crate::embedding::{cosine_similarity, generate_embedding_async, generate_embeddings_async};
|
||||||
use crate::models::{Adr, Entity, Snippet, Task};
|
use crate::models::{Adr, Entity, Snippet, Task};
|
||||||
|
use crate::state::MemoryState;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tantivy::schema::*;
|
use tantivy::schema::*;
|
||||||
use tantivy::{Index, IndexReader, IndexWriter, ReloadPolicy, doc};
|
use tantivy::{Index, IndexReader, IndexWriter, ReloadPolicy, doc};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct SearchResult {
|
||||||
|
pub id: String,
|
||||||
|
pub doc_type: String,
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
pub score: f32,
|
||||||
|
}
|
||||||
|
|
||||||
pub type SearchResultTuple = (String, String, String, String, f32);
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct MemoryIndex {
|
pub struct MemoryIndex {
|
||||||
pub index: Index,
|
pub index: Index,
|
||||||
@@ -18,6 +49,8 @@ pub struct MemoryIndex {
|
|||||||
pub body_field: Field,
|
pub body_field: Field,
|
||||||
pub type_field: Field,
|
pub type_field: Field,
|
||||||
pub namespace_field: Field,
|
pub namespace_field: Field,
|
||||||
|
|
||||||
|
pub query_parser: tantivy::query::QueryParser,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryIndex {
|
impl MemoryIndex {
|
||||||
@@ -36,14 +69,15 @@ impl MemoryIndex {
|
|||||||
let index = Index::open_in_dir(&index_dir)
|
let index = Index::open_in_dir(&index_dir)
|
||||||
.or_else(|_| Index::create_in_dir(&index_dir, schema.clone()))?;
|
.or_else(|_| Index::create_in_dir(&index_dir, schema.clone()))?;
|
||||||
|
|
||||||
let mut writer = index.writer(50_000_000)?;
|
let writer = index.writer(50_000_000)?;
|
||||||
writer.delete_all_documents()?;
|
|
||||||
writer.commit()?;
|
|
||||||
let reader = index
|
let reader = index
|
||||||
.reader_builder()
|
.reader_builder()
|
||||||
.reload_policy(ReloadPolicy::OnCommitWithDelay)
|
.reload_policy(ReloadPolicy::OnCommitWithDelay)
|
||||||
.try_into()?;
|
.try_into()?;
|
||||||
|
|
||||||
|
let query_parser =
|
||||||
|
tantivy::query::QueryParser::for_index(&index, vec![title_field, body_field]);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
index,
|
index,
|
||||||
reader,
|
reader,
|
||||||
@@ -54,27 +88,86 @@ impl MemoryIndex {
|
|||||||
body_field,
|
body_field,
|
||||||
type_field,
|
type_field,
|
||||||
namespace_field,
|
namespace_field,
|
||||||
|
query_parser,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn new_in_ram() -> tantivy::Result<Self> {
|
||||||
|
let mut schema_builder = Schema::builder();
|
||||||
|
let id_field = schema_builder.add_text_field("id", STRING | STORED);
|
||||||
|
let title_field = schema_builder.add_text_field("title", TEXT | STORED);
|
||||||
|
let body_field = schema_builder.add_text_field("body", TEXT | STORED);
|
||||||
|
let type_field = schema_builder.add_text_field("type", STRING | STORED);
|
||||||
|
let namespace_field = schema_builder.add_text_field("namespace", STRING | STORED);
|
||||||
|
let schema = schema_builder.build();
|
||||||
|
|
||||||
|
let index = Index::create_in_ram(schema);
|
||||||
|
let writer = index.writer(50_000_000)?;
|
||||||
|
let reader = index
|
||||||
|
.reader_builder()
|
||||||
|
.reload_policy(ReloadPolicy::OnCommitWithDelay)
|
||||||
|
.try_into()?;
|
||||||
|
|
||||||
|
let query_parser =
|
||||||
|
tantivy::query::QueryParser::for_index(&index, vec![title_field, body_field]);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
index,
|
||||||
|
reader,
|
||||||
|
writer: Arc::new(Mutex::new(writer)),
|
||||||
|
needs_commit: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
|
id_field,
|
||||||
|
title_field,
|
||||||
|
body_field,
|
||||||
|
type_field,
|
||||||
|
namespace_field,
|
||||||
|
query_parser,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&self) -> tantivy::Result<()> {
|
||||||
|
let mut writer = self
|
||||||
|
.writer
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| tantivy::TantivyError::SystemError(e.to_string()))?;
|
||||||
|
writer.delete_all_documents()?;
|
||||||
|
writer.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn index_entity(&self, e: &Entity) -> tokio::task::JoinHandle<tantivy::Result<()>> {
|
pub fn index_entity(&self, e: &Entity) -> tokio::task::JoinHandle<tantivy::Result<()>> {
|
||||||
|
self.index_entities_batch(std::slice::from_ref(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn index_entities_batch(
|
||||||
|
&self,
|
||||||
|
entities: &[Entity],
|
||||||
|
) -> tokio::task::JoinHandle<tantivy::Result<()>> {
|
||||||
let writer = Arc::clone(&self.writer);
|
let writer = Arc::clone(&self.writer);
|
||||||
let id_field = self.id_field;
|
let id_field = self.id_field;
|
||||||
let id_val = e.name.clone();
|
|
||||||
let needs_commit = Arc::clone(&self.needs_commit);
|
let needs_commit = Arc::clone(&self.needs_commit);
|
||||||
|
|
||||||
let doc = doc!(
|
let docs: Vec<(String, tantivy::TantivyDocument)> = entities
|
||||||
self.id_field => e.name.as_str(),
|
.iter()
|
||||||
self.title_field => e.name.as_str(),
|
.map(|e| {
|
||||||
self.body_field => e.observations.join(" "),
|
let id_val = e.name.clone();
|
||||||
self.type_field => "entity",
|
let doc = doc!(
|
||||||
self.namespace_field => e.namespace.as_str()
|
self.id_field => e.name.as_str(),
|
||||||
);
|
self.title_field => e.name.as_str(),
|
||||||
|
self.body_field => e.observations.join(" "),
|
||||||
|
self.type_field => "entity",
|
||||||
|
self.namespace_field => e.namespace.as_str()
|
||||||
|
);
|
||||||
|
(id_val, doc)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let writer = writer.lock().unwrap_or_else(|e| e.into_inner());
|
let writer_guard = writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
writer.delete_term(tantivy::Term::from_field_text(id_field, &id_val));
|
for (id_val, doc) in docs {
|
||||||
writer.add_document(doc)?;
|
writer_guard.delete_term(tantivy::Term::from_field_text(id_field, &id_val));
|
||||||
|
writer_guard.add_document(doc)?;
|
||||||
|
}
|
||||||
needs_commit.store(true, std::sync::atomic::Ordering::SeqCst);
|
needs_commit.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
@@ -86,10 +179,21 @@ impl MemoryIndex {
|
|||||||
let id_val = t.id.clone();
|
let id_val = t.id.clone();
|
||||||
let needs_commit = Arc::clone(&self.needs_commit);
|
let needs_commit = Arc::clone(&self.needs_commit);
|
||||||
|
|
||||||
|
let criteria_body = t
|
||||||
|
.acceptance_criteria
|
||||||
|
.iter()
|
||||||
|
.fold(String::new(), |mut acc, c| {
|
||||||
|
if !acc.is_empty() {
|
||||||
|
acc.push('\n');
|
||||||
|
}
|
||||||
|
acc.push_str(&c.description);
|
||||||
|
acc
|
||||||
|
});
|
||||||
|
|
||||||
let doc = doc!(
|
let doc = doc!(
|
||||||
self.id_field => t.id.as_str(),
|
self.id_field => t.id.as_str(),
|
||||||
self.title_field => t.title.as_str(),
|
self.title_field => t.title.as_str(),
|
||||||
self.body_field => format!("{}\n{}", t.description, t.acceptance_criteria.iter().map(|c| c.description.as_str()).collect::<Vec<_>>().join("\n")),
|
self.body_field => format!("{}\n{}", t.description, criteria_body),
|
||||||
self.type_field => "task",
|
self.type_field => "task",
|
||||||
self.namespace_field => "global"
|
self.namespace_field => "global"
|
||||||
);
|
);
|
||||||
@@ -103,6 +207,34 @@ impl MemoryIndex {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn index_clipboard_image(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
file_path: &str,
|
||||||
|
ocr_text: &str,
|
||||||
|
) -> tokio::task::JoinHandle<tantivy::Result<()>> {
|
||||||
|
let writer = Arc::clone(&self.writer);
|
||||||
|
let id_field = self.id_field;
|
||||||
|
let id_val = id.to_string();
|
||||||
|
let needs_commit = Arc::clone(&self.needs_commit);
|
||||||
|
|
||||||
|
let doc = doc!(
|
||||||
|
self.id_field => id,
|
||||||
|
self.title_field => format!("Clipboard Screenshot: {}", file_path),
|
||||||
|
self.body_field => ocr_text,
|
||||||
|
self.type_field => "clipboard_image",
|
||||||
|
self.namespace_field => "global"
|
||||||
|
);
|
||||||
|
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let writer = writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
writer.delete_term(tantivy::Term::from_field_text(id_field, &id_val));
|
||||||
|
writer.add_document(doc)?;
|
||||||
|
needs_commit.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn delete_document(&self, id: &str) -> tokio::task::JoinHandle<tantivy::Result<()>> {
|
pub fn delete_document(&self, id: &str) -> tokio::task::JoinHandle<tantivy::Result<()>> {
|
||||||
let writer = Arc::clone(&self.writer);
|
let writer = Arc::clone(&self.writer);
|
||||||
let id_field = self.id_field;
|
let id_field = self.id_field;
|
||||||
@@ -120,10 +252,20 @@ impl MemoryIndex {
|
|||||||
pub async fn commit(&self) -> tantivy::Result<()> {
|
pub async fn commit(&self) -> tantivy::Result<()> {
|
||||||
let writer = Arc::clone(&self.writer);
|
let writer = Arc::clone(&self.writer);
|
||||||
let needs_commit = Arc::clone(&self.needs_commit);
|
let needs_commit = Arc::clone(&self.needs_commit);
|
||||||
|
let reader = self.reader.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
if needs_commit.swap(false, std::sync::atomic::Ordering::SeqCst) {
|
if needs_commit.swap(false, std::sync::atomic::Ordering::SeqCst) {
|
||||||
let mut writer = writer.lock().unwrap_or_else(|e| e.into_inner());
|
let mut writer = writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
writer.commit()?;
|
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
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
let _ = reader.reload();
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
@@ -135,23 +277,78 @@ impl MemoryIndex {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn commit_if_needed(&self) -> tantivy::Result<()> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn search(
|
pub fn search(
|
||||||
&self,
|
&self,
|
||||||
query: &str,
|
query: &str,
|
||||||
namespace: Option<&str>,
|
namespace: Option<&str>,
|
||||||
) -> tantivy::Result<Vec<SearchResultTuple>> {
|
) -> tantivy::Result<Vec<SearchResultTuple>> {
|
||||||
let searcher = self.reader.searcher();
|
let searcher = self.reader.searcher();
|
||||||
let query_parser = tantivy::query::QueryParser::for_index(
|
let mut is_wildcard_fallback = false;
|
||||||
&self.index,
|
|
||||||
vec![self.title_field, self.body_field],
|
let q = self.query_parser.parse_query(query).or_else(|_| {
|
||||||
);
|
let sanitized: String = query
|
||||||
let q = query_parser.parse_query(query)?;
|
.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'+' | '-' | '&' | '|' | '!' | '(' | ')' | '{' | '}' | '[' | ']' | '^' | '"'
|
||||||
|
| '~' | '*' | '?' | ':' | '\\' | '/' => ' ',
|
||||||
|
_ => c,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let safe_query = sanitized.trim();
|
||||||
|
if safe_query.is_empty() {
|
||||||
|
is_wildcard_fallback = true;
|
||||||
|
self.query_parser.parse_query("*")
|
||||||
|
} else {
|
||||||
|
self.query_parser.parse_query(safe_query)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
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_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"),
|
||||||
|
tantivy::schema::IndexRecordOption::Basic,
|
||||||
|
));
|
||||||
|
Box::new(tantivy::query::BooleanQuery::new(vec![
|
||||||
|
(tantivy::query::Occur::Should, ns_term_query),
|
||||||
|
(tantivy::query::Occur::Should, global_term_query),
|
||||||
|
]))
|
||||||
|
} else {
|
||||||
|
ns_term_query
|
||||||
|
};
|
||||||
|
|
||||||
|
Box::new(tantivy::query::BooleanQuery::new(vec![
|
||||||
|
(tantivy::query::Occur::Must, q),
|
||||||
|
(tantivy::query::Occur::Must, ns_filter),
|
||||||
|
]))
|
||||||
|
} else {
|
||||||
|
q
|
||||||
|
};
|
||||||
|
|
||||||
let top_docs = searcher.search(
|
let top_docs = searcher.search(
|
||||||
&q,
|
&final_query,
|
||||||
&tantivy::collector::TopDocs::with_limit(50).order_by_score(),
|
&tantivy::collector::TopDocs::with_limit(target_limit).order_by_score(),
|
||||||
)?;
|
)?;
|
||||||
let mut results = Vec::new();
|
|
||||||
|
let mut results = Vec::with_capacity(target_limit);
|
||||||
for (score, doc_address) in top_docs {
|
for (score, doc_address) in top_docs {
|
||||||
let retrieved_doc = searcher.doc::<tantivy::TantivyDocument>(doc_address)?;
|
let retrieved_doc = searcher.doc::<tantivy::TantivyDocument>(doc_address)?;
|
||||||
let id = retrieved_doc
|
let id = retrieved_doc
|
||||||
@@ -174,16 +371,6 @@ impl MemoryIndex {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
let doc_ns = retrieved_doc
|
|
||||||
.get_first(self.namespace_field)
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
if let Some(ns) = namespace
|
|
||||||
&& doc_ns != ns
|
|
||||||
&& doc_ns != "global"
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
results.push((id, doc_type, title, body, score));
|
results.push((id, doc_type, title, body, score));
|
||||||
}
|
}
|
||||||
Ok(results)
|
Ok(results)
|
||||||
@@ -236,72 +423,235 @@ impl MemoryIndex {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_entity_sync(&self, e: &Entity) {
|
pub fn add_entity_sync(&self, e: &Entity) {
|
||||||
if let Ok(writer) = self.writer.lock() {
|
let writer = self.writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let _ = writer.add_document(doc!(
|
writer.delete_term(tantivy::Term::from_field_text(self.id_field, &e.name));
|
||||||
self.id_field => e.name.as_str(),
|
if let Err(err) = writer.add_document(doc!(
|
||||||
self.title_field => e.name.as_str(),
|
self.id_field => e.name.as_str(),
|
||||||
self.body_field => e.observations.join(" "),
|
self.title_field => e.name.as_str(),
|
||||||
self.type_field => "entity",
|
self.body_field => e.observations.join(" "),
|
||||||
self.namespace_field => e.namespace.as_str()
|
self.type_field => "entity",
|
||||||
));
|
self.namespace_field => e.namespace.as_str()
|
||||||
self.needs_commit
|
)) {
|
||||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
tracing::warn!("Sync entity indexing failed for '{}': {}", e.name, err);
|
||||||
}
|
}
|
||||||
|
self.needs_commit
|
||||||
|
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_all(&self) {
|
pub fn delete_all(&self) {
|
||||||
if let Ok(writer) = self.writer.lock() {
|
let writer = self.writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let _ = writer.delete_all_documents();
|
if let Err(err) = writer.delete_all_documents() {
|
||||||
self.needs_commit
|
tracing::warn!("delete_all_documents failed: {}", err);
|
||||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
||||||
}
|
}
|
||||||
|
self.needs_commit
|
||||||
|
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_task_sync(&self, t: &Task) {
|
pub fn add_task_sync(&self, t: &Task) {
|
||||||
println!("add_task_sync called for task: {}", t.id);
|
let writer = self.writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
if let Ok(writer) = self.writer.lock() {
|
writer.delete_term(tantivy::Term::from_field_text(self.id_field, &t.id));
|
||||||
let _res = writer.add_document(doc!(
|
if let Err(err) = writer.add_document(doc!(
|
||||||
self.id_field => t.id.as_str(),
|
self.id_field => t.id.as_str(),
|
||||||
self.title_field => t.title.as_str(),
|
self.title_field => t.title.as_str(),
|
||||||
self.body_field => t.description.as_str(),
|
self.body_field => t.description.as_str(),
|
||||||
self.type_field => "task",
|
self.type_field => "task",
|
||||||
self.namespace_field => "global"
|
self.namespace_field => "global"
|
||||||
));
|
)) {
|
||||||
println!("Writer add_document returned id/result");
|
tracing::warn!("Sync task indexing failed for '{}': {}", t.id, err);
|
||||||
self.needs_commit
|
|
||||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
||||||
println!("Needs_commit set to true in add_task_sync");
|
|
||||||
} else {
|
|
||||||
println!("Failed to acquire writer lock in add_task_sync");
|
|
||||||
}
|
}
|
||||||
|
self.needs_commit
|
||||||
|
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_snippet_sync(&self, s: &Snippet) {
|
pub fn add_snippet_sync(&self, s: &Snippet) {
|
||||||
if let Ok(writer) = self.writer.lock() {
|
let writer = self.writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let _ = writer.add_document(doc!(
|
writer.delete_term(tantivy::Term::from_field_text(self.id_field, &s.name));
|
||||||
self.id_field => s.name.as_str(),
|
if let Err(err) = writer.add_document(doc!(
|
||||||
self.title_field => s.name.as_str(),
|
self.id_field => s.name.as_str(),
|
||||||
self.body_field => format!("{} {}", s.language, s.description),
|
self.title_field => s.name.as_str(),
|
||||||
self.type_field => "snippet",
|
self.body_field => format!("{} {}", s.language, s.description),
|
||||||
self.namespace_field => "global"
|
self.type_field => "snippet",
|
||||||
));
|
self.namespace_field => "global"
|
||||||
self.needs_commit
|
)) {
|
||||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
tracing::warn!("Sync snippet indexing failed for '{}': {}", s.name, err);
|
||||||
}
|
}
|
||||||
|
self.needs_commit
|
||||||
|
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_adr_sync(&self, a: &Adr) {
|
pub fn add_adr_sync(&self, a: &Adr) {
|
||||||
if let Ok(writer) = self.writer.lock() {
|
let writer = self.writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let _ = writer.add_document(doc!(
|
writer.delete_term(tantivy::Term::from_field_text(self.id_field, &a.id));
|
||||||
self.id_field => a.id.as_str(),
|
if let Err(err) = writer.add_document(doc!(
|
||||||
self.title_field => a.title.as_str(),
|
self.id_field => a.id.as_str(),
|
||||||
self.body_field => format!("{} {} {}", a.context, a.decision, a.consequence),
|
self.title_field => a.title.as_str(),
|
||||||
self.type_field => "adr",
|
self.body_field => format!("{} {} {}", a.context, a.decision, a.consequence),
|
||||||
self.namespace_field => "global"
|
self.type_field => "adr",
|
||||||
));
|
self.namespace_field => "global"
|
||||||
self.needs_commit
|
)) {
|
||||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
tracing::warn!("Sync ADR indexing failed for '{}': {}", a.id, err);
|
||||||
}
|
}
|
||||||
|
self.needs_commit
|
||||||
|
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SearchService {
|
||||||
|
state: Arc<MemoryState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SearchService {
|
||||||
|
pub fn new(state: Arc<MemoryState>) -> Self {
|
||||||
|
Self { state }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn semantic_search(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
filter_namespace: Option<&str>,
|
||||||
|
limit: usize,
|
||||||
|
) -> crate::error::Result<Vec<SearchResult>> {
|
||||||
|
let query_emb = generate_embedding_async(query.to_string())
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
if query_emb.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
let mut uncached_texts = Vec::new();
|
||||||
|
let mut uncached_meta = Vec::new();
|
||||||
|
|
||||||
|
self.state.code.snippets.read_with(|snips| {
|
||||||
|
for snippet in snips.iter() {
|
||||||
|
if let Some(ref emb) = snippet.embedding {
|
||||||
|
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(),
|
||||||
|
title: snippet.name.clone(),
|
||||||
|
body: snippet.description.clone(),
|
||||||
|
score: sim,
|
||||||
|
});
|
||||||
|
} else if uncached_texts.len() < 50 {
|
||||||
|
uncached_texts.push(format!(
|
||||||
|
"{} {} {}",
|
||||||
|
snippet.name, snippet.description, snippet.code
|
||||||
|
));
|
||||||
|
uncached_meta.push((
|
||||||
|
snippet.name.clone(),
|
||||||
|
"snippet".to_string(),
|
||||||
|
snippet.description.clone(),
|
||||||
|
Some(snippet.updated_at),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
self.state.read_graph(|graph| {
|
||||||
|
for entity in graph.entities.values() {
|
||||||
|
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 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(),
|
||||||
|
title: entity.name.clone(),
|
||||||
|
body: desc,
|
||||||
|
score: sim,
|
||||||
|
});
|
||||||
|
} 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,
|
||||||
|
entity.updated_at,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
self.state.code.error_fixes.read_with(|fixes| {
|
||||||
|
for fix in fixes.iter() {
|
||||||
|
if let Some(ref emb) = fix.embedding {
|
||||||
|
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(),
|
||||||
|
title: fix.signature.clone(),
|
||||||
|
body: fix.solution.clone(),
|
||||||
|
score: sim,
|
||||||
|
});
|
||||||
|
} else if uncached_texts.len() < 50 {
|
||||||
|
uncached_texts.push(format!("{} {}", fix.signature, fix.solution));
|
||||||
|
uncached_meta.push((
|
||||||
|
fix.signature.clone(),
|
||||||
|
"error_fix".to_string(),
|
||||||
|
fix.solution.clone(),
|
||||||
|
Some(fix.timestamp),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if !uncached_texts.is_empty()
|
||||||
|
&& let Ok(embeddings) = generate_embeddings_async(uncached_texts).await
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
score: sim,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.sort_by(|a, b| {
|
||||||
|
b.score
|
||||||
|
.partial_cmp(&a.score)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
results.truncate(limit);
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn keyword_search(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
filter_namespace: Option<&str>,
|
||||||
|
limit: usize,
|
||||||
|
) -> crate::error::Result<Vec<SearchResult>> {
|
||||||
|
let idx = self.state.get_search_index().await;
|
||||||
|
let matches = idx
|
||||||
|
.search(query, filter_namespace)
|
||||||
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for (id, doc_type, title, body, score) in matches.into_iter().take(limit) {
|
||||||
|
results.push(SearchResult {
|
||||||
|
id,
|
||||||
|
doc_type,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
score,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,6 +671,7 @@ mod tests {
|
|||||||
observations: vec!["This is a test observation".to_string()],
|
observations: vec!["This is a test observation".to_string()],
|
||||||
namespace: "global".to_string(),
|
namespace: "global".to_string(),
|
||||||
git_branch: None,
|
git_branch: None,
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
let _ = index.index_entity(&entity).await.unwrap();
|
let _ = index.index_entity(&entity).await.unwrap();
|
||||||
|
|
||||||
@@ -336,6 +687,7 @@ mod tests {
|
|||||||
dependencies: vec![],
|
dependencies: vec![],
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
expires_at: None,
|
expires_at: None,
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
let _ = index.index_task(&task).await.unwrap();
|
let _ = index.index_task(&task).await.unwrap();
|
||||||
|
|
||||||
@@ -345,7 +697,9 @@ mod tests {
|
|||||||
language: "rust".to_string(),
|
language: "rust".to_string(),
|
||||||
description: "A test snippet".to_string(),
|
description: "A test snippet".to_string(),
|
||||||
updated_at: 0,
|
updated_at: 0,
|
||||||
|
tags: vec![],
|
||||||
embedding: None,
|
embedding: None,
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
let _ = index.index_snippet(&snippet).await.unwrap();
|
let _ = index.index_snippet(&snippet).await.unwrap();
|
||||||
|
|
||||||
@@ -358,6 +712,7 @@ mod tests {
|
|||||||
status: "accepted".to_string(),
|
status: "accepted".to_string(),
|
||||||
supersedes: None,
|
supersedes: None,
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
let _ = index.index_adr(&adr).await.unwrap();
|
let _ = index.index_adr(&adr).await.unwrap();
|
||||||
|
|
||||||
@@ -385,12 +740,12 @@ mod tests {
|
|||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
let index = MemoryIndex::new(temp_dir.path()).unwrap();
|
let index = MemoryIndex::new(temp_dir.path()).unwrap();
|
||||||
|
|
||||||
// Malformed lucene query (unclosed parenthesis)
|
// Malformed lucene query (unclosed parenthesis) - sanitized gracefully
|
||||||
let result = index.search("title: (unclosed", None);
|
let result = index.search("title: (unclosed", None);
|
||||||
assert!(result.is_err());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
// Another malformed query (unclosed quote)
|
// Another malformed query (unclosed quote) - sanitized gracefully
|
||||||
let result2 = index.search("title: \"unclosed", None);
|
let result2 = index.search("title: \"unclosed", None);
|
||||||
assert!(result2.is_err());
|
assert!(result2.is_ok());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+443
-107
@@ -1,9 +1,10 @@
|
|||||||
use crate::models::*;
|
use crate::models::*;
|
||||||
use crate::search::MemoryIndex;
|
use crate::search::MemoryIndex;
|
||||||
|
pub use crate::search::{SearchResult as UnifiedSearchResult, SearchService};
|
||||||
use crate::store::Store;
|
use crate::store::Store;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct GenericEvent {
|
pub struct GenericEvent {
|
||||||
@@ -12,47 +13,146 @@ pub struct GenericEvent {
|
|||||||
pub payload: serde_json::Value,
|
pub payload: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct MemoryState {
|
pub struct ProjectStores {
|
||||||
pub base_dir: PathBuf,
|
|
||||||
pub clipboard_watch_mode: tokio::sync::RwLock<bool>,
|
|
||||||
pub graph: Store<KnowledgeGraph>,
|
|
||||||
pub search_index: RwLock<MemoryIndex>,
|
|
||||||
pub ledger: Store<Vec<CodeChange>>,
|
|
||||||
pub sticky: Store<Vec<StickyNote>>,
|
|
||||||
pub tasks: Store<Vec<Task>>,
|
pub tasks: Store<Vec<Task>>,
|
||||||
|
pub milestones: Store<Vec<Milestone>>,
|
||||||
|
pub snapshots: Store<Vec<StateSnapshot>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CodeStores {
|
||||||
|
pub ledger: Store<Vec<CodeChange>>,
|
||||||
pub snippets: Store<Vec<Snippet>>,
|
pub snippets: Store<Vec<Snippet>>,
|
||||||
pub adrs: Store<Vec<Adr>>,
|
pub adrs: Store<Vec<Adr>>,
|
||||||
pub prefs: Store<HashMap<String, Preference>>,
|
|
||||||
pub error_fixes: Store<Vec<ErrorFix>>,
|
pub error_fixes: Store<Vec<ErrorFix>>,
|
||||||
pub pinned_files: Store<Vec<PinnedFile>>,
|
pub tech_debts: Store<Vec<TechDebt>>,
|
||||||
pub session_summaries: Store<Vec<SessionSummary>>,
|
pub hypotheses: Store<Vec<Hypothesis>>,
|
||||||
pub handoff_memos: Store<Vec<HandoffMemo>>,
|
}
|
||||||
|
|
||||||
|
pub struct EnvironmentStores {
|
||||||
pub env_fingerprints: Store<HashMap<String, EnvFingerprint>>,
|
pub env_fingerprints: Store<HashMap<String, EnvFingerprint>>,
|
||||||
pub env_requirements: Store<Vec<EnvRequirement>>,
|
pub env_requirements: Store<Vec<EnvRequirement>>,
|
||||||
pub milestones: Store<Vec<Milestone>>,
|
|
||||||
pub environments: Store<Vec<EnvironmentDetail>>,
|
pub environments: Store<Vec<EnvironmentDetail>>,
|
||||||
pub pr_checklists: Store<Vec<PrChecklistItem>>,
|
|
||||||
pub tech_debts: Store<Vec<TechDebt>>,
|
|
||||||
pub gates: Store<Vec<GateRecord>>,
|
pub gates: Store<Vec<GateRecord>>,
|
||||||
pub context_workspaces: Store<Vec<ContextWorkspace>>,
|
}
|
||||||
|
|
||||||
|
pub struct TelemetryStores {
|
||||||
|
pub session_summaries: Store<Vec<SessionSummary>>,
|
||||||
|
pub handoff_memos: Store<Vec<HandoffMemo>>,
|
||||||
pub recent_activities: Store<std::collections::VecDeque<serde_json::Value>>,
|
pub recent_activities: Store<std::collections::VecDeque<serde_json::Value>>,
|
||||||
pub terminal_history: Store<std::collections::VecDeque<TerminalHistory>>,
|
pub terminal_history: Store<std::collections::VecDeque<TerminalHistory>>,
|
||||||
|
pub agent_signals: Store<Vec<AgentSignal>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct CachedClipboardImage {
|
||||||
|
pub file_path: String,
|
||||||
|
pub file_path_wsl: String,
|
||||||
|
pub captured_at_epoch_ms: u64,
|
||||||
|
pub age: String,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub size_bytes: usize,
|
||||||
|
pub ocr_text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct CachedClipboardText {
|
||||||
|
pub text: String,
|
||||||
|
pub captured_at_epoch_ms: u64,
|
||||||
|
pub age: String,
|
||||||
|
pub char_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum ClipboardHistoryItem {
|
||||||
|
Image(CachedClipboardImage),
|
||||||
|
Text(CachedClipboardText),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ClipboardCacheState {
|
||||||
|
pub last_image: Option<CachedClipboardImage>,
|
||||||
|
pub last_text: Option<CachedClipboardText>,
|
||||||
|
pub history: std::collections::VecDeque<ClipboardHistoryItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClipboardCacheState {
|
||||||
|
pub fn push_image(&mut self, image: CachedClipboardImage) {
|
||||||
|
self.last_image = Some(image.clone());
|
||||||
|
self.history.push_front(ClipboardHistoryItem::Image(image));
|
||||||
|
if self.history.len() > 20 {
|
||||||
|
self.history.pop_back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_text(&mut self, text: CachedClipboardText) {
|
||||||
|
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));
|
||||||
|
if self.history.len() > 20 {
|
||||||
|
self.history.pop_back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_age(epoch_ms: u64) -> String {
|
||||||
|
let now_ms = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as u64;
|
||||||
|
let diff_secs = now_ms.saturating_sub(epoch_ms) / 1000;
|
||||||
|
if diff_secs < 60 {
|
||||||
|
format!("{}s ago", diff_secs)
|
||||||
|
} else if diff_secs < 3600 {
|
||||||
|
format!("{}m ago", diff_secs / 60)
|
||||||
|
} else {
|
||||||
|
format!("{}h ago", diff_secs / 3600)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MemoryState {
|
||||||
|
pub base_dir: PathBuf,
|
||||||
|
pub index_commit_notify: Arc<tokio::sync::Notify>,
|
||||||
|
pub ttl_notify: Arc<tokio::sync::Notify>,
|
||||||
|
pub condense_notify: Arc<tokio::sync::Notify>,
|
||||||
|
pub shutdown_notify: Arc<tokio::sync::Notify>,
|
||||||
|
pub graph: Store<KnowledgeGraph>,
|
||||||
|
pub search_index: tokio::sync::RwLock<MemoryIndex>,
|
||||||
|
|
||||||
|
pub project: ProjectStores,
|
||||||
|
pub code: CodeStores,
|
||||||
|
pub env: EnvironmentStores,
|
||||||
|
pub telemetry: TelemetryStores,
|
||||||
|
|
||||||
pub activity_tx: tokio::sync::broadcast::Sender<String>,
|
pub activity_tx: tokio::sync::broadcast::Sender<String>,
|
||||||
pub event_bus_tx: tokio::sync::broadcast::Sender<GenericEvent>,
|
pub event_bus_tx: tokio::sync::broadcast::Sender<GenericEvent>,
|
||||||
|
pub ollama: Arc<crate::ollama::OllamaClient>,
|
||||||
|
pub clipboard_cache: Arc<tokio::sync::RwLock<ClipboardCacheState>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryState {
|
impl MemoryState {
|
||||||
|
pub fn new_in_memory() -> Self {
|
||||||
|
Self::new(":memory:")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn new(base_dir_str: &str) -> Self {
|
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);
|
let base = std::path::PathBuf::from(base_dir_str);
|
||||||
std::fs::create_dir_all(&base).expect("Failed to create store dir");
|
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);
|
let db = crate::db::init_redb(&base);
|
||||||
|
|
||||||
Self {
|
let search_index = if is_in_memory {
|
||||||
clipboard_watch_mode: tokio::sync::RwLock::new(false),
|
crate::search::MemoryIndex::new_in_ram().expect("Failed to create RAM MemoryIndex")
|
||||||
graph: Store::new("knowledge_graph_master", db.clone()),
|
} else {
|
||||||
base_dir: base.clone(),
|
match crate::search::MemoryIndex::new(&base) {
|
||||||
search_index: RwLock::new(match crate::search::MemoryIndex::new(&base) {
|
|
||||||
Ok(idx) => idx,
|
Ok(idx) => idx,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let log_path = dirs::home_dir()
|
let log_path = dirs::home_dir()
|
||||||
@@ -60,59 +160,70 @@ impl MemoryState {
|
|||||||
.join(".gemini/mcp_memory/daemon_error.log");
|
.join(".gemini/mcp_memory/daemon_error.log");
|
||||||
let _ =
|
let _ =
|
||||||
std::fs::write(&log_path, format!("Failed to create MemoryIndex: {}\n", e));
|
std::fs::write(&log_path, format!("Failed to create MemoryIndex: {}\n", e));
|
||||||
std::process::exit(1);
|
crate::search::MemoryIndex::new_in_ram()
|
||||||
|
.expect("Failed to create RAM MemoryIndex")
|
||||||
}
|
}
|
||||||
}),
|
}
|
||||||
ledger: Store::new("audit_ledger", db.clone()),
|
};
|
||||||
sticky: Store::new("sticky_notes", db.clone()),
|
|
||||||
tasks: Store::new("tasks", db.clone()),
|
let state = Self {
|
||||||
snippets: Store::new("snippets", db.clone()),
|
ollama: Arc::new(crate::ollama::OllamaClient::new_from_env()),
|
||||||
adrs: Store::new("adrs", db.clone()),
|
index_commit_notify: Arc::new(tokio::sync::Notify::new()),
|
||||||
prefs: Store::new("preferences", db.clone()),
|
ttl_notify: Arc::new(tokio::sync::Notify::new()),
|
||||||
error_fixes: Store::new("error_fixes", db.clone()),
|
condense_notify: Arc::new(tokio::sync::Notify::new()),
|
||||||
pinned_files: Store::new("pinned_files", db.clone()),
|
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||||
session_summaries: Store::new("session_summaries", db.clone()),
|
graph: Store::new("knowledge_graph_master", db.clone()),
|
||||||
handoff_memos: Store::new("handoff_memos", db.clone()),
|
base_dir: base.clone(),
|
||||||
env_fingerprints: Store::new("env_fingerprints", db.clone()),
|
search_index: tokio::sync::RwLock::new(search_index),
|
||||||
env_requirements: Store::new("env_requirements", db.clone()),
|
|
||||||
milestones: Store::new("milestones", db.clone()),
|
project: ProjectStores {
|
||||||
environments: Store::new("environments", db.clone()),
|
tasks: Store::new("tasks", db.clone()),
|
||||||
pr_checklists: Store::new("pr_checklists", db.clone()),
|
milestones: Store::new("milestones", db.clone()),
|
||||||
tech_debts: Store::new("tech_debts", db.clone()),
|
snapshots: Store::new("state_snapshots", db.clone()),
|
||||||
gates: Store::new("gates", db.clone()),
|
},
|
||||||
context_workspaces: Store::new("context_workspaces", db.clone()),
|
code: CodeStores {
|
||||||
recent_activities: Store::new("recent_activities", db.clone()),
|
ledger: Store::new("audit_ledger", db.clone()),
|
||||||
terminal_history: Store::new("terminal_history", db.clone()),
|
snippets: Store::new("snippets", db.clone()),
|
||||||
|
adrs: Store::new("adrs", db.clone()),
|
||||||
|
error_fixes: Store::new("error_fixes", db.clone()),
|
||||||
|
tech_debts: Store::new("tech_debts", db.clone()),
|
||||||
|
hypotheses: Store::new("hypotheses", db.clone()),
|
||||||
|
},
|
||||||
|
env: EnvironmentStores {
|
||||||
|
env_fingerprints: Store::new("env_fingerprints", db.clone()),
|
||||||
|
env_requirements: Store::new("env_requirements", db.clone()),
|
||||||
|
environments: Store::new("environments", db.clone()),
|
||||||
|
gates: Store::new("gates", db.clone()),
|
||||||
|
},
|
||||||
|
telemetry: TelemetryStores {
|
||||||
|
session_summaries: Store::new("session_summaries", db.clone()),
|
||||||
|
handoff_memos: Store::new("handoff_memos", db.clone()),
|
||||||
|
recent_activities: Store::new("recent_activities", db.clone()),
|
||||||
|
terminal_history: Store::new("terminal_history", db.clone()),
|
||||||
|
agent_signals: Store::new("agent_signals", db.clone()),
|
||||||
|
},
|
||||||
|
|
||||||
activity_tx: tokio::sync::broadcast::channel(100).0,
|
activity_tx: tokio::sync::broadcast::channel(100).0,
|
||||||
event_bus_tx: tokio::sync::broadcast::channel(1000).0,
|
event_bus_tx: tokio::sync::broadcast::channel(1000).0,
|
||||||
}
|
clipboard_cache: Arc::new(tokio::sync::RwLock::new(ClipboardCacheState::default())),
|
||||||
}
|
};
|
||||||
|
|
||||||
pub fn broadcast_activity(&self, message: &str) {
|
// Normalize pre-existing graph entity and relation types
|
||||||
let time = std::time::SystemTime::now()
|
state.graph.modify(|g| {
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
for entity in g.entities.values_mut() {
|
||||||
.unwrap_or_default()
|
entity.entity_type = crate::models::normalize_entity_type(&entity.entity_type);
|
||||||
.as_millis() as u64;
|
}
|
||||||
|
for relation in g.relations.iter_mut() {
|
||||||
let item = serde_json::json!({
|
relation.relation_type =
|
||||||
"time": time,
|
crate::models::normalize_relation_type(&relation.relation_type);
|
||||||
"message": message
|
|
||||||
});
|
|
||||||
|
|
||||||
self.recent_activities.modify(|activities| {
|
|
||||||
activities.push_back(item.clone());
|
|
||||||
if activities.len() > 100 {
|
|
||||||
activities.pop_front();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let payload = serde_json::json!({
|
state
|
||||||
"jsonrpc": "2.0",
|
}
|
||||||
"method": "notifications/activity",
|
|
||||||
"params": item
|
pub fn broadcast_activity(&self, category: &str, message: &str) {
|
||||||
})
|
self.record_activity(category, message, None);
|
||||||
.to_string();
|
|
||||||
let _ = self.activity_tx.send(payload);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_graph<F, R>(&self, f: F) -> R
|
pub fn read_graph<F, R>(&self, f: F) -> R
|
||||||
@@ -124,57 +235,224 @@ impl MemoryState {
|
|||||||
|
|
||||||
pub fn modify_graph<F: FnOnce(&mut KnowledgeGraph)>(&self, update_fn: F) {
|
pub fn modify_graph<F: FnOnce(&mut KnowledgeGraph)>(&self, update_fn: F) {
|
||||||
self.graph.modify(update_fn);
|
self.graph.modify(update_fn);
|
||||||
|
let threshold: usize = std::env::var("MCP_MEMORY_CONDENSE_THRESHOLD")
|
||||||
|
.unwrap_or_else(|_| "100".to_string())
|
||||||
|
.parse()
|
||||||
|
.unwrap_or(100);
|
||||||
|
let count = self.graph.read_with(|g| g.entities.len());
|
||||||
|
if count >= threshold {
|
||||||
|
self.condense_notify.notify_one();
|
||||||
|
}
|
||||||
|
let _ = self.event_bus_tx.send(GenericEvent {
|
||||||
|
topic: "resource:updated".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({ "uri": "memory://graph" }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_search_index(&self) -> MemoryIndex {
|
pub async fn get_search_index(&self) -> MemoryIndex {
|
||||||
self.search_index
|
self.search_index.read().await.clone()
|
||||||
.read()
|
}
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
|
||||||
.clone()
|
pub fn search(self: &Arc<Self>) -> SearchService {
|
||||||
|
SearchService::new(self.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn rebuild_index(self: &Arc<Self>) {
|
pub async fn rebuild_index(self: &Arc<Self>) {
|
||||||
let idx = self.search_index.read().unwrap().clone();
|
let is_in_memory = self.base_dir.to_str() == Some(":memory:");
|
||||||
idx.delete_all();
|
let base_dir = self.base_dir.clone();
|
||||||
|
let state_clone = Arc::clone(self);
|
||||||
|
|
||||||
let entities: Vec<_> = self
|
// Offload full clone and synchronous Tantivy doc indexing off the async Tokio reactor
|
||||||
.graph
|
let new_idx = match tokio::task::spawn_blocking(move || {
|
||||||
.read_with(|g| g.entities.values().cloned().collect());
|
let new_idx = if is_in_memory {
|
||||||
let tasks = self.tasks.read_with(|t| t.clone());
|
crate::search::MemoryIndex::new_in_ram()
|
||||||
let snippets = self.snippets.read_with(|s| s.clone());
|
.expect("Failed to create RAM MemoryIndex for rebuild")
|
||||||
let adrs = self.adrs.read_with(|a| a.clone());
|
} else {
|
||||||
|
match crate::search::MemoryIndex::new(&base_dir) {
|
||||||
|
Ok(idx) => idx,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Failed to create disk MemoryIndex for rebuild ({}), falling back to RAM",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
crate::search::MemoryIndex::new_in_ram()
|
||||||
|
.expect("Failed to create RAM MemoryIndex for rebuild")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = new_idx.clear();
|
||||||
|
|
||||||
println!(
|
let entities: Vec<_> = state_clone
|
||||||
"rebuild_index: found {} entities, {} tasks",
|
.graph
|
||||||
entities.len(),
|
.read_with(|g| g.entities.values().cloned().collect());
|
||||||
tasks.len()
|
let tasks = state_clone.project.tasks.read_with(|t| t.clone());
|
||||||
);
|
let snippets = state_clone.code.snippets.read_with(|s| s.clone());
|
||||||
|
let adrs = state_clone.code.adrs.read_with(|a| a.clone());
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"rebuild_index: indexing {} entities, {} tasks synchronously in blocking thread",
|
||||||
|
entities.len(),
|
||||||
|
tasks.len()
|
||||||
|
);
|
||||||
|
|
||||||
let idx_clone = idx.clone();
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
println!("spawn_blocking started in rebuild_index");
|
|
||||||
for e in entities {
|
for e in entities {
|
||||||
idx_clone.add_entity_sync(&e);
|
new_idx.add_entity_sync(&e);
|
||||||
}
|
}
|
||||||
for task in tasks {
|
for task in tasks {
|
||||||
idx_clone.add_task_sync(&task);
|
new_idx.add_task_sync(&task);
|
||||||
}
|
}
|
||||||
for snippet in snippets {
|
for snippet in snippets {
|
||||||
idx_clone.add_snippet_sync(&snippet);
|
new_idx.add_snippet_sync(&snippet);
|
||||||
}
|
}
|
||||||
for adr in adrs {
|
for adr in adrs {
|
||||||
idx_clone.add_adr_sync(&adr);
|
new_idx.add_adr_sync(&adr);
|
||||||
}
|
}
|
||||||
|
new_idx
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|e| {
|
{
|
||||||
tracing::error!("Failed to join tantivy index rebuild thread: {}", e);
|
Ok(idx) => idx,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to join tantivy index rebuild thread: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = new_idx.commit().await;
|
||||||
|
*self.search_index.write().await = new_idx;
|
||||||
|
self.index_commit_notify.notify_waiters();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_activity(&self, category: &str, summary: &str, details: Option<&str>) {
|
||||||
|
let ts = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as u64;
|
||||||
|
|
||||||
|
let category_upper = category.to_uppercase();
|
||||||
|
let truncated_details = details.map(|s| {
|
||||||
|
if s.len() > 4096 {
|
||||||
|
format!("{}... [truncated]", &s[..4096])
|
||||||
|
} else {
|
||||||
|
s.to_string()
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let _ = idx.commit().await;
|
let activity = ActivityRecord {
|
||||||
if let Ok(mut w) = self.search_index.write() {
|
timestamp: ts,
|
||||||
*w = idx;
|
category: category_upper,
|
||||||
|
summary: summary.to_string(),
|
||||||
|
details: truncated_details,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(record_val) = serde_json::to_value(&activity) {
|
||||||
|
self.telemetry.recent_activities.modify(|activities| {
|
||||||
|
activities.push_front(record_val);
|
||||||
|
if activities.len() > 100 {
|
||||||
|
activities.pop_back();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.activity_tx.receiver_count() > 0 {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "notifications/activity",
|
||||||
|
"params": activity
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let _ = self.activity_tx.send(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn broadcast_task_event(&self, event: TaskEvent) {
|
||||||
|
let mut payload_val = serde_json::to_value(&event).unwrap_or_default();
|
||||||
|
if let Some(obj) = payload_val.as_object_mut() {
|
||||||
|
let active_task = self.project.tasks.read_with(|tasks| {
|
||||||
|
tasks
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.is_active())
|
||||||
|
.or_else(|| {
|
||||||
|
tasks
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.status == "pending" && t.parent_id.is_none())
|
||||||
|
})
|
||||||
|
.map(|t| t.title.clone())
|
||||||
|
});
|
||||||
|
obj.insert(
|
||||||
|
"active_task".to_string(),
|
||||||
|
serde_json::to_value(active_task).unwrap_or_default(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary_str = format!("Task {} -> {}", event.task_id, event.status);
|
||||||
|
let details_str = payload_val.to_string();
|
||||||
|
let truncated_details = if details_str.len() > 4096 {
|
||||||
|
format!("{}... [truncated]", &details_str[..4096])
|
||||||
|
} else {
|
||||||
|
details_str
|
||||||
|
};
|
||||||
|
|
||||||
|
self.telemetry.recent_activities.modify(|activities| {
|
||||||
|
let activity = ActivityRecord {
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
category: "TASK_EVENT".to_string(),
|
||||||
|
summary: summary_str,
|
||||||
|
details: Some(truncated_details),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
if let Ok(act_val) = serde_json::to_value(&activity) {
|
||||||
|
activities.push_front(act_val);
|
||||||
|
if activities.len() > 100 {
|
||||||
|
activities.pop_back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let generic_ev = GenericEvent {
|
||||||
|
topic: "task:event".to_string(),
|
||||||
|
session_id: event.session_id.clone(),
|
||||||
|
payload: payload_val,
|
||||||
|
};
|
||||||
|
let _ = self.event_bus_tx.send(generic_ev);
|
||||||
|
let _ = self.event_bus_tx.send(GenericEvent {
|
||||||
|
topic: "resource:updated".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({ "uri": "memory://tasks/active" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
let ws_notification = serde_json::json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "notifications/task/completed",
|
||||||
|
"params": event
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let _ = self.activity_tx.send(ws_notification);
|
||||||
|
|
||||||
|
let ws_resource_notification = serde_json::json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "notifications/resources/updated",
|
||||||
|
"params": {
|
||||||
|
"uri": "memory://tasks/active"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let _ = self.activity_tx.send(ws_resource_notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_terminal_history(&self, mut payload: TerminalHistory) {
|
||||||
|
if payload.command.len() > 2048 {
|
||||||
|
payload.command = format!("{}... [truncated]", &payload.command[..2048]);
|
||||||
|
}
|
||||||
|
self.telemetry.terminal_history.modify(|history| {
|
||||||
|
history.push_front(payload);
|
||||||
|
if history.len() > 100 {
|
||||||
|
history.pop_back();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +470,7 @@ mod tests {
|
|||||||
assert_eq!(state.base_dir, dir.path());
|
assert_eq!(state.base_dir, dir.path());
|
||||||
|
|
||||||
// Write a test value
|
// Write a test value
|
||||||
state.tasks.modify(|tasks| {
|
state.project.tasks.modify(|tasks| {
|
||||||
tasks.push(Task {
|
tasks.push(Task {
|
||||||
id: "123".to_string(),
|
id: "123".to_string(),
|
||||||
title: "Test Task".to_string(),
|
title: "Test Task".to_string(),
|
||||||
@@ -205,11 +483,12 @@ mod tests {
|
|||||||
git_branch: None,
|
git_branch: None,
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
expires_at: None,
|
expires_at: None,
|
||||||
|
..Default::default()
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ensure it is saved
|
// Ensure it is saved
|
||||||
state.tasks.read_with(|tasks| {
|
state.project.tasks.read_with(|tasks| {
|
||||||
assert_eq!(tasks.len(), 1);
|
assert_eq!(tasks.len(), 1);
|
||||||
assert_eq!(tasks[0].id, "123");
|
assert_eq!(tasks[0].id, "123");
|
||||||
});
|
});
|
||||||
@@ -219,16 +498,16 @@ mod tests {
|
|||||||
arc_state.rebuild_index().await;
|
arc_state.rebuild_index().await;
|
||||||
|
|
||||||
// Check search index initialization
|
// Check search index initialization
|
||||||
let idx = arc_state.search_index.read().unwrap();
|
let idx = arc_state.search_index.read().await;
|
||||||
// Force reload reader to ensure it sees the commit made by rebuild_index
|
// Force reload reader to ensure it sees the commit made by rebuild_index
|
||||||
idx.reader.reload().unwrap();
|
idx.reader.reload().unwrap();
|
||||||
println!(
|
// tracing::info!(
|
||||||
"Index reader doc count: {}",
|
// "Index reader doc count: {}",
|
||||||
idx.reader.searcher().num_docs()
|
// idx.reader.searcher().num_docs()
|
||||||
);
|
// );
|
||||||
|
|
||||||
let all_docs = idx.search("Test", None).expect("Search failed");
|
let _all_docs = idx.search("Test", None).expect("Search failed");
|
||||||
println!("All docs for 'Test': {:?}", all_docs);
|
// tracing::info!("All docs for 'Test': {:?}", all_docs);
|
||||||
|
|
||||||
// Verify the task added synchronously is actually searchable
|
// Verify the task added synchronously is actually searchable
|
||||||
let results = idx.search("Test", None).expect("Search failed");
|
let results = idx.search("Test", None).expect("Search failed");
|
||||||
@@ -239,4 +518,61 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(results[0].1, "task", "Expected document type to be task");
|
assert_eq!(results[0].1, "task", "Expected document type to be task");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_record_and_broadcast_activity() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let state = MemoryState::new(dir.path().to_str().unwrap());
|
||||||
|
let mut rx = state.activity_tx.subscribe();
|
||||||
|
|
||||||
|
// 1. Record an activity with details
|
||||||
|
state.record_activity(
|
||||||
|
"code_change",
|
||||||
|
"Refactored state.rs",
|
||||||
|
Some("Updated ActivityRecord schema"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify recent_activities store
|
||||||
|
let activities: Vec<ActivityRecord> = state.telemetry.recent_activities.read_with(|act| {
|
||||||
|
act.iter()
|
||||||
|
.filter_map(|v| serde_json::from_value(v.clone()).ok())
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(activities.len(), 1);
|
||||||
|
assert_eq!(activities[0].category, "CODE_CHANGE");
|
||||||
|
assert_eq!(activities[0].summary, "Refactored state.rs");
|
||||||
|
assert_eq!(
|
||||||
|
activities[0].details,
|
||||||
|
Some("Updated ActivityRecord schema".to_string())
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
activities[0].timestamp > 1_700_000_000_000,
|
||||||
|
"Timestamp must be in epoch milliseconds"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify broadcast channel message
|
||||||
|
let broadcast_msg = rx.recv().await.expect("Expected broadcast notification");
|
||||||
|
let broadcast_val: serde_json::Value =
|
||||||
|
serde_json::from_str(&broadcast_msg).expect("Valid JSON");
|
||||||
|
assert_eq!(broadcast_val["jsonrpc"], "2.0");
|
||||||
|
assert_eq!(broadcast_val["method"], "notifications/activity");
|
||||||
|
assert_eq!(broadcast_val["params"]["category"], "CODE_CHANGE");
|
||||||
|
|
||||||
|
// 2. Broadcast an activity without details
|
||||||
|
state.broadcast_activity("task", "Completed live activity fix");
|
||||||
|
|
||||||
|
let activities_updated: Vec<ActivityRecord> =
|
||||||
|
state.telemetry.recent_activities.read_with(|act| {
|
||||||
|
act.iter()
|
||||||
|
.filter_map(|v| serde_json::from_value(v.clone()).ok())
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(activities_updated.len(), 2);
|
||||||
|
assert_eq!(activities_updated[0].category, "TASK");
|
||||||
|
assert_eq!(activities_updated[0].summary, "Completed live activity fix");
|
||||||
|
assert_eq!(activities_updated[0].details, None);
|
||||||
|
assert!(activities_updated[0].timestamp >= activities_updated[1].timestamp);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+557
-76
@@ -4,63 +4,416 @@ use std::sync::{Arc, RwLock};
|
|||||||
|
|
||||||
pub const STORE_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("store");
|
pub const STORE_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("store");
|
||||||
|
|
||||||
pub struct Store<T> {
|
/// Internal write request dispatched to the single database writer actor.
|
||||||
pub cache: Arc<RwLock<T>>,
|
enum DbOp {
|
||||||
tx: tokio::sync::mpsc::Sender<()>,
|
Insert(Vec<u8>),
|
||||||
|
Delete,
|
||||||
|
Batch {
|
||||||
|
inserts: Vec<(String, Vec<u8>)>,
|
||||||
|
deletes: Vec<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: DeserializeOwned + Default + Serialize + Clone + Send + Sync + 'static> Store<T> {
|
/// Internal write request dispatched to the single database writer actor.
|
||||||
pub fn new(key: &str, db: Arc<Database>) -> Self {
|
struct DbWriteTask {
|
||||||
let initial_data = Self::load_from_db(key, &db);
|
key: String,
|
||||||
let cache = Arc::new(RwLock::new(initial_data));
|
op: DbOp,
|
||||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
|
flushed_notifier: Arc<tokio::sync::Notify>,
|
||||||
|
oneshot_tx: Option<tokio::sync::oneshot::Sender<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
let db_clone = db.clone();
|
/// Shared centralized write queue actor that handles all database writes serially with micro-batching.
|
||||||
let key_clone = key.to_string();
|
#[derive(Clone)]
|
||||||
let cache_clone = cache.clone();
|
pub struct DbWriteQueue {
|
||||||
|
tx: tokio::sync::mpsc::Sender<DbWriteTask>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static QUEUE_REGISTRY: std::sync::Mutex<Option<(Arc<Database>, DbWriteQueue)>> =
|
||||||
|
std::sync::Mutex::new(None);
|
||||||
|
|
||||||
|
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
|
||||||
|
&& 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()));
|
||||||
|
new_queue
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DbWriteQueue {
|
||||||
|
pub fn new(db: Arc<Database>) -> Self {
|
||||||
|
let (tx, mut rx) = tokio::sync::mpsc::channel::<DbWriteTask>(1024);
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while rx.recv().await.is_some() {
|
while let Some(first_task) = rx.recv().await {
|
||||||
// Drain any other pending notifications so we batch writes
|
let mut batch = Vec::with_capacity(100);
|
||||||
while rx.try_recv().is_ok() {}
|
batch.push(first_task);
|
||||||
|
|
||||||
let db_inner = db_clone.clone();
|
// Gold Standard Micro-batching: Drain up to 100 accumulated tasks from queue without blocking
|
||||||
let key_inner = key_clone.clone();
|
while batch.len() < 100 {
|
||||||
let json_data = {
|
match rx.try_recv() {
|
||||||
let lock = cache_clone.read().unwrap_or_else(|e| e.into_inner());
|
Ok(task) => batch.push(task),
|
||||||
serde_json::to_vec(&*lock)
|
Err(_) => break,
|
||||||
.map_err(|e| tracing::error!("Failed to serialize memory store: {}", e))
|
}
|
||||||
.ok()
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(json_data) = json_data {
|
|
||||||
let _ = tokio::task::spawn_blocking(move || {
|
|
||||||
if let Ok(write_txn) = db_inner.begin_write() {
|
|
||||||
if let Ok(mut table) = write_txn.open_table(STORE_TABLE) {
|
|
||||||
let _ = table.insert(key_inner.as_str(), json_data.as_slice());
|
|
||||||
}
|
|
||||||
let _ = write_txn.commit();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let db_inner = db.clone();
|
||||||
|
let _ = tokio::task::spawn_blocking(move || {
|
||||||
|
match db_inner.begin_write() {
|
||||||
|
Ok(write_txn) => {
|
||||||
|
if let Ok(mut table) = write_txn.open_table(STORE_TABLE) {
|
||||||
|
for task in &batch {
|
||||||
|
match &task.op {
|
||||||
|
DbOp::Insert(data) => {
|
||||||
|
if let Err(e) =
|
||||||
|
table.insert(task.key.as_str(), data.as_slice())
|
||||||
|
{
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to insert key '{}' into redb: {}",
|
||||||
|
task.key,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DbOp::Delete => {
|
||||||
|
if let Err(e) = table.remove(task.key.as_str()) {
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to delete key '{}' from redb: {}",
|
||||||
|
task.key,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DbOp::Batch { inserts, deletes } => {
|
||||||
|
for del_k in deletes {
|
||||||
|
if let Err(e) = table.remove(del_k.as_str()) {
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to delete batch key '{}' from redb: {}",
|
||||||
|
del_k,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (ins_k, ins_bytes) in inserts {
|
||||||
|
if let Err(e) =
|
||||||
|
table.insert(ins_k.as_str(), ins_bytes.as_slice())
|
||||||
|
{
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to insert batch key '{}' into redb: {}",
|
||||||
|
ins_k,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(e) = write_txn.commit() {
|
||||||
|
tracing::error!("Failed to commit batch to redb: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to begin write transaction on redb writer actor: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event-driven notification to all waiting listeners for this micro-batch
|
||||||
|
for task in batch {
|
||||||
|
if let Some(oneshot) = task.oneshot_tx {
|
||||||
|
let _ = oneshot.send(());
|
||||||
|
}
|
||||||
|
task.flushed_notifier.notify_waiters();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Self { cache, tx }
|
Self { tx }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_from_db(key: &str, db: &Database) -> T {
|
pub fn push(
|
||||||
let Ok(read_txn) = db.begin_read() else {
|
&self,
|
||||||
return T::default();
|
key: String,
|
||||||
|
data: Vec<u8>,
|
||||||
|
flushed_notifier: Arc<tokio::sync::Notify>,
|
||||||
|
) -> Option<tokio::sync::oneshot::Receiver<()>> {
|
||||||
|
self.push_op(key, DbOp::Insert(data), flushed_notifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_delete(
|
||||||
|
&self,
|
||||||
|
key: String,
|
||||||
|
flushed_notifier: Arc<tokio::sync::Notify>,
|
||||||
|
) -> Option<tokio::sync::oneshot::Receiver<()>> {
|
||||||
|
self.push_op(key, DbOp::Delete, flushed_notifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_batch(
|
||||||
|
&self,
|
||||||
|
key: String,
|
||||||
|
inserts: Vec<(String, Vec<u8>)>,
|
||||||
|
deletes: Vec<String>,
|
||||||
|
flushed_notifier: Arc<tokio::sync::Notify>,
|
||||||
|
) -> Option<tokio::sync::oneshot::Receiver<()>> {
|
||||||
|
self.push_op(key, DbOp::Batch { inserts, deletes }, flushed_notifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_op(
|
||||||
|
&self,
|
||||||
|
key: String,
|
||||||
|
op: DbOp,
|
||||||
|
flushed_notifier: Arc<tokio::sync::Notify>,
|
||||||
|
) -> Option<tokio::sync::oneshot::Receiver<()>> {
|
||||||
|
let (oneshot_tx, oneshot_rx) = tokio::sync::oneshot::channel();
|
||||||
|
let task = DbWriteTask {
|
||||||
|
key,
|
||||||
|
op,
|
||||||
|
flushed_notifier,
|
||||||
|
oneshot_tx: Some(oneshot_tx),
|
||||||
};
|
};
|
||||||
if let Ok(table) = read_txn.open_table(STORE_TABLE)
|
if let Err(e) = self.tx.try_send(task) {
|
||||||
&& let Ok(Some(value)) = table.get(key)
|
match e {
|
||||||
&& let Ok(parsed) = serde_json::from_slice::<T>(value.value())
|
tokio::sync::mpsc::error::TrySendError::Full(task) => {
|
||||||
{
|
let tx = self.tx.clone();
|
||||||
return parsed;
|
let key = task.key.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(err) = tx.send(task).await {
|
||||||
|
tracing::error!(
|
||||||
|
"DbWriteQueue fallback send failed for key '{}': {}",
|
||||||
|
key,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
None
|
||||||
|
}
|
||||||
|
tokio::sync::mpsc::error::TrySendError::Closed(task) => {
|
||||||
|
tracing::error!(
|
||||||
|
"DbWriteQueue channel closed; unable to persist key '{}'",
|
||||||
|
task.key
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Some(oneshot_rx)
|
||||||
}
|
}
|
||||||
T::default()
|
}
|
||||||
|
|
||||||
|
pub async fn push_async(
|
||||||
|
&self,
|
||||||
|
key: String,
|
||||||
|
data: Vec<u8>,
|
||||||
|
flushed_notifier: Arc<tokio::sync::Notify>,
|
||||||
|
) -> Option<tokio::sync::oneshot::Receiver<()>> {
|
||||||
|
self.push_op_async(key, DbOp::Insert(data), flushed_notifier)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn push_delete_async(
|
||||||
|
&self,
|
||||||
|
key: String,
|
||||||
|
flushed_notifier: Arc<tokio::sync::Notify>,
|
||||||
|
) -> Option<tokio::sync::oneshot::Receiver<()>> {
|
||||||
|
self.push_op_async(key, DbOp::Delete, flushed_notifier)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn push_batch_async(
|
||||||
|
&self,
|
||||||
|
key: String,
|
||||||
|
inserts: Vec<(String, Vec<u8>)>,
|
||||||
|
deletes: Vec<String>,
|
||||||
|
flushed_notifier: Arc<tokio::sync::Notify>,
|
||||||
|
) -> Option<tokio::sync::oneshot::Receiver<()>> {
|
||||||
|
self.push_op_async(key, DbOp::Batch { inserts, deletes }, flushed_notifier)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn push_op_async(
|
||||||
|
&self,
|
||||||
|
key: String,
|
||||||
|
op: DbOp,
|
||||||
|
flushed_notifier: Arc<tokio::sync::Notify>,
|
||||||
|
) -> Option<tokio::sync::oneshot::Receiver<()>> {
|
||||||
|
let (oneshot_tx, oneshot_rx) = tokio::sync::oneshot::channel();
|
||||||
|
let task = DbWriteTask {
|
||||||
|
key,
|
||||||
|
op,
|
||||||
|
flushed_notifier,
|
||||||
|
oneshot_tx: Some(oneshot_tx),
|
||||||
|
};
|
||||||
|
if let Err(e) = self.tx.send(task).await {
|
||||||
|
tracing::error!(
|
||||||
|
"DbWriteQueue channel closed; unable to persist key '{}'",
|
||||||
|
e.0.key
|
||||||
|
);
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(oneshot_rx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Store<T> {
|
||||||
|
pub cache: Arc<RwLock<T>>,
|
||||||
|
pub flushed: Arc<tokio::sync::Notify>,
|
||||||
|
key: String,
|
||||||
|
queue: DbWriteQueue,
|
||||||
|
is_corrupted: bool,
|
||||||
|
known_granular_keys: Arc<RwLock<std::collections::HashSet<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: DeserializeOwned + Default + Serialize + Send + Sync + 'static> Store<T> {
|
||||||
|
pub fn new(key: &str, db: Arc<Database>) -> Self {
|
||||||
|
let (initial_data, is_corrupted, known_keys) = Self::load_from_db(key, &db);
|
||||||
|
let cache = Arc::new(RwLock::new(initial_data));
|
||||||
|
let flushed = Arc::new(tokio::sync::Notify::new());
|
||||||
|
let queue = get_or_create_queue(db);
|
||||||
|
let known_granular_keys = Arc::new(RwLock::new(known_keys));
|
||||||
|
|
||||||
|
Self {
|
||||||
|
cache,
|
||||||
|
flushed,
|
||||||
|
key: key.to_string(),
|
||||||
|
queue,
|
||||||
|
is_corrupted,
|
||||||
|
known_granular_keys,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_from_db(key: &str, db: &Database) -> (T, bool, std::collections::HashSet<String>) {
|
||||||
|
let Ok(read_txn) = db.begin_read() else {
|
||||||
|
tracing::error!("Failed to begin read transaction for key '{}'", key);
|
||||||
|
return (T::default(), false, std::collections::HashSet::new());
|
||||||
|
};
|
||||||
|
let Ok(table) = read_txn.open_table(STORE_TABLE) else {
|
||||||
|
return (T::default(), false, std::collections::HashSet::new());
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Check monolithic key first as the authoritative snapshot
|
||||||
|
match table.get(key) {
|
||||||
|
Ok(Some(value)) => match serde_json::from_slice::<T>(value.value()) {
|
||||||
|
Ok(parsed) => {
|
||||||
|
let mut known = std::collections::HashSet::new();
|
||||||
|
if let Ok(val) = serde_json::to_value(&parsed) {
|
||||||
|
for k in Self::extract_granular_keys(key, &val) {
|
||||||
|
known.insert(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (parsed, false, known);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
"CRITICAL: Corrupted data for key '{}' in database: {}. Quarantine mode active: state initialized to empty default without overwriting DB key.",
|
||||||
|
key,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return (T::default(), true, std::collections::HashSet::new());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to get key '{}' from store table: {}", key, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Granular prefix keys fallback: format!("{}:", key)
|
||||||
|
let prefix = format!("{}:", key);
|
||||||
|
let mut items_array = Vec::new();
|
||||||
|
let mut items_map = serde_json::Map::new();
|
||||||
|
let mut found_granular = false;
|
||||||
|
let mut known = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
if let Ok(range) = table.range(prefix.as_str()..) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if found_granular {
|
||||||
|
if let Ok(parsed) = serde_json::from_value::<T>(serde_json::Value::Array(items_array)) {
|
||||||
|
return (parsed, false, known);
|
||||||
|
}
|
||||||
|
if let Ok(parsed) = serde_json::from_value::<T>(serde_json::Value::Object(items_map)) {
|
||||||
|
return (parsed, false, known);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(T::default(), false, std::collections::HashSet::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_granular_keys(base_key: &str, val: &serde_json::Value) -> Vec<String> {
|
||||||
|
let mut keys = Vec::new();
|
||||||
|
match val {
|
||||||
|
serde_json::Value::Array(arr) => {
|
||||||
|
for (i, item) in arr.iter().enumerate() {
|
||||||
|
let sub_key = item
|
||||||
|
.get("id")
|
||||||
|
.or_else(|| item.get("name"))
|
||||||
|
.or_else(|| item.get("title"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| i.to_string());
|
||||||
|
keys.push(format!("{}:{}", base_key, sub_key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::Value::Object(map) => {
|
||||||
|
for sub_key in map.keys() {
|
||||||
|
keys.push(format!("{}:{}", base_key, sub_key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
keys
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_granular_entries(base_key: &str, val: &serde_json::Value) -> Vec<(String, Vec<u8>)> {
|
||||||
|
let mut granular = Vec::new();
|
||||||
|
match val {
|
||||||
|
serde_json::Value::Array(arr) => {
|
||||||
|
for (i, item) in arr.iter().enumerate() {
|
||||||
|
let sub_key = item
|
||||||
|
.get("id")
|
||||||
|
.or_else(|| item.get("name"))
|
||||||
|
.or_else(|| item.get("title"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| i.to_string());
|
||||||
|
if let Ok(item_bytes) = serde_json::to_vec(item) {
|
||||||
|
granular.push((format!("{}:{}", base_key, sub_key), item_bytes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::Value::Object(map) => {
|
||||||
|
for (sub_key, item) in map {
|
||||||
|
if let Ok(item_bytes) = serde_json::to_vec(item) {
|
||||||
|
granular.push((format!("{}:{}", base_key, sub_key), item_bytes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
granular
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_with<F, R>(&self, f: F) -> R
|
pub fn read_with<F, R>(&self, f: F) -> R
|
||||||
@@ -71,19 +424,124 @@ impl<T: DeserializeOwned + Default + Serialize + Clone + Send + Sync + 'static>
|
|||||||
f(&lock)
|
f(&lock)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn modify<F: FnOnce(&mut T)>(&self, f: F) {
|
pub fn modify<F: FnOnce(&mut T)>(&self, f: F)
|
||||||
{
|
where
|
||||||
|
T: Serialize + Clone,
|
||||||
|
{
|
||||||
|
if self.is_corrupted {
|
||||||
|
tracing::error!(
|
||||||
|
"CRITICAL: Refusing to persist changes for corrupted store key '{}' to prevent data loss.",
|
||||||
|
self.key
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast mutation under critical lock section, only ONE clone taken, then immediately release the RwLock guard
|
||||||
|
let new_snapshot = {
|
||||||
let mut lock = self.cache.write().unwrap_or_else(|e| e.into_inner());
|
let mut lock = self.cache.write().unwrap_or_else(|e| e.into_inner());
|
||||||
f(&mut lock);
|
f(&mut lock);
|
||||||
|
(*lock).clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
match self.prepare_batch(&new_snapshot) {
|
||||||
|
Ok((batch_inserts, removed_keys)) => {
|
||||||
|
self.queue.push_batch(
|
||||||
|
self.key.clone(),
|
||||||
|
batch_inserts,
|
||||||
|
removed_keys,
|
||||||
|
self.flushed.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => tracing::error!(
|
||||||
|
"Failed to serialize memory store for key '{}': {}",
|
||||||
|
self.key,
|
||||||
|
e
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
fn prepare_batch(
|
||||||
|
&self,
|
||||||
|
new_snapshot: &T,
|
||||||
|
) -> Result<(Vec<(String, Vec<u8>)>, Vec<String>), serde_json::Error>
|
||||||
|
where
|
||||||
|
T: Serialize,
|
||||||
|
{
|
||||||
|
let full_bytes = serde_json::to_vec(new_snapshot)?;
|
||||||
|
let granular_entries = match serde_json::from_slice::<serde_json::Value>(&full_bytes) {
|
||||||
|
Ok(val) => Self::extract_granular_entries(&self.key, &val),
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_keys: std::collections::HashSet<String> =
|
||||||
|
granular_entries.iter().map(|(k, _)| k.clone()).collect();
|
||||||
|
let mut removed_keys = Vec::new();
|
||||||
|
{
|
||||||
|
let mut known = self
|
||||||
|
.known_granular_keys
|
||||||
|
.write()
|
||||||
|
.unwrap_or_else(|e| e.into_inner());
|
||||||
|
for old_k in known.iter() {
|
||||||
|
if !new_keys.contains(old_k) {
|
||||||
|
removed_keys.push(old_k.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*known = new_keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut batch_inserts = Vec::with_capacity(granular_entries.len() + 1);
|
||||||
|
batch_inserts.extend(granular_entries);
|
||||||
|
batch_inserts.push((self.key.clone(), full_bytes));
|
||||||
|
|
||||||
|
Ok((batch_inserts, removed_keys))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn modify_async<F: FnOnce(&mut T)>(&self, f: F)
|
||||||
|
where
|
||||||
|
T: Serialize + Clone,
|
||||||
|
{
|
||||||
|
if self.is_corrupted {
|
||||||
|
tracing::error!(
|
||||||
|
"CRITICAL: Refusing to persist changes for corrupted store key '{}' to prevent data loss.",
|
||||||
|
self.key
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_snapshot = {
|
||||||
|
let mut lock = self.cache.write().unwrap_or_else(|e| e.into_inner());
|
||||||
|
f(&mut lock);
|
||||||
|
(*lock).clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
match self.prepare_batch(&new_snapshot) {
|
||||||
|
Ok((batch_inserts, removed_keys)) => {
|
||||||
|
if let Some(rx) = self
|
||||||
|
.queue
|
||||||
|
.push_batch_async(
|
||||||
|
self.key.clone(),
|
||||||
|
batch_inserts,
|
||||||
|
removed_keys,
|
||||||
|
self.flushed.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let _ = rx.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::error!(
|
||||||
|
"Failed to serialize memory store for key '{}': {}",
|
||||||
|
self.key,
|
||||||
|
e
|
||||||
|
),
|
||||||
}
|
}
|
||||||
let _ = self.tx.try_send(());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use tempfile::NamedTempFile;
|
|
||||||
|
|
||||||
#[derive(Serialize, serde::Deserialize, Clone, Default, PartialEq, Debug)]
|
#[derive(Serialize, serde::Deserialize, Clone, Default, PartialEq, Debug)]
|
||||||
struct TestData {
|
struct TestData {
|
||||||
@@ -91,18 +549,21 @@ mod tests {
|
|||||||
value: i32,
|
value: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
fn create_in_memory_test_db() -> Arc<Database> {
|
||||||
async fn test_store_read_write() {
|
let db = Database::builder()
|
||||||
let temp_file = NamedTempFile::new().unwrap();
|
.create_with_backend(redb::backends::InMemoryBackend::new())
|
||||||
let db = Database::create(temp_file.path()).unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let write_txn = db.begin_write().unwrap();
|
let write_txn = db.begin_write().unwrap();
|
||||||
{
|
{
|
||||||
write_txn.open_table(STORE_TABLE).unwrap();
|
write_txn.open_table(STORE_TABLE).unwrap();
|
||||||
}
|
}
|
||||||
write_txn.commit().unwrap();
|
write_txn.commit().unwrap();
|
||||||
|
Arc::new(db)
|
||||||
|
}
|
||||||
|
|
||||||
let db = Arc::new(db);
|
#[tokio::test]
|
||||||
|
async fn test_store_read_write() {
|
||||||
|
let db = create_in_memory_test_db();
|
||||||
let store = Store::<TestData>::new("test_key", db.clone());
|
let store = Store::<TestData>::new("test_key", db.clone());
|
||||||
|
|
||||||
assert_eq!(store.read_with(|s| s.clone()), TestData::default());
|
assert_eq!(store.read_with(|s| s.clone()), TestData::default());
|
||||||
@@ -112,18 +573,9 @@ mod tests {
|
|||||||
data.value = 42;
|
data.value = 42;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Need to wait for spawn_blocking to finish
|
// Event-driven wait for persistence completion
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
store.flushed.notified().await;
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
store.read_with(|s| s.clone()),
|
|
||||||
TestData {
|
|
||||||
name: "Hello".to_string(),
|
|
||||||
value: 42
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Load again to verify persistence
|
|
||||||
let store2 = Store::<TestData>::new("test_key", db.clone());
|
let store2 = Store::<TestData>::new("test_key", db.clone());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store2.read_with(|s| s.clone()),
|
store2.read_with(|s| s.clone()),
|
||||||
@@ -136,16 +588,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
async fn test_store_concurrency() {
|
async fn test_store_concurrency() {
|
||||||
let temp_file = NamedTempFile::new().unwrap();
|
let db = create_in_memory_test_db();
|
||||||
let db = Database::create(temp_file.path()).unwrap();
|
|
||||||
|
|
||||||
let write_txn = db.begin_write().unwrap();
|
|
||||||
{
|
|
||||||
write_txn.open_table(STORE_TABLE).unwrap();
|
|
||||||
}
|
|
||||||
write_txn.commit().unwrap();
|
|
||||||
|
|
||||||
let db = Arc::new(db);
|
|
||||||
let store = Arc::new(Store::<TestData>::new("concurrent_key", db.clone()));
|
let store = Arc::new(Store::<TestData>::new("concurrent_key", db.clone()));
|
||||||
|
|
||||||
let mut handles = vec![];
|
let mut handles = vec![];
|
||||||
@@ -162,9 +605,47 @@ mod tests {
|
|||||||
h.await.unwrap();
|
h.await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for all blocking writes to flush
|
// Event-driven wait for blocking writes to flush
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
store.flushed.notified().await;
|
||||||
|
|
||||||
assert_eq!(store.read_with(|s| s.value), 50);
|
assert_eq!(store.read_with(|s| s.value), 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_store_deletion_does_not_resurrect() {
|
||||||
|
let db = create_in_memory_test_db();
|
||||||
|
#[derive(Serialize, serde::Deserialize, Clone, Default, PartialEq, Debug)]
|
||||||
|
struct Item {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
let store = Store::<Vec<Item>>::new("items", db.clone());
|
||||||
|
store.modify(|items| {
|
||||||
|
items.push(Item {
|
||||||
|
id: "item1".into(),
|
||||||
|
name: "First".into(),
|
||||||
|
});
|
||||||
|
items.push(Item {
|
||||||
|
id: "item2".into(),
|
||||||
|
name: "Second".into(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
store.flushed.notified().await;
|
||||||
|
|
||||||
|
// Verify both items loaded
|
||||||
|
let store_check = Store::<Vec<Item>>::new("items", db.clone());
|
||||||
|
assert_eq!(store_check.read_with(|items| items.len()), 2);
|
||||||
|
|
||||||
|
// Delete item1
|
||||||
|
store.modify(|items| {
|
||||||
|
items.retain(|i| i.id != "item1");
|
||||||
|
});
|
||||||
|
store.flushed.notified().await;
|
||||||
|
|
||||||
|
// Reload from DB into a brand new Store instance - item1 must NOT resurrect!
|
||||||
|
let store_reloaded = Store::<Vec<Item>>::new("items", db.clone());
|
||||||
|
let remaining = store_reloaded.read_with(|items| items.clone());
|
||||||
|
assert_eq!(remaining.len(), 1);
|
||||||
|
assert_eq!(remaining[0].id, "item2");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
use arboard::{Clipboard, ImageData};
|
|
||||||
|
|
||||||
use std::borrow::Cow;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let img = image::open("C:/Users/reazul.ashraf/workspace/rust/mcp-memory/test.jpg").unwrap_or_else(|_| image::DynamicImage::new_rgb8(10, 10));
|
|
||||||
let rgba = img.into_rgba8();
|
|
||||||
let (w, h) = rgba.dimensions();
|
|
||||||
|
|
||||||
let img_data = ImageData {
|
|
||||||
width: w as usize,
|
|
||||||
height: h as usize,
|
|
||||||
bytes: Cow::Owned(rgba.into_raw()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut clipboard = Clipboard::new().unwrap();
|
|
||||||
clipboard.set_image(img_data).unwrap();
|
|
||||||
println!("Successfully wrote image");
|
|
||||||
}
|
|
||||||
+755
-416
File diff suppressed because it is too large.
Load diff
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user