Compare commits
36
Commits
f546579596
...
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 |
No files matched your search
@@ -5,6 +5,8 @@ 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 (`tasks` list, `get_preflight_context`):** Executed when a session starts. This gives the LLM immediate awareness of the current workflow.
|
|
||||||
* **Context Switching (`manage_checkpoint`):** Executed when moving between branches or large features. This prevents context bleed between disparate tasks.
|
|
||||||
* **End-of-Day Handoff (`add_session_summary`, `generate_standup_report`):** Triggered when the user logs off, seamlessly serializing the mental state of the LLM for tomorrow.
|
|
||||||
|
|
||||||
### B. Subagent Orchestration (The Background Team)
|
|
||||||
Heavy or verbose interactions with the MCP server are delegated to specialized background subagents. This keeps the primary chat fast and focused on the code, while the "team" handles project management.
|
|
||||||
|
|
||||||
#### 1. `MemoryLibrarian` (The Graph Curator)
|
|
||||||
* **Role:** Analyzes git diffs and chat history to structure the Knowledge Graph.
|
|
||||||
* **Tools:** `log_code_change`, `log_error_fix`, `create_entities`, `log_tech_debt`.
|
|
||||||
* **Rationale:** Parsing diffs and determining entity relationships is token-heavy. Delegating this prevents the main agent from wasting reasoning cycles on database normalization.
|
|
||||||
|
|
||||||
#### 2. `ScrumMaster` (The Project Manager)
|
|
||||||
* **Role:** Manages the task lifecycle and acceptance criteria.
|
|
||||||
* **Tools:** `add_task`, `update_task_status`, `add_milestone`, `verify_acceptance_criteria`.
|
|
||||||
* **Rationale:** The main agent shouldn't have to repeatedly query "are we done yet?" The `ScrumMaster` runs alongside the session, validating criteria in the background and updating the board autonomously.
|
|
||||||
|
|
||||||
#### 3. `DevOpsSRE` (The Environment Manager)
|
|
||||||
* **Role:** Monitors dependencies and manages session transitions.
|
|
||||||
* **Tools:** `update_env_fingerprint`, `leave_handoff_memo`.
|
|
||||||
* **Rationale:** Prevents "it works on my machine" failures by passively updating fingerprints when build files (e.g., `Cargo.toml`) change.
|
|
||||||
|
|
||||||
## 3. Graceful Degradation & Server Resilience
|
|
||||||
The `mcp-memory` server is a distinct background process (typically port 3000). The LLM ecosystem must handle server downtime gracefully:
|
|
||||||
|
|
||||||
1. **Event Webhooks:** If the server goes down, waiting webhook tasks (e.g., waiting for a user to save a file in Neovim) will drop. These *do not* self-heal. The LLM must recognize the dropped connection and prompt the user to retry the action.
|
|
||||||
2. **Persistent Storage:** Data (tasks, graph, ledger) is persisted to `mcp_store.redb`. When the server comes back online, no data is lost. The LLM can immediately resume querying.
|
|
||||||
3. **Subagent Fast-Failing:** If the `MemoryLibrarian` attempts to log a change while the server is offline, it will instantly fail. It is designed to abandon the background task and notify the primary agent. To recover, the primary agent can manually re-invoke the Librarian once the connection is restored, instructing it to analyze recent commits to backfill the graph.
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
By treating `mcp-memory` as the durable brain, and enforcing a strict division of labor between the primary agent loop and background subagents, we achieve a highly autonomous, highly resilient AI pair-programming environment that scales across long-running projects and multiple terminal sessions.
|
|
||||||
|
|
||||||
## 4. MCP Feature Differentiation (Cognitive Boundaries)
|
|
||||||
The MCP protocol exposes three primary primitives. To prevent LLM confusion and API hallucination, the LLM must strictly adhere to the following interaction boundaries:
|
|
||||||
|
|
||||||
### A. Tools (For Stateful Mutation)
|
|
||||||
* **When to use:** Use tools *exclusively* for mutating state (e.g., dd_task, log_code_change) or for highly targeted semantic searches (e.g., search_nodes, query_graph_path).
|
|
||||||
* **LLM Awareness:** The LLM must not use tools to repeatedly poll for state changes. Tools represent active, expensive computing steps.
|
|
||||||
|
|
||||||
### B. Resources (For Passive Awareness)
|
|
||||||
* **When to use:** Use URIs (e.g., memory://tasks/active, memory://session/delta) to read holistic project state.
|
|
||||||
* **LLM Awareness:** The client integration should map these URIs to the LLM's context window. Instead of the LLM invoking a list_active_tasks tool (which costs a round-trip), the LLM should simply read the memory://tasks/active resource content if it needs to know what to do next. Resources are for passive, zero-cost reading.
|
|
||||||
|
|
||||||
### C. Prompts (For Macro-Workflows)
|
|
||||||
* **When to use:** Use server-defined prompts to execute complex, multi-step routines that require bundled context.
|
|
||||||
* **LLM Awareness:** Instead of the user or main agent trying to manually figure out the correct sequence of tools to end a session, the LLM should trigger the handoff_routine prompt. The server will respond with a strictly formatted message array that perfectly primes the LLM on exactly what to do next. Prompts act as "macro-instructions" to prevent the LLM from wandering off-script during complex transitions.
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# Architecture Design: MCP Resources & Prompts
|
|
||||||
|
|
||||||
## 1. Current Architecture (Tools)
|
|
||||||
Currently, the `mcp-memory` server handles MCP tools using an elegant trait-based approach in `router.rs`:
|
|
||||||
```rust
|
|
||||||
#[async_trait]
|
|
||||||
pub trait McpTool: Send + Sync {
|
|
||||||
fn name(&self) -> &'static str;
|
|
||||||
fn schema(&self) -> Value;
|
|
||||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Tools are registered into a `HashMap<String, Box<dyn McpTool>>` within the `MemoryHandler`. This prevents the main JSON-RPC match block from becoming a monolithic switch statement.
|
|
||||||
|
|
||||||
## 2. The Problem
|
|
||||||
Currently, the `resources/list`, `resources/read`, `prompts/list`, and `prompts/get` endpoints are hardcoded directly inside the `MemoryHandler::handle_request` match block in `router.rs`.
|
|
||||||
As we expand our usage of Resources (to expose the database state dynamically) and Prompts (to bundle complex workflows), continuing to hardcode them in `router.rs` will result in massive code duplication and tearup.
|
|
||||||
|
|
||||||
## 3. The Proposed Solution (Trait Extensibility)
|
|
||||||
We will replicate the success of the `McpTool` trait by introducing `McpResource` and `McpPrompt` traits.
|
|
||||||
|
|
||||||
### A. MCP Resources
|
|
||||||
**Trait Definition (`router.rs` or `resources.rs`):**
|
|
||||||
```rust
|
|
||||||
#[async_trait]
|
|
||||||
pub trait McpResource: Send + Sync {
|
|
||||||
/// The exact URI the client requests (e.g. "memory://tasks/active")
|
|
||||||
fn uri(&self) -> &'static str;
|
|
||||||
|
|
||||||
/// Human-readable name for the client UI
|
|
||||||
fn name(&self) -> &'static str;
|
|
||||||
|
|
||||||
/// Description for the client UI
|
|
||||||
fn description(&self) -> Option<&'static str> { None }
|
|
||||||
|
|
||||||
/// Mime type of the content (usually "application/json" or "text/markdown")
|
|
||||||
fn mime_type(&self) -> Option<&'static str> { Some("application/json") }
|
|
||||||
|
|
||||||
/// Retrieve the resource content
|
|
||||||
async fn read(&self, state: Arc<MemoryState>) -> Result<String, String>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
**Implementation:**
|
|
||||||
* Add `pub resources: std::collections::HashMap<String, Box<dyn McpResource>>` to `MemoryHandler`.
|
|
||||||
* In `handle_request("resources/list")`, iterate over `self.resources.values()` and build the JSON payload.
|
|
||||||
* In `handle_request("resources/read")`, lookup the requested URI in `self.resources` and call `.read(state).await`.
|
|
||||||
* Move the existing `memory://graph/entities` logic into its own handler struct.
|
|
||||||
|
|
||||||
### B. MCP Prompts
|
|
||||||
**Trait Definition (`router.rs` or `prompts.rs`):**
|
|
||||||
```rust
|
|
||||||
#[async_trait]
|
|
||||||
pub trait McpPrompt: Send + Sync {
|
|
||||||
/// The unique name of the prompt (e.g. "analyze_tech_debt")
|
|
||||||
fn name(&self) -> &'static str;
|
|
||||||
|
|
||||||
/// Description for the client UI
|
|
||||||
fn description(&self) -> Option<&'static str> { None }
|
|
||||||
|
|
||||||
/// Schema or array defining arguments (can default to empty)
|
|
||||||
fn arguments(&self) -> serde_json::Value { serde_json::json!([]) }
|
|
||||||
|
|
||||||
/// Execute the prompt and return the `messages` array payload
|
|
||||||
async fn get(&self, args: Value, state: Arc<MemoryState>) -> Result<serde_json::Value, String>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
**Implementation:**
|
|
||||||
* Add `pub prompts: std::collections::HashMap<String, Box<dyn McpPrompt>>` to `MemoryHandler`.
|
|
||||||
* In `handle_request("prompts/list")`, map over `self.prompts.values()`.
|
|
||||||
* In `handle_request("prompts/get")`, call `.get(args, state).await`.
|
|
||||||
|
|
||||||
## 4. Execution Plan
|
|
||||||
1. **Refactor `router.rs` (No functional changes yet):** Define the `McpResource` and `McpPrompt` traits. Update the `MemoryHandler` struct to hold these HashMaps. Migrate the existing hardcoded stubs (`memory://graph/entities` and `analyze_tech_debt`) into structs implementing these traits.
|
|
||||||
2. **Expand Resources (Phase 1):** Add new handlers for `memory://tasks/active`, `memory://session/delta`, etc.
|
|
||||||
3. **Expand Prompts (Phase 2):** Add new handlers for `handoff_routine`, etc.
|
|
||||||
|
|
||||||
This design guarantees we do not needlessly tear up code—we merely extend the existing robust `McpTool` pattern to the rest of the protocol.
|
|
||||||
Generated
+167
@@ -1370,6 +1370,16 @@ version = "0.1.13"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b"
|
checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flatbuffers"
|
||||||
|
version = "24.12.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4f1baf0dbf96932ec9a3038d57900329c015b0bfb7b63d904f3bc27e2b02a096"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"rustc_version",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flate2"
|
name = "flate2"
|
||||||
version = "1.1.10"
|
version = "1.1.10"
|
||||||
@@ -2636,12 +2646,15 @@ dependencies = [
|
|||||||
"ignore",
|
"ignore",
|
||||||
"image",
|
"image",
|
||||||
"notify",
|
"notify",
|
||||||
|
"ocrs",
|
||||||
"redb 4.3.0",
|
"redb 4.3.0",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rmcp",
|
"rmcp",
|
||||||
|
"rten",
|
||||||
"schemars",
|
"schemars",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_yaml",
|
||||||
"tantivy",
|
"tantivy",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
@@ -2661,6 +2674,7 @@ dependencies = [
|
|||||||
"tree-sitter-rust",
|
"tree-sitter-rust",
|
||||||
"tree-sitter-typescript",
|
"tree-sitter-typescript",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"windows",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
"winres",
|
"winres",
|
||||||
]
|
]
|
||||||
@@ -2670,6 +2684,7 @@ name = "mcp-memory-stub"
|
|||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-channel",
|
"async-channel",
|
||||||
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"dirs 7.0.0",
|
"dirs 7.0.0",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -2988,6 +3003,7 @@ name = "nvim-core"
|
|||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chrono",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"dirs 7.0.0",
|
"dirs 7.0.0",
|
||||||
"mcp-stdio",
|
"mcp-stdio",
|
||||||
@@ -3104,6 +3120,21 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ocrs"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2e4192a7dbc7570398ef97ab2606799703b6ea11da53f637d705d44546614435"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"rayon",
|
||||||
|
"rten",
|
||||||
|
"rten-imageproc",
|
||||||
|
"rten-tensor",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -3869,6 +3900,123 @@ dependencies = [
|
|||||||
"rmp",
|
"rmp",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rten"
|
||||||
|
version = "0.26.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf7741649977427aa13009537ac9a8fe1a503b0aa58784069ce8f117e47eb6ab"
|
||||||
|
dependencies = [
|
||||||
|
"flatbuffers",
|
||||||
|
"num_cpus",
|
||||||
|
"rayon",
|
||||||
|
"rten-base",
|
||||||
|
"rten-gemm",
|
||||||
|
"rten-model-file",
|
||||||
|
"rten-onnx",
|
||||||
|
"rten-parallel",
|
||||||
|
"rten-shape-inference",
|
||||||
|
"rten-simd",
|
||||||
|
"rten-tensor",
|
||||||
|
"rten-vecmath",
|
||||||
|
"rustc-hash",
|
||||||
|
"smallvec",
|
||||||
|
"typeid",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rten-base"
|
||||||
|
version = "0.26.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "413dc975c7a3d0e6da0b146cd0387e3d389cceab0fc48328bfa716c7f2ae1b1e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rten-gemm"
|
||||||
|
version = "0.26.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8efc07c654d4b3f8d81e2efd8b13aea7475568395289e5bb35393411aa6a451e"
|
||||||
|
dependencies = [
|
||||||
|
"rayon",
|
||||||
|
"rten-base",
|
||||||
|
"rten-parallel",
|
||||||
|
"rten-simd",
|
||||||
|
"rten-tensor",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rten-imageproc"
|
||||||
|
version = "0.26.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "18726eae9614e23a400560eb3a5e3fdb06526914c31aadaf767f14afde7680dd"
|
||||||
|
dependencies = [
|
||||||
|
"rten-tensor",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rten-model-file"
|
||||||
|
version = "0.26.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "04ea9e630b377f9249ff3c290466fef282319f86d13d560964209104cd3af542"
|
||||||
|
dependencies = [
|
||||||
|
"flatbuffers",
|
||||||
|
"rten-base",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rten-onnx"
|
||||||
|
version = "0.26.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "96944374568990b8d1bbc5bae5957585866f9dd8256130460405730948bfd42e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rten-parallel"
|
||||||
|
version = "0.26.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba9075b130f81df7be82496ea3a9bbf20fe9e75f106a22b0ece8700850f8f664"
|
||||||
|
dependencies = [
|
||||||
|
"rayon",
|
||||||
|
"rten-base",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rten-shape-inference"
|
||||||
|
version = "0.26.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "89289c26b6a5248fccf4d67627596848a03ed31888e6ce4ba6f666c689258122"
|
||||||
|
dependencies = [
|
||||||
|
"rten-tensor",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rten-simd"
|
||||||
|
version = "0.26.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f0c01294eb782adca256aa130b48a04006d03763073a68f45a9c33b982ea556"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rten-tensor"
|
||||||
|
version = "0.26.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf72556ba231d7af50238d26dd264856a2149c6369694eb79b22644c8280c81b"
|
||||||
|
dependencies = [
|
||||||
|
"rayon",
|
||||||
|
"rten-base",
|
||||||
|
"rten-parallel",
|
||||||
|
"smallvec",
|
||||||
|
"typeid",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rten-vecmath"
|
||||||
|
version = "0.26.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1007f05160f9ea2ce4e9c67324e5bafb27ae1e5302d0c6b1ea4db0d35fed78ee"
|
||||||
|
dependencies = [
|
||||||
|
"rten-base",
|
||||||
|
"rten-simd",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rust-stemmers"
|
name = "rust-stemmers"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -4195,6 +4343,19 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_yaml"
|
||||||
|
version = "0.9.34+deprecated"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
"unsafe-libyaml",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha1"
|
name = "sha1"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -5291,6 +5452,12 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
|
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unsafe-libyaml"
|
||||||
|
version = "0.2.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
# Effective Discourse: LLM Prompting Guide for MCP Memory
|
|
||||||
|
|
||||||
To get the most out of the Antigravity MCP Memory server and its advanced developer tools, use specific phrases that clearly state your intent. This guides the LLM to use the most efficient tools, reducing token consumption, speeding up time-to-resolve (T2R), and avoiding brute-force file reading.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Global Semantic Code Search & Navigation
|
|
||||||
Instead of having the LLM use brute-force text searches or `grep` to find abstract logic, instruct it to use local vector embeddings.
|
|
||||||
* **Don't say:** "Grep the codebase for database connection strings."
|
|
||||||
* **Do say:** "Perform a semantic code search for how database connections are established."
|
|
||||||
* **Tool Triggered:** `semantic_code_search`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Subgraph Expansion & Neighborhood Exploration
|
|
||||||
When inspecting complex system interactions or module dependencies around a target component.
|
|
||||||
* **Don't say:** "Tell me everything connected to the DatabaseTable node."
|
|
||||||
* **Do say:** "Get the subgraph expansion around 'DatabaseTable' up to 2 hops."
|
|
||||||
* **Tool Triggered:** `get_subgraph`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Automated Error Fix Suggestions
|
|
||||||
When encountering build failures, runtime crashes, or stack traces.
|
|
||||||
* **Don't say:** "Here is a stack trace, let's debug from scratch: [paste stack trace]"
|
|
||||||
* **Do say:** "Suggest an error fix for this stack trace before we start debugging."
|
|
||||||
* **Tool Triggered:** `suggest_error_fix`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Memory State Checkpointing & Safety Rollbacks
|
|
||||||
Before initiating risky refactors or running experimental multi-step subagents.
|
|
||||||
* **Don't say:** "Hope this refactor doesn't mess up our task board or memory graph."
|
|
||||||
* **Do say:** "Checkpoint the memory state under 'pre-refactor' before we begin."
|
|
||||||
* **Tool Triggered:** `checkpoint_state` / `restore_state`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Codebase Exploration & Token Efficiency
|
|
||||||
When entering a new file, avoid having the LLM read the entire contents blindly.
|
|
||||||
* **Don't say:** "Read server.rs and tell me what it does." *(Consumes massive tokens)*
|
|
||||||
* **Do say:** "Extract the AST skeleton of server.rs to understand its structure first."
|
|
||||||
* **Tool Triggered:** `read_file_skeleton`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Debugging & Log Parsing
|
|
||||||
Stop copy-pasting giant walls of logs into the chat interface.
|
|
||||||
* **Don't say:** "Here is the error: [paste 500 lines of logs]"
|
|
||||||
* **Do say:** "The daemon crashed. Fetch the recent logs from daemon.log." or "Watch the process logs for server.log."
|
|
||||||
* **Tool Triggered:** `process_logs` (action: "get", action: "watch")
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Git & Context Handoff
|
|
||||||
When you've been working independently and need to loop the LLM back in on your current state.
|
|
||||||
* **Don't say:** "I changed some files, here are the diffs..."
|
|
||||||
* **Do say:** "Get the active git worktree context to review my uncommitted changes before we continue."
|
|
||||||
* **Tool Triggered:** `get_active_worktree_context`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Inter-Agent Signal Bus & Coordination
|
|
||||||
When multiple subagents collaborate or background tasks complete.
|
|
||||||
* **Don't say:** "Let me manually tell the ScrumMaster that testing finished."
|
|
||||||
* **Do say:** "Broadcast an agent signal that unit tests passed and ping the PrePushAuditor."
|
|
||||||
* **Tool Triggered:** `agent_signals` (action: "broadcast", action: "query")
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Structural AST Editing
|
|
||||||
When asking the LLM to modify complex files, prevent indentation bugs and regex failures by guiding it to use tree-sitter.
|
|
||||||
* **Don't say:** "Search for `fn process()` and replace it with this string."
|
|
||||||
* **Do say:** "Use the AST node replacer to swap out the `process` function in `server.rs`."
|
|
||||||
* **Tool Triggered:** `replace_ast_node`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Bird's-Eye Repository Exploration
|
|
||||||
When the LLM is first analyzing a repository, don't let it run `ls -R` and guess.
|
|
||||||
* **Don't say:** "List the files in the directory and guess where the database code is."
|
|
||||||
* **Do say:** "Read the directory architecture to get a summary of what each file is responsible for."
|
|
||||||
* **Tool Triggered:** `read_directory_architecture`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Graph, Memory & Casing Standards
|
|
||||||
Actively instruct the LLM to maintain its memory constraints and use canonical casing.
|
|
||||||
* **Do say:** "Log this architectural decision in the knowledge graph using PascalCase for entity types."
|
|
||||||
* **Do say:** "Leave a handoff memo with the test database credentials and session action items."
|
|
||||||
* **Do say:** "Create a milestone for the 'Rich Clipboard' feature and break it down into active tasks."
|
|
||||||
* **Tools Triggered:** `create_entities`, `decisions` (log), `handoff_memos` (leave), `milestones` (add), `tasks` (add)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Self-Healing Graph Maintenance
|
|
||||||
* **Don't say:** "Search for duplicates and orphaned entities in the graph manually."
|
|
||||||
* **Do say:** "Sweep graph health to identify orphaned entities and duplicate candidates."
|
|
||||||
* **Tool Triggered:** `sweep_graph_health`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Causal Lineage & Provenance
|
|
||||||
* **Don't say:** "Search all tasks, ADRs, and commits to explain why this file was changed."
|
|
||||||
* **Do say:** "Query lineage for `server/src/state.rs` to construct a causal timeline."
|
|
||||||
* **Tool Triggered:** `query_lineage`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Actionable Task Resolution
|
|
||||||
* **Don't say:** "List all tasks and figure out which ones are blocked."
|
|
||||||
* **Do say:** "Get the next actionable tasks to find unblocked work ready for execution."
|
|
||||||
* **Tool Triggered:** `get_next_actionable_tasks`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. Diagnostic Hypotheses & Reasoning Traces
|
|
||||||
* **Don't say:** "Let's test three guesses and remember what we tried in chat."
|
|
||||||
* **Do say:** "Log a hypothesis for this memory leak with tested evidence."
|
|
||||||
* **Tool Triggered:** `hypotheses` (action: "log", action: "query")
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. State Checkpoints & Rollbacks
|
|
||||||
* **Don't say:** "Save a backup snapshot before we do this refactor."
|
|
||||||
* **Do say:** "Create a checkpoint named 'pre-refactor' before editing the database layer."
|
|
||||||
* **Tool Triggered:** `manage_checkpoint`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
By phrasing requests around *actions* rather than *information retrieval*, the LLM is primed to leverage the rich MCP toolset built into the Antigravity Memory Server.
|
|
||||||
@@ -1,197 +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 `agy` CLI agents. It tracks entities, relations, background tasks, engineering debt, architectural decisions, and error fixes across sessions.
|
`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 eliminate heavy Cross-OS I/O penalties when using WSL and Windows simultaneously, `mcp-memory` operates using a **Dual-Transport Leader/Stub Architecture**:
|
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 an Axum HTTP and WebSocket server for secondary clients.
|
* **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 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.
|
* **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.
|
||||||
|
|
||||||
> **Note for Users & LLMs**: Please read the [Strategic Guidelines](./instructions.md) and [Effective Discourse Guide](./EFFECTIVE_DISCOURSE.md) to learn how to perfectly trigger this server's advanced MCP tools.
|
> [!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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Casing & Naming Standards
|
## 🏛️ Architecture Status: 100% ADR Implementation
|
||||||
|
|
||||||
To prevent graph fragmentation and ensure optimal LLM tokenization and retrieval:
|
All 25 Architectural Decision Records (**ADR-0080 through ADR-0104**) are **100% implemented, verified, and reconciled** in the persistent store:
|
||||||
* **Entity Types (`entity_type`)**: Standardized as **`PascalCase`** (e.g. `DatabaseTable`, `McpTool`, `ArchitectureComponent`, `File`).
|
* **ADR-0080 – ADR-0093**: Enterprise persistence, AST intelligence, vector embeddings, cross-OS dual transports, and headless Neovim RPC.
|
||||||
* **Relation Types (`relation_type`)**: Standardized as **`snake_case`** (e.g. `depends_on`, `calls`, `implements`, `uses`).
|
* **ADR-0094 – ADR-0101**: Zero-subprocess security invariants, pure native Rust clipboard (`arboard`), and bounded telemetry buffers.
|
||||||
* **Field Keys & Attributes**: Standardized as **`snake_case`** (e.g. `file_path`, `git_commit`, `created_at`).
|
* **ADR-0102**: Dynamic Fastembed micro-batching with 16k character budget ceiling.
|
||||||
|
* **ADR-0103**: Real-time Tantivy search reader auto-reloading upon background index commits.
|
||||||
*Note: The server automatically normalizes and migrates incoming types to these canonical conventions on every read and write operation.*
|
* **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. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛠️ Consolidated Smart MCP Tools
|
## ⚡ MCP Workflow Prompts (`prompts/list`)
|
||||||
|
|
||||||
The server consolidates granular single-purpose tools into 11 concise, action-oriented smart domain handlers with zero prefix clutter:
|
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`).
|
* **`tasks`**: Complete task lifecycle management (`add`, `update`, `delete`, `list`, `set_criteria`, `verify`).
|
||||||
* **`milestones`**: Milestone tracking (`add`, `update`, `list`).
|
* **`milestones`**: Project milestone tracking (`add`, `update`, `list`).
|
||||||
* **`handoff_memos`**: Cross-session handoff notes (`leave`, `read`, `clear`).
|
* **`handoff_memos`**: Cross-session scratchpad and handoff memos (`leave`, `read`, `clear`).
|
||||||
* **`snippets`**: Reusable code snippet vault with BM25+Vector search (`store`, `search`, `delete`, `tag`).
|
* **`snippets`**: Reusable code snippet vault with hybrid BM25 + dense vector search (`store`, `search`, `delete`, `tag`).
|
||||||
* **`decisions`**: Architectural Decision Records (ADRs) (`log`, `query`, `delete`).
|
* **`decisions`**: Architectural Decision Records (ADRs) (`log`, `update`, `query`, `delete`).
|
||||||
* **`tech_debt`**: Engineering technical debt backlog (`log`, `resolve`, `list`).
|
* **`tech_debt`**: Engineering technical debt backlog (`log`, `resolve`, `list`).
|
||||||
* **`environment`**: Infrastructure & tool fingerprints tracking (`update_fingerprint`, `read_fingerprint`, `log_requirement`, `register`, `get_details`).
|
* **`environment`**: Infrastructure & tool fingerprints tracking (`update_fingerprint`, `read_fingerprint`, `log_requirement`, `register`, `get_details`).
|
||||||
* **`clipboard`**: Cross-OS clipboard management (`read`, `write`).
|
* **`clipboard`**: Pure native Rust OS clipboard interface (`read`, `write`).
|
||||||
* **`hypotheses`**: Diagnostic hypothesis memory (`log`, `query`).
|
* **`hypotheses`**: Diagnostic hypothesis memory for root cause analysis (`log`, `query`).
|
||||||
* **`agent_signals`**: Inter-agent signal bus (`broadcast`, `query`).
|
* **`agent_signals`**: Real-time inter-agent signal bus (`broadcast`, `query`).
|
||||||
* **`process_logs`**: Process and daemon log management (`watch`, `get`, `clear`).
|
* **`process_logs`**: Process and daemon log management (`watch`, `get`, `clear`).
|
||||||
---
|
|
||||||
|
|
||||||
## Key Features & Capabilities
|
### 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)
|
||||||
|
|
||||||
### 📜 VCS-Agnostic Code Change Ledger & Recent Deltas (`/api/ledger` & `memory://session/delta`)
|
### 3. AST & Code Intelligence (8 Tools)
|
||||||
Maintains an audit ledger of all file modifications, commit hashes / SVN revisions (`vcs_revision`), repository branches, upstream URLs, and AI change summaries with deterministic length bounds. Fully agnostic across Git, Subversion (SVN), and Mercurial (Hg). Exposed via the Brain Monitor Web UI (`/api/ledger`) and accessible as a passive context resource (`memory://session/delta`).
|
* `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.
|
||||||
|
|
||||||
### 💻 Terminal & Process Telemetry (`/terminal/history`)
|
### 4. Meta, Audit & Intelligence (15 Tools)
|
||||||
Tracks active shell instances (PowerShell, Bash, Nushell, Zsh), command history, working directories, and exit codes in real time. Enables LLMs and the Brain Monitor UI to maintain total visibility over terminal execution contexts.
|
* `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`
|
||||||
|
|
||||||
### 📋 Enriched Task Board, ADRs & Technical Debt Backlog
|
### 5. Task & Milestone Management (2 Tools)
|
||||||
Supports structured priorities (`low`, `medium`, `high`, `urgent`), assigned subagent roles, automated verification commands, architectural decision alternatives and consequences, and granular technical debt tracking (line ranges, workarounds, effort estimates).
|
* `tasks`, `milestones`
|
||||||
|
|
||||||
### 🕸️ Multi-Hop Subgraph Expansion (`get_subgraph`)
|
### 6. Notes, Handoffs & Reporting (4 Tools)
|
||||||
Performs a Breadth-First Search (BFS) around a target root entity node up to a specified depth ($N$ hops), returning all connected sub-entities and relationships in a single call.
|
* `handoff_memos`, `add_session_summary`, `generate_standup_report`, `promote_to_entity`
|
||||||
|
|
||||||
### ⚡ Automated Error Fix Auto-Matcher (`suggest_error_fix`)
|
### 7. Git & Worktree Context (2 Tools)
|
||||||
Compares build and test stack traces against historical error resolutions using dense vector embeddings and signature matching, returning past solutions, modified files, and git commits.
|
* `get_active_worktree_context`, `query_git_diffs`
|
||||||
|
|
||||||
### 💾 Memory State Checkpoints & Rollbacks (`checkpoint_state` / `restore_state`)
|
|
||||||
Saves point-in-time snapshots of graph entities, active tasks, and tech debt backlogs before risky operations, enabling seamless state restoration.
|
|
||||||
|
|
||||||
### 📊 Token Budgeting & RRF Search
|
|
||||||
* **Token Budgeting**: Supports `summary_level` (`compact` | `detailed` | `full`) and `max_tokens` parameters on `tasks` (list) and `tech_debt` (list).
|
|
||||||
* **Hybrid RRF Search**: `omni_search` combines Tantivy BM25 keyword matching with Dense Vector embeddings using Reciprocal Rank Fusion.
|
|
||||||
* **Session Delta Resource (`memory://session/delta`)**: Delivers recent session changes in a compact context resource.
|
|
||||||
|
|
||||||
### 🏷️ Domain Tagging for Code Snippets (`snippets`)
|
|
||||||
Supports categorization tags (`tags: Vec<String>`) on code snippets for category-filtered searches and domain organization.
|
|
||||||
|
|
||||||
### 🧹 Self-Healing Graph Sweeper (`sweep_graph_health`)
|
|
||||||
Audits entity nodes for orphans and calculates name similarity to surface near-duplicate merge recommendations or auto-prune stale nodes.
|
|
||||||
|
|
||||||
### 🔗 Causal Lineage & Provenance Tracker (`query_lineage`)
|
|
||||||
Traces the full causal chain linking tasks, ADRs, audit ledger entries, git commits, and error fixes for any query.
|
|
||||||
|
|
||||||
### 🎯 Topological Unblocked Task Resolver (`get_next_actionable_tasks`)
|
|
||||||
Evaluates task dependency graphs and returns unblocked, ready-to-run tasks for subagent execution.
|
|
||||||
|
|
||||||
### 🧠 Chain-of-Thought & Diagnostic Hypothesis Memory (`hypotheses`)
|
|
||||||
Records structured diagnostic hypotheses, test evidence, and verification statuses (actions: `log`, `query`) to preserve reasoning across sessions.
|
|
||||||
|
|
||||||
### 📡 Inter-Agent Signal Bus (`agent_signals`)
|
|
||||||
Facilitates real-time peer-to-peer signal exchange between autonomous subagents (actions: `broadcast`, `query`) with TTL expiration and activity feeds.
|
|
||||||
|
|
||||||
### 🔒 Resilient Storage & Serde Parameter Tolerances
|
|
||||||
### 📑 Process & Daemon Log Management (`process_logs`)
|
|
||||||
Registers and tails live process and daemon log files with UTF-8 safe seeking (actions: `watch`, `get`, `clear`) to diagnose runtime behavior without reading multi-megabyte files into chat context.
|
|
||||||
|
|
||||||
* **Explicit Fail-Fast Persistence Safety**: Replaced unsafe silent fallback to temporary databases (`/tmp/mcp_store_fallback_*`) with an explicit open retry and fail-fast panic unless `MCP_ALLOW_TMP_FALLBACK=1` is explicitly set, preventing silent data loss.
|
|
||||||
* **Store Write Lock Minimization**: Releases write lock immediately following in-memory mutation, serializing JSON payloads under read locks to allow non-blocking concurrent readers.
|
|
||||||
* **Two-Phase Graph Condensation**: Employs a non-destructive 2-phase commit in `condense_graph_worker` (reading without clearing, inserting into the knowledge graph, and only pruning summarized records by timestamp/content upon verified success).
|
|
||||||
* **Redb Transient Lock Backoff**: Added exponential backoff retry loop (3 attempts, 150ms delay) on Redb table lock acquisition to gracefully handle concurrent access contention.
|
|
||||||
* **Offloaded Background Index Rebuilds**: Heavy graph cloning and Tantivy re-indexing in `rebuild_index` are offloaded to `tokio::task::spawn_blocking` to prevent starving Tokio async worker pools.
|
|
||||||
* **Watch-Based Non-Destructive Shutdown**: Server cancellation signals utilize `tokio::sync::watch` rather than `mpsc` to allow multi-consumer broadcast notifications.
|
|
||||||
* **Async Mutex Deadlock Elimination**: Converted shared state and Neovim connection locks (`shutdown_tx`, `NVIM_CONN`, `ACTIVE_SOCKET`, `HEADLESS_PROC`) to `tokio::sync::Mutex` to prevent worker thread pool starvation across `.await` points.
|
|
||||||
* **Telemetry Session Deduplication & Channel Pruning**: Added `LAST_SESSION` in-memory state deduplication for UDP telemetry writes (eliminating disk I/O thrashing) and distinguished WebSocket `TrySendError::Full` backpressure vs `TrySendError::Closed` client pruning.
|
|
||||||
* **Graph Adjacency Indexing**: Leverages `KnowledgeGraph::build_adjacency_map` to build $O(1)$ lookup adjacency lists for fast BFS shortest path graph queries.
|
|
||||||
* **Serde Parameter & Enum Ergonomics**: Consolidated tools support flexible aliases (`source`/`from`, `target`/`to`, `relationType`/`relation_type`, `parent_id`/`parentId`, `camelCase`/`PascalCase`/`snake_case`) so LLM tool invocations never fail due to parameter discrepancies.
|
|
||||||
* **Embedding Input Safeguards**: `generate_embedding_async` returns explicit errors for empty string inputs instead of 0-length fallback vectors, guaranteeing vector dimension compatibility in `cosine_similarity`.
|
|
||||||
* **Path Traversal Security Guards**: Enforces path canonicalization (`validate_safe_path`) to reject parent relative directory traversal (`..`) across process and file log endpoints.
|
|
||||||
* **Proactive Watcher Memory Eviction**: Caps file watcher `last_processed` map size at 1,000 items and purges items older than 10 minutes to prevent long-running memory leaks.
|
|
||||||
* **Pre-cached Embedding Search**: Reuses pre-computed snippet embeddings (`snippet.embedding`), bypassing ONNX inference latency during in-memory semantic searches.
|
|
||||||
### 📡 Real-time WebSocket Memory Sync (`ws://127.0.0.1:3000/ws`)
|
|
||||||
Broadcasting event pipeline streams real-time graph, task, and activity mutations directly to the Brain Monitor UI.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start & Usage
|
## 🖥️ Brain Monitor Web UI (`http://127.0.0.1:3000/`)
|
||||||
|
|
||||||
### 1. Windows Installation (The Server & Stub)
|
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.
|
||||||
|
|
||||||
To enforce strict process safety and eliminate file locks on Windows, the build, deploy, and execution lifecycle are entirely decoupled in the `justfile`.
|
---
|
||||||
|
|
||||||
**The Golden Rule:** You must gracefully stop the server before deploying a new binary. Deploy recipes only copy files; they do not kill processes.
|
## 🔒 Security & Concurrency Invariants
|
||||||
|
|
||||||
The easiest way to manage this end-to-end (Stop -> Build -> Deploy -> Start) is using the chaining commands:
|
* **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
|
```powershell
|
||||||
# For the main server:
|
# 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, follow this exact order:
|
### 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
|
||||||
```
|
```
|
||||||
|
|
||||||
**Auto-Start Configuration:** To ensure the background server is always available, add this to your PowerShell profile:
|
### 3. Testing & Parity
|
||||||
```powershell
|
```powershell
|
||||||
if ($host.Name -eq 'ConsoleHost' -and -not (Get-Process mcp-memory-server -ErrorAction SilentlyContinue)) {
|
just test # Run fast parallel tests via cargo-nextest & type-check UI
|
||||||
Start-Process -FilePath "C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe" -WindowStyle Hidden -ErrorAction SilentlyContinue
|
just test-config # Verify eagerTools configuration parity
|
||||||
}
|
just test-ui # Verify dashboard UI endpoint and HTML integrity
|
||||||
```
|
```
|
||||||
|
|
||||||
**Shutting Down & Managing:**
|
### 4. Agent Configuration (`mcp_config.json`)
|
||||||
```powershell
|
|
||||||
just start
|
|
||||||
just stop
|
|
||||||
just restart
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Config Setup (`mcp_config.json`)
|
|
||||||
|
|
||||||
Update your `~/.gemini/config/mcp_config.json`:
|
|
||||||
```json
|
```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": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Brain Monitor Dashboard
|
|
||||||
|
|
||||||
The server hosts a live, real-time SPA dashboard called the **Brain Monitor**.
|
|
||||||
|
|
||||||
To view the dashboard, open your browser at:
|
|
||||||
`http://127.0.0.1:3000/`
|
|
||||||
|
|
||||||
### Dashboard Features:
|
|
||||||
* **Interactive Knowledge Graph:** Physics-simulated network graph with node-type coloring, drag-and-drop, and Inspector Panel.
|
|
||||||
* **Kanban Board:** Track active Tasks and trigger status transitions directly from the browser.
|
|
||||||
* **Clipboard Inspector:** Review OS-level clipboard image captures via `/api/clipboard/capture`.
|
|
||||||
* **Live WebSocket Telemetry:** Real-time UI updates triggered by server state changes.
|
|
||||||
* **Code Change Ledger:** Chronological audit trail of all code edits, commits, and summaries.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## High-Performance Concurrency & Resilience Guarantees
|
|
||||||
|
|
||||||
* **Atomic Store Write Lock Minimization**: `Store::modify` and `Store::modify_async` release write lock guards immediately after applying state mutations, performing JSON serialization under read guards to prevent blocking concurrent readers during state serialization.
|
|
||||||
* **Async Commit Index Reader Auto-Reload**: `MemoryIndex::commit()` automatically reloads index searchers upon background commit completion, eliminating search latency and stale reader windows.
|
|
||||||
* **Async Channel Backpressure (`push_async`)**: `Store::modify_async` uses `DbWriteQueue::push_async` with `tx.send(task).await` backpressure to guarantee database write persistence under heavy async write loads without dropping write transactions.
|
|
||||||
* **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.
|
|
||||||
* **Dynamic Character Micro-Batching**: `generate_embeddings_async` dynamically batches text payloads up to a 16,000 character budget inside `spawn_blocking`, eliminating heap spikes during high-volume vector indexing while keeping SIMD pipelines saturated.
|
|
||||||
* **Bounded Telemetry Detail Records**: Activity and terminal telemetry buffers enforce a 4,000 character truncation ceiling on log details (`ActivityRecord`, `TerminalHistory`) to prevent unbounded RAM growth under heavy RPC traffic.
|
|
||||||
* **Zero-Allocation Stream Formatting**: Graph condensation loops (`condense_graph_worker`) use `std::fmt::Write` string stream buffers to format subgraphs without allocating temporary string intermediates.
|
|
||||||
* **SIMD-Friendly Single-Pass Cosine Similarity**: `cosine_similarity` calculates dot product and Euclidean norm squares in a single iterator fold pass over float vectors, enabling SIMD compiler auto-vectorization.
|
|
||||||
* **Safe Stream Decoding on Log Tails**: Process log tailing (`process_logs`, action: `get`) reads raw bytes and decodes using lossy UTF-8 conversion (`String::from_utf8_lossy`) to ensure resilience when seeking across multi-byte UTF-8 boundaries.
|
|
||||||
* **Serde Parameter & Enum Tolerance**: All action enums (`HandoffMemoAction`, `HypothesisAction`, `AgentSignalAction`, `ProcessLogAction`, `SnippetSearchMode`, `Relation`) support case-insensitive variants and field aliases (`source`/`from`, `target`/`to`, `relationType`/`relation_type`) to ensure seamless execution when LLMs pass varied string formatting.
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -12,24 +12,46 @@ description: Strict guidelines for interacting with the mcp-memory server, ensur
|
|||||||
## 2. Proactive "Central Brain" Usage
|
## 2. Proactive "Central Brain" Usage
|
||||||
|
|
||||||
> [!NOTE] Two-Tier Memory Architecture
|
> [!NOTE] Two-Tier Memory Architecture
|
||||||
> 1. **Tier 1 (Static Markdown)**: Repository rules, coding style, tech constraints, and architectural boundaries belong in static git-tracked markdown (`rules/*.md`, `instructions.md`) and system prompts for 0ms latency and deterministic turn-0 enforcement.
|
> 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.
|
> 2. **Tier 2 (Structured DB & Telemetry)**: The MCP Memory server specializes in high-volume, dynamic data: file modification ledgers (`audit_ledger`), terminal command history, error resolutions (`log_error_fix`), active tasks, and preflight context aggregation.
|
||||||
|
|
||||||
The MCP Memory server is the central brain. You must be PROACTIVE, not reactive, in using it:
|
The MCP Memory server is the central brain. You must be PROACTIVE, not reactive, in using it:
|
||||||
- **Session Starts & Context Drops**: Always begin by calling `tasks` (action: "list"), `get_preflight_context`, and `omni_search` to regain context.
|
|
||||||
|
### Passive Resource Retrieval (Zero-Turn Latency)
|
||||||
|
Before making expensive active tool calls, read available MCP resources:
|
||||||
|
- **`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.
|
- **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 (e.g., Bitbucket markdown rendering quirks, nuanced framework bugs), IMMEDIATELY call `log_error_fix`. Supply `repo_name`, `error_category`, and `stack_trace` so future searches can perform embedding-based match.
|
- **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 (e.g., nested `if` statements, arrow anti-pattern) but deliberately skip fixing it to focus on a feature, IMMEDIATELY call `tech_debt` (action: "log") with `description`, `file_path`, `line_range`, `workaround`, `effort_estimate`, and `severity`.
|
- **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)**: When selecting design patterns, crate choices, or system structure, call `decisions` (action: "log") with `author`, `affected_components`, `alternatives_considered`, `decision`, and `consequence`.
|
- **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`.
|
- **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.
|
- **VCS & SVN Agnosticism**: Supply `vcs_type` ('git'|'svn'|'hg'), `vcs_revision` (git hash or svn revision like 'r12345'), and `upstream_url` to `log_code_change` and workspace tools.
|
||||||
- **Terminal & Shell Context**: Terminal sessions and commands are automatically tracked in the server. Query `/terminal/history` or recent logs when analyzing shell execution context.
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
## 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.
|
- **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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ The Antigravity CLI (`agy`) acts as the MCP Client and automatically manages the
|
|||||||
When `agy` starts up, it reads `mcp_config.json`. If it finds `"win-nvim": { "command": "C:\\Users\\reazul.ashraf\\.local\\bin\\mcp-memory-nvim.exe" }`, it will spawn that binary as a background subprocess using standard `stdio`.
|
When `agy` starts up, it reads `mcp_config.json`. If it finds `"win-nvim": { "command": "C:\\Users\\reazul.ashraf\\.local\\bin\\mcp-memory-nvim.exe" }`, it will spawn that binary as a background subprocess using standard `stdio`.
|
||||||
|
|
||||||
3. **Communication:**
|
3. **Communication:**
|
||||||
- The LLM requests to use a consolidated tool (e.g., `nvim_view` with action `goto_line`, or `nvim_execute_lua`).
|
- The LLM requests to use a consolidated tool (e.g., `nvim_workspace` with action `focus`, or `nvim_exec`).
|
||||||
- The `agy` CLI sends a JSON-RPC request to the `mcp-memory-nvim` subprocess via its `stdin`.
|
- The `agy` CLI sends a JSON-RPC request to the `mcp-memory-nvim` subprocess via its `stdin`.
|
||||||
- The Rust MCP Server receives the request, connects to the Neovim active socket/pipe (`~/.gemini/active_nvim.txt` or `\\.\pipe\nvim.*`), sends the Msgpack-RPC command, and writes the JSON-RPC response back to `stdout`.
|
- The Rust MCP Server receives the request, connects to the Neovim active socket/pipe (`~/.gemini/active_nvim.txt` or `\\.\pipe\nvim.*`), sends the Msgpack-RPC command, and writes the JSON-RPC response back to `stdout`.
|
||||||
- The `agy` CLI reads the response from `stdout` and returns it to the LLM context.
|
- The `agy` CLI reads the response from `stdout` and returns it to the LLM context.
|
||||||
@@ -29,12 +29,10 @@ The Antigravity CLI (`agy`) acts as the MCP Client and automatically manages the
|
|||||||
## Capabilities & Requirements
|
## Capabilities & Requirements
|
||||||
To use this architecture, Neovim must run the `gemini-integration.lua` script to broadcast its active socket to `~/.gemini/active_nvim.txt`.
|
To use this architecture, Neovim must run the `gemini-integration.lua` script to broadcast its active socket to `~/.gemini/active_nvim.txt`.
|
||||||
|
|
||||||
The MCP server provides 7 cohesive domain tools:
|
The MCP server provides 5 cohesive mega-tools:
|
||||||
1. **`nvim_buffer`** (actions: `get_active`, `read`, `open`, `create_scratch`, `save`, `reload`, `close`, `list`, `search`)
|
1. **`nvim_buffer`** (actions: `read`, `replace`, `save`, `undo`, `redo`, `create_scratch`)
|
||||||
2. **`nvim_window`** (actions: `list`, `get_active`, `focus`, `split`, `close`)
|
2. **`nvim_workspace`** (actions: `list_buffers`, `list_windows`, `focus`, `split`, `cwd`)
|
||||||
3. **`nvim_view`** (actions: `goto_line`, `get_cursor`, `get_viewport`, `get_selection`)
|
3. **`nvim_intelligence`** (actions: `hover`, `definition`, `references`, `outline`, `query`, `diagnostics`, `rename`, `code_action`)
|
||||||
4. **`nvim_diagnostics`** (actions: `get`, `set`, `set_quickfix`)
|
4. **`nvim_ui`** (actions: `highlight`, `ghost_text`, `clear`)
|
||||||
5. **`nvim_visual`** (actions: `preview`, `extmark`, `highlight`, `clear_highlight`)
|
5. **`nvim_exec`** (actions: `lua`, `vimscript`, `terminal`)
|
||||||
6. **`nvim_execute_lua`** (direct Lua execution escape hatch)
|
|
||||||
7. **`nvim_system`** (actions: `get_info`, `get_messages`, `send_to_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 `win-nvim` (Neovim) MCP Server to communicate with their *active* UI (e.g., using `nvim_execute_lua` with `vim.cmd('edit <file>')`).
|
## 1. No Invisible Shell Edits (CRITICAL)
|
||||||
|
**NEVER** use `sed`, `awk`, `python -c`, or `echo` to modify file contents from the terminal.
|
||||||
|
File edits must use EXACTLY one of two paths:
|
||||||
|
- **Active UI (Neovim MCP responsive):** Route edits through specialized MCP tools to provide live visual feedback.
|
||||||
|
- **No UI (Neovim MCP unresponsive):** Fall back to the built-in `replace_file_content` tool to generate clean chat diffs.
|
||||||
|
|
||||||
When making code edits or displaying search results/outputs:
|
## 2. Strict Tool Adherence (No Raw Lua RCE)
|
||||||
1. Verify if the `win-nvim` (Neovim) 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`).
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
When interacting with the user's Neovim editor (e.g., opening a file, moving the cursor, reading the active buffer, setting diagnostics), you MUST ALWAYS use the MCP tools provided by the `win-nvim` (Neovim) MCP server.
|
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 consolidated MCP tool registry (`nvim_buffer`, `nvim_window`, `nvim_view`, `nvim_diagnostics`, `nvim_visual`, `nvim_execute_lua`, `nvim_system`).
|
- You must rely entirely on the consolidated MCP tool registry (`nvim_buffer`, `nvim_workspace`, `nvim_intelligence`, `nvim_ui`, `nvim_exec`).
|
||||||
- If the tool is eagerly loaded, use it natively as an agent tool. If lazy-loaded, invoke it via the `call_mcp_tool` mechanism.
|
- If the tool is eagerly loaded, use it natively as an agent tool. If lazy-loaded, invoke it via the `call_mcp_tool` mechanism.
|
||||||
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Rule 1: Network Connectivity Testing
|
|
||||||
|
|
||||||
- **Mandatory Tooling**: When testing ANY network connectivity, you MUST use the exact same Rust crates used in the production code.
|
|
||||||
- **Methodology**: You must create standalone Rust applications (e.g., small test binaries or examples in the repository) that utilize the exact same networking plumbing as the main application.
|
|
||||||
- **Forbidden Tools**: You CANNOT use bash (e.g., curl, netcat), Python, Java, Perl, wscat, or any other external scripting/CLI tools to validate network connectivity. Testing with these tools creates false positives and bypasses the actual networking crates causing issues.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Rule 2: Empirical Evidence & Logging
|
|
||||||
|
|
||||||
- **Mandatory Logging**: If an issue occurs and there are no logs (or insufficient logs) to diagnose it, you must STOP immediately and add logging.
|
|
||||||
- **Replication**: After adding logging, you must repeat the test to replicate the exact issue so that the logs capture the failure.
|
|
||||||
- **No Blind Changes**: NO business logic can be altered blindly or based on guesses. You must have empirical evidence (derived from the logs) proving the root cause before attempting any changes to the logic.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# Rule 3: Resiliency & Cross-OS Load Testing
|
|
||||||
|
|
||||||
- **Resiliency Requirement**: A fix must be resilient and work under load. It is not enough for it to work just once.
|
|
||||||
- **Mandatory 5x Matrix Testing**: Any request/response cycle must be explicitly tested five (5) times for each of the following boundaries:
|
|
||||||
1. Windows to Windows (`win - win`)
|
|
||||||
2. WSL to Windows (`wsl - win`)
|
|
||||||
- **Documentation**: The results of these load tests MUST be recorded permanently (e.g., in an artifact or log) to prove resilience and to avoid repeating needless re-tests.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Rule 4: Component Isolation & Skeletal Troubleshooting
|
|
||||||
|
|
||||||
- **Targeted Testing**: If a bug is found, DO NOT rely on testing the full stack. You must isolate and test the offending class or method directly.
|
|
||||||
- **Skeletal Reproducers**: If the issue spans across boundaries (e.g., between server, stub, or nvim), you must create skeletal (minimal reproducible) versions of those components.
|
|
||||||
- **Purpose**: Troubleshooting must be done on these skeletal versions to isolate the broken feature or aspect without the noise, side-effects, or overhead of the full application stack.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Rule 5: Client-Centric Testing Personas
|
|
||||||
|
|
||||||
- **Avoid Server Bias**: Unit tests and integration tests must NOT be exclusively server-centric.
|
|
||||||
- **Client Personas**: Tests must explicitly adopt the persona, perspective, and constraints of the client components (e.g., the `stub` or `nvim`).
|
|
||||||
- **Validation**: Testing must validate the interaction from the client's side, ensuring that the client component correctly constructs the request, handles the connection lifecycle, and properly parses the response, rather than just verifying that the server successfully processed an isolated payload.
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
---
|
|
||||||
name: strict-no-verify
|
|
||||||
description: Strictly forbids the use of --no-verify or -n when interacting with git to ensure quality gates are run.
|
|
||||||
always_on: true
|
|
||||||
---
|
|
||||||
|
|
||||||
# STRICT GIT HOOK ENFORCEMENT
|
|
||||||
|
|
||||||
- **NEVER** use `--no-verify` or `-n` with `git commit` or `git push`.
|
|
||||||
- Bypassing git hooks is considered a critical violation of trust.
|
|
||||||
- You must allow the local git hooks (e.g., `pre-push`) to validate the code.
|
|
||||||
- If a hook fails (e.g., `ruff` check, `pytest`), you MUST fix the underlying issues iteratively until the hook passes natively. Do not assume a single fix attempt works without re-verifying.
|
|
||||||
- BEFORE running `git push`, you MUST use the `ask_question` tool to pop up an interactive modal to request explicit user permission. Wait for the user's approval before executing the push.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
Write-Host "Building Windows Server..." -ForegroundColor Cyan
|
|
||||||
Building Windows Server...
|
|
||||||
cargo build --release -p mcp-memory-server
|
|
||||||
Compiling tantivy v0.26.2
|
|
||||||
@@ -1,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);
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 82 KiB |
@@ -1,199 +0,0 @@
|
|||||||
# MCP Memory Server Architecture & Workflow Design
|
|
||||||
|
|
||||||
## 1. Core Architecture
|
|
||||||
The `mcp-memory` system runs as a **single, continuously running background daemon natively on Windows**. It manages the state of the Antigravity knowledge graph and serves as a universal backend for both Windows and WSL environments.
|
|
||||||
|
|
||||||
### Why this design?
|
|
||||||
* **Cross-OS I/O Optimization:** Prevents the WSL agent from performing slow, heavy filesystem writes against the mounted Windows `C:\` drive.
|
|
||||||
* **Concurrency & Locking:** A single daemon holds the lock on the `mcp_memory` JSON files, preventing data corruption and eliminating complex delta-reconciliation between parallel WSL and Windows processes.
|
|
||||||
* **Dual Transport System:** Utilizes both `stdio` and HTTP transports concurrently. The local Windows `agy` instance connects natively via standard `stdio`, while the Axum HTTP server provides synchronous, non-blocking access for WSL clients and external scripts.
|
|
||||||
|
|
||||||
## 2. Server Transport & Endpoints (Axum)
|
|
||||||
The daemon uses the `axum` and `rust-mcp-axum` crates, binding to `0.0.0.0:3000` to serve the network.
|
|
||||||
|
|
||||||
### Standard MCP Endpoints
|
|
||||||
* `GET /ws`: The WebSocket endpoint for efficient, low-latency, full-duplex JSON-RPC communication (preferred for proxies and the UI dashboard).
|
|
||||||
* `GET /sse`: The Server-Sent Events (SSE) endpoint. Antigravity clients connect here to keep a one-way pipe open for receiving pushed responses.
|
|
||||||
* `POST /messages`: The JSON-RPC endpoint. Clients use this to send tool calls and resource reads up to the server when connected via SSE.
|
|
||||||
|
|
||||||
### Dashboard REST API
|
|
||||||
The server hosts a rich Single Page Application (SPA) natively on the root route, backed by a suite of REST endpoints:
|
|
||||||
* `GET /`: The Brain Monitor live HTML dashboard SPA.
|
|
||||||
* `GET /api/graph`: Returns the full node/edge topology for the interactive physics-simulated canvas.
|
|
||||||
* `GET /api/tasks` & `POST /api/tasks/{id}/complete`: Drives the actionable Kanban board.
|
|
||||||
* `GET /api/ledger` & `GET /api/search`: Powers the code change ledger and global fuzzy search UI.
|
|
||||||
* `GET /api/stats`: Returns a JSON snapshot of current entity, relation, task, and tech debt counts.
|
|
||||||
|
|
||||||
### Git & IDE Integration Endpoints
|
|
||||||
* `UDP /nvim/telemetry`: A one-way connectionless datagram listener that Neovim instances hit on `FocusGained` or `BufEnter` to globally broadcast the user's active file to the UI and Agent.
|
|
||||||
* `GET /gate/verify`: A lightweight, deterministic endpoint used by external scripts (like a `git push` wrapper) to verify if an action is authorized based on the current state.
|
|
||||||
|
|
||||||
## 3. Client Connections
|
|
||||||
The Antigravity configurations (`mcp_config.json`) utilize the dual-transport system:
|
|
||||||
* **Windows `agy`:** Spawns and connects to the local daemon natively via `stdio` subprocess execution.
|
|
||||||
* **WSL (Linux) `agy`:** Connects to the running Windows HTTP daemon via the host network proxy, e.g., `http://127.0.0.1:3000/sse`.
|
|
||||||
|
|
||||||
## 4. Git Integration (Global Wrapper)
|
|
||||||
Instead of relying on localized per-repository Git hooks (like `.git/hooks/pre-push`), the system leverages a **global bash/PowerShell alias wrapper** for the `git` command. This intercepts `git` commands universally across the OS.
|
|
||||||
|
|
||||||
### Workflow Example (Push Safety Gate)
|
|
||||||
1. The user types `git push`.
|
|
||||||
2. The global wrapper intercepts the command.
|
|
||||||
3. It makes a synchronous HTTP request to the local daemon: `curl -s http://localhost:3000/gate/verify`.
|
|
||||||
4. If the endpoint returns `200 OK` (indicating the `PrePushAuditor` subagent has verified that unit tests pass and history is squashed), the push proceeds.
|
|
||||||
5. If not `200 OK`, the wrapper blocks the push and alerts the user to fix tests or run `gsquash`.
|
|
||||||
|
|
||||||
### Benefits of the Wrapper Approach
|
|
||||||
* **Universal Enforcement:** The push safety gate is protected across all repositories automatically, without copying hook scripts.
|
|
||||||
* **No File Locks:** Uses safe, lightweight, parallelizable HTTP requests rather than executing the Rust binary directly.
|
|
||||||
* **Action Logging:** The wrapper can be seamlessly extended to log actions (like `checkout` or `commit`) directly into the knowledge graph in real-time.
|
|
||||||
## 5. Storage Architecture & Persistence (Redb LSM-Tree)
|
|
||||||
The daemon has completely eliminated raw JSON file sprawl and fragmented delta-file reconciliation. It now utilizes a pure-Rust, embedded Key-Value engine (`redb`) that implements a robust Log-Structured Merge-Tree (LSM-tree) architecture.
|
|
||||||
|
|
||||||
### Key Principles:
|
|
||||||
* **Embedded Database Engine:** All structured components (Tasks, Snippets, Tech Debt, Checklists, etc.) are stored as binary-encoded values inside a unified `redb` database file (`store.redb`).
|
|
||||||
* **ACID Compliance & File Locks:** The Windows daemon holds an exclusive read-write lock on the database file, guaranteeing zero data corruption, race conditions, or lock contention during concurrent access.
|
|
||||||
* **Atomic Write-Guard Scope:** Store modification methods (`Store::modify` and `Store::modify_async`) retain the write lock through both the in-memory mutation and JSON serialization phases, eliminating lock-release TOCTOU race conditions.
|
|
||||||
* **Store Quarantine Mode:** If deserialization fails during `Store::load_from_db`, the store flags `is_corrupted = true` and refuses to overwrite database keys with default values on subsequent writes.
|
|
||||||
* **Asynchronous Checkpointing:** The core Knowledge Graph (Entities, Relations, Observations) still utilizes a Write-Ahead Logging (WAL) pattern (`wal.jsonl`) and a master snapshot (`master.json`) to allow safe, lock-free memory mutations which are reconciled in the background.
|
|
||||||
|
|
||||||
## 6. Domain Models & Component Stores
|
|
||||||
The system leverages a modular, thread-safe generic `Store<T>` abstraction that automatically transparently serializes and deserializes native Rust structs directly into the underlying `redb` tables.
|
|
||||||
Currently implemented persistent stores include:
|
|
||||||
* **Audit Ledger & Tasks:** Tracks agent actions and active background tasks.
|
|
||||||
* **Context & Handoffs:** Session Summaries, Handoff Memos, Hypotheses, and Agent Signals.
|
|
||||||
* **Engineering Tracking:** ADRs (Architecture Decision Records), Snippets, Error Fixes, Tech Debt, and PR Checklists.
|
|
||||||
* **Environment State:** Pinned Files, Env Fingerprints, Milestones, and Environments.
|
|
||||||
* **Safety Gates:** Authorized execution gates (Push Safety).
|
|
||||||
|
|
||||||
## 7. Full-Text & Semantic Search Engine (Tantivy + FastEmbed)
|
|
||||||
To support blazing-fast, intelligent semantic retrieval across the sprawling knowledge graph, the daemon embeds **Tantivy** (a full-text search engine inspired by Apache Lucene) alongside **FastEmbed** (a local ONNX runtime for vector embeddings).
|
|
||||||
* **The `MemoryIndex`:** Whenever the graph or auxiliary stores mutate, a background thread dynamically rebuilds the Tantivy index (`tantivy_index/` dir) and computes semantic vectors.
|
|
||||||
* **Pre-cached Vector Embeddings:** `SearchService::semantic_search` reuses pre-cached snippet embedding vectors (`snippet.embedding`), bypassing redundant ONNX neural network inference calls during query execution.
|
|
||||||
* **Global Omni-Search:** This architecture powers the `omni_search` tool, allowing subagents to instantly fuzzy-search and semantically rank documents across Entities, Tasks, Snippets, Error Fixes, and ADRs simultaneously in milliseconds, without loading massive JSON arrays into RAM.
|
|
||||||
|
|
||||||
## 8. Webhook Telemetry & Passive Ingestion
|
|
||||||
The server features a suite of webhook listeners that passively ingest development activity to build context without requiring human copy-pasting:
|
|
||||||
* **Terminal Ingestion (`/terminal/telemetry`):** Shell hooks (PowerShell/Nushell) silently POST command execution history and exit codes, allowing agents to read recent stack traces via the `memory://terminal/recent` resource.
|
|
||||||
* **IDE Telemetry (`/nvim/telemetry`):** A connectionless UDP datagram listener receives focus events from Neovim, broadcasting the user's active file to the UI and Agent instantly.
|
|
||||||
|
|
||||||
## 9. The Gate System (Push Safety Verification)
|
|
||||||
The binary supports a flexible safety gate authorization system, accessible both via the HTTP API and CLI subcommands.
|
|
||||||
* **API / CLI set**: Records an authorization status (`authorized`, `blocked`, or `pending`) for a specific target and namespace. Can be invoked via `POST /gate/set` (JSON) or `mcp-memory-stub gate set`.
|
|
||||||
* **API / CLI verify**: Evaluates a pending action against the gate store. It returns standard HTTP status codes (`200 OK`, `403 Forbidden`, `404 Not Found`) via `GET /gate/verify?action=...` or POSIX exit codes (0, 1, 2) via `mcp-memory-stub gate verify`. Both support a `consume` parameter/flag to immediately revoke the authorization after a successful check.
|
|
||||||
|
|
||||||
## 9. Operational Configuration & Paths
|
|
||||||
The physical storage location of the knowledge graph and all persistent stores is strictly controlled by the MCP_MEMORY_STORE_DIR environment variable.
|
|
||||||
* **Default Path:** If not set, the daemon defaults to ~/.gemini/mcp_memory.
|
|
||||||
* **Port Binding:** The Axum HTTP server strictly binds to .0.0.0:3000.
|
|
||||||
|
|
||||||
## 10. Exposed MCP Capabilities (Tools)
|
|
||||||
The server implements the Model Context Protocol (MCP) by exposing a vast suite of tools via the JSON-RPC interface, categorized broadly into:
|
|
||||||
* **Graph Management:** create_entities, create_relations, merge_entities,
|
|
||||||
read_graph, etc.
|
|
||||||
* **Task & Context Tracking:** tasks, milestones, handoff_memos, hypotheses, agent_signals, process_logs, add_session_summary, etc.
|
|
||||||
* **Engineering & DevOps:** log_code_change, log_error_fix, tech_debt, decisions, etc.
|
|
||||||
* **Environment & Workspaces:** environment, manage_checkpoint, manage_subagent_namespace, snippets, etc.
|
|
||||||
|
|
||||||
|
|
||||||
## 11. Concurrency & Thread Safety
|
|
||||||
With the introduction of the Dual Transport System, the daemon must safely handle simultaneous read/write requests from both Stdio (Windows agy) and HTTP (WSL agy) clients.
|
|
||||||
* **Shared State:** The entire server operates on a cloned Arc<MemoryState>.
|
|
||||||
* **Locking Mechanism:** The unified Knowledge Graph and modular Store<T> components are protected by RwLock primitives.
|
|
||||||
* **Safe Mutation:** Store modifications utilize closure-based modify(|store| { ... }) methods to ensure locks are safely acquired, mutations applied, and file writes executed sequentially without deadlocking the asynchronous Tokio runtimes.
|
|
||||||
|
|
||||||
## 12. Lifecycle & Startup Management (Server/Stub Architecture)
|
|
||||||
The mcp-memory daemon employs a strict Server/Client paradigm to maintain separation of concerns. There are no dual roles or morphing executables.
|
|
||||||
|
|
||||||
### The Windows Background Daemon (`mcp-memory-server.exe`)
|
|
||||||
The server executable is responsible exclusively for running the Axum HTTP and WebSockets daemon and managing the Knowledge Graph. It does not contain any proxy logic. If port 3000 is already in use by an existing server instance, it gracefully exits instead of attempting to run.
|
|
||||||
|
|
||||||
* **Non-Blocking Startup & Indexing:** Upon launch, the server uses a `tokio::spawn` task to rebuild the Tantivy index in the background. This ensures the Axum HTTP server binds immediately to port 3000, allowing instantaneous liveness checks via the `/ping` endpoint without waiting for the synchronous I/O operations of the index rebuild to complete.
|
|
||||||
* **Robust Background Detachment:** To ensure the server survives the death of the parent shell that spawns it (such as a deployment script), it uses Windows `WScript.Shell` (via `start_server.ps1`) to spawn the binary with a strictly hidden window style (`SW_HIDE`). This completely detaches it from the parent process tree, preventing `CTRL_CLOSE_EVENT` signals from killing the background daemon when the deploying terminal closes.
|
|
||||||
|
|
||||||
### The Client Proxy (`mcp-memory-stub.exe`)
|
|
||||||
All Antigravity sessions (Windows and WSL) use the lightweight `mcp-memory-stub` as their proxy. The stub connects to the server via WebSockets and acts as the bridge for standard `stdio` JSON-RPC traffic.
|
|
||||||
* **Startup via Interop/Spawn:** Upon launch, the stub attempts to connect to the Windows host on port 3000. If the server is offline, the stub automatically executes a spawn command (e.g., executing `Start-Process` natively, or via WSL interop) to silently wake up the Windows Leader before commencing the proxy loop.
|
|
||||||
* **MPSC Queue Resilience:** The stub utilizes an asynchronous multi-producer, single-consumer (MPSC) channel queue. If the Windows Leader daemon restarts or momentarily drops, the proxy buffers incoming JSON-RPC tool calls and infinitely retries them until the connection is restored. This guarantees **zero message loss** and **zero thread leaks** without crashing the active `agy` session.
|
|
||||||
* **Zero I/O Penalty (WSL):** This ensures the Linux binary never directly touches the Windows NTFS files, reserving all heavy disk operations for the native Windows host.
|
|
||||||
## 13. Cargo Workspace & Binary Artifacts
|
|
||||||
To optimize for different environments, the codebase is structured as a Cargo Workspace containing two distinct crates:
|
|
||||||
|
|
||||||
### 1. mcp-memory-server (The "Full-Fat" Daemon)
|
|
||||||
* **Path:** server/
|
|
||||||
* **Size/Complexity:** Heavy (contains Axum, MCP SDK, JSON parsing, Tokio runtime).
|
|
||||||
* **Role:** This is the primary background daemon. It binds to .0.0.0:3000, holds file locks, and manages the graph.
|
|
||||||
* **Windows Behavior:** It is designed to run in the background as a standalone service.
|
|
||||||
|
|
||||||
### 2. mcp-memory-stub (The Ultra-Lightweight Proxy)
|
|
||||||
* **Path:** stub/
|
|
||||||
* **Size/Complexity:** Extremely light (only relies on
|
|
||||||
eqwest and okio).
|
|
||||||
* **Role:** A dedicated, OS-agnostic proxy binary used strictly for routing stdio JSON-RPC traffic over HTTP to a remote Leader. Windows gy clients point directly to this binary to bypass loading the heavy Server daemon into memory.
|
|
||||||
* **WSL Behavior:** Compiled as a Linux native binary (x86_64-unknown-linux-musl). When executed by WSL agy, it acts as a transparent proxy to http://127.0.0.1:3000. It can also execute wake_cmd (e.g., WSL interop) to silently wake the Windows host if the Leader is offline.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 14. Neovim Integration Architecture & "God Mode"
|
|
||||||
To enable seamless pair-programming inside Neovim, the daemon integrates with Neovim using two complementary systems: a Webhook Telemetry pipeline and dedicated MCP binaries.
|
|
||||||
|
|
||||||
### Telemetry Pipeline (Last Focused Wins)
|
|
||||||
A lightweight Lua script (`gemini-integration.lua`) is loaded into Neovim, which fires an asynchronous UDP datagram to `127.0.0.1:3002` (fire-and-forget via native libuv) whenever the user focuses a buffer or moves the cursor. The server then writes this data (including `session_id`, `file`, `line`, and `col`) to both the Windows and WSL `active_nvim.txt` files and broadcasts it over WebSockets.
|
|
||||||
|
|
||||||
### Native MCP Binaries (win-nvim & linux-nvim)
|
|
||||||
The project compiles two standalone, highly-performant binaries that implement the MCP JSON-RPC protocol over Stdio and bridge it directly to Neovim's Msgpack-RPC Named Pipes/Sockets. These binaries avoid hardcoding infinite tools by utilizing a "God Mode" escape hatch.
|
|
||||||
|
|
||||||
Exposed Neovim Tools:
|
|
||||||
* **
|
|
||||||
vim_get_active_buffer &
|
|
||||||
vim_get_cursor**: Read file state.
|
|
||||||
* **
|
|
||||||
vim_goto_line &
|
|
||||||
vim_set_diagnostics**: Manipulate IDE state.
|
|
||||||
* **
|
|
||||||
vim_get_visual_selection**: Read exact highlight coordinates (handles mode dynamically).
|
|
||||||
* **
|
|
||||||
vim_list_buffers**: Discover unsaved work and context.
|
|
||||||
* **
|
|
||||||
vim_get_diagnostics**: Read live LSP errors dynamically instead of requiring a compiler.
|
|
||||||
* **
|
|
||||||
vim_execute_lua ("God Mode")**: The ultimate fallback tool. Evaluates raw Lua scripts inside the active Neovim instance and returns JSON. This prevents the need to continuously recompile the Rust server whenever a new Neovim capability is required.
|
|
||||||
|
|
||||||
## 15. Build & Deployment Strategy
|
|
||||||
Because the background server operates as an always-on Windows daemon, standard recompilation and file-copying strategies will fail due to active Windows OS file locks.
|
|
||||||
|
|
||||||
### Randomized Lock Bypassing
|
|
||||||
The \uild.ps1\ deployment pipeline intercepts locked \.exe\ files by appending a unique, timestamped/randomized suffix (e.g., \mcp-memory-server.exe.12345.old\) when forcing a \Move-Item\. This guarantees that rapid sequential deployments (where a previous \.old\ file might still be locked by a zombie process) never silently fail or collide.
|
|
||||||
|
|
||||||
### Dynamic Versioning
|
|
||||||
To trace binary provenances during rapid deployment cycles, all binaries embed dynamic versioning directly at compile time (via \uild.rs\ and \uild_template.rs\). The injected \APP_VERSION\ environment variable combines the static Cargo \ersion\ with the live \git\ short hash and UTC timestamp, allowing the CLI \--version\ commands and the HTTP \/api/version\ endpoints to guarantee exactly which iteration of the code is actively executing.
|
|
||||||
## 16. Testing Architecture (Native Rust E2E)
|
|
||||||
Historically, the project relied on a complex Python testing suite (\pytest\ + \mcp_client.py\) to validate the server over HTTP/SSE. This has been fully deprecated in favor of **Native Rust End-to-End Testing**.
|
|
||||||
* **Unit Tests:** Handlers and business logic are tested directly inside \server/src/handlers.rs\ using native \ okio::test\ constructs.
|
|
||||||
* **E2E Tests:** Integration and full-system tests run via \stub/tests/e2e.rs\ and \win-nvim/tests/integration_test.rs\, ensuring type safety, faster execution, and eliminating Python environment dependencies.
|
|
||||||
## 17. Automated Git Context Binding (git2)
|
|
||||||
Instead of forcing the LLM client to manually run `git rev-parse HEAD` and pass `git_branch` / `git_commit` arguments for every single code change, the server integrates the native **`git2`** C bindings.
|
|
||||||
When engineering endpoints (`log_code_change`, `log_error_fix`, `log_tech_debt`) are invoked, the server asynchronously discovers the surrounding Git repository, peels the HEAD reference, and automatically injects the current commit hash, commit message, and branch name directly into the stored entities and audit logs. This guarantees airtight VCS traceability without wasting LLM tokens or relying on the agent's memory.
|
|
||||||
|
|
||||||
## 18. Upcoming Enhancements
|
|
||||||
|
|
||||||
### A. Graph Summarization & Decay
|
|
||||||
To prevent context window bloat, the Redb engine will enforce TTLs on ephemeral nodes (`StickyNotes`, minor `Tasks`). Furthermore, an `archive_routine` prompt will allow subagents to automatically compress older `SessionSummaries` into dense milestone retrospectives.
|
|
||||||
|
|
||||||
### B. Proactive Subagent Triggers
|
|
||||||
The daemon will eventually gain the ability to autonomously spawn background subagents (like the `BugDiagnostician`) when specific events are detected in the webhook telemetry, rather than relying strictly on the human user to initiate the agent.
|
|
||||||
|
|
||||||
## 11. Advanced Clipboard Capabilities
|
|
||||||
The memory server bypasses typical cross-OS Linux/Windows clipboard restrictions by proxying operations from the WSL stub back to the Windows Axum host.
|
|
||||||
It supports reading and writing rich formats natively to the Windows Host OS using the clipboard-win and rboard crates:
|
|
||||||
* **Plain & Rich Text:** CF_UNICODETEXT and CF_HTML are supported for rich copying/pasting.
|
|
||||||
* **File Drops (CF_HDROP):** The server can parse file lists copied from Windows Explorer, and can inversely synthesize file drops into the clipboard from absolute paths.
|
|
||||||
* **Images (CF_BITMAP):** The server natively rasterizes clipboard bitmaps to JPEG on read, and can write raw RgbaImage buffers back to the clipboard on write.
|
|
||||||
* **Developer Tooling:** read_file_skeleton (AST), get_active_worktree_context (Git), get_recent_logs, toggle_clipboard_watch_mode.
|
|
||||||
|
|
||||||
## 19. High-Performance Concurrency & Resilience Guarantees
|
|
||||||
* **Async Channel Backpressure (`push_async`)**: `Store::modify_async` uses `DbWriteQueue::push_async` with `tx.send(task).await` backpressure to guarantee database write persistence under heavy async write loads without dropping write transactions.
|
|
||||||
* **Atomic Search Index Swaps**: `MemoryState::rebuild_index` constructs and populates a new `MemoryIndex` instance in isolation before performing an atomic pointer swap (`*self.search_index.write().await = new_idx`), eliminating transient empty search result windows.
|
|
||||||
* **SIMD-Friendly Single-Pass Cosine Similarity**: `cosine_similarity` calculates dot product and Euclidean norm squares in a single linear pass over float vectors, enabling SIMD compiler auto-vectorization.
|
|
||||||
* **Safe Stream Decoding on Log Tails**: Log tail operations (`get_recent_logs`) read raw bytes and decode using lossy UTF-8 conversion (`String::from_utf8_lossy`) to ensure resilience when seeking across multi-byte UTF-8 boundaries.
|
|
||||||
* **Serde Parameter & Enum Tolerance**: All action enums (`StickyNoteAction`, `SnippetSearchMode`, `Relation`) support case-insensitive variants and field aliases (`source`/`from`, `target`/`to`, `relationType`/`relation_type`) to ensure seamless execution when LLMs pass varied string formatting.
|
|
||||||
+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
|
||||||
+145
-53
@@ -1,7 +1,7 @@
|
|||||||
# Memory MCP Strategic Guidelines
|
# Memory MCP Strategic Guidelines
|
||||||
|
|
||||||
This document outlines the STRATEGY, SEMANTICS, and CASING STANDARDS 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 `tools/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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -19,14 +19,47 @@ To prevent graph fragmentation and ensure seamless LLM context retrieval:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Consolidated Smart Tools Architecture
|
## 2. The Two-Tier Context Paradigm
|
||||||
|
|
||||||
> [!IMPORTANT] 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 (`rules/*.md`, `instructions.md`) and system prompts. This guarantees 0ms turn-0 availability without relying on proactive agent tool retrieval.
|
> * **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.
|
> * **Tier 2 (Telemetry & Ephemeral DB)**: High-frequency operational history—the Code Change Ledger (`audit_ledger`), terminal command history, compiler error fixes (`log_error_fix`), active tasks, and preflight context—is handled by the MCP Memory Server and surfaced via the Brain Monitor Web UI and MCP resources.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
The server consolidates granular single-purpose tools into domain-named smart tools. Always prefer the consolidated tools over legacy aliases:
|
## 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.
|
* **`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: "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`).
|
||||||
@@ -53,7 +86,8 @@ The server consolidates granular single-purpose tools into domain-named smart to
|
|||||||
- `action: "tag"`: Attach classification tags (requires `id`, `tags: Vec<String>`).
|
- `action: "tag"`: Attach classification tags (requires `id`, `tags: Vec<String>`).
|
||||||
|
|
||||||
* **`decisions`**: Architectural Decision Records (ADRs).
|
* **`decisions`**: Architectural Decision Records (ADRs).
|
||||||
- `action: "log"`: Log ADR (requires `title`, optional `status: "accepted" | "proposed" | "deprecated" | "superseded"`, `context`, `decision`, `consequence`, `author`, `affected_components: Vec<String>`, `alternatives_considered: Vec<String>`, `supersedes`, `repo_name`).
|
- `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: "query"`: Query ADRs (optional `query`).
|
||||||
- `action: "delete"`: Delete ADR (requires `id`).
|
- `action: "delete"`: Delete ADR (requires `id`).
|
||||||
|
|
||||||
@@ -88,89 +122,155 @@ The server consolidates granular single-purpose tools into domain-named smart to
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. VCS & SVN Agnosticism & Multi-Repo Provenance
|
## 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):
|
To support diverse enterprise repositories (Git, Subversion / SVN, Mercurial / Hg, Monorepos):
|
||||||
* **`vcs_type`**: Designates the VCS engine (`"git"`, `"svn"`, `"hg"`, `"perforce"`, or `"none"`).
|
* **`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"`).
|
* **`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`).
|
* **`upstream_url`**: Canonical remote repository URL (e.g. `https://svn.corp/repo/trunk`, `git@bitbucket.org:org/repo.git`).
|
||||||
* **`repo_name`**: Logical project or repository identifier allowing multiple repositories to share or partition memory namespaces cleanly without collision.
|
* **`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>`.
|
* **Audit Ledger (`log_code_change`)**: Enriched with `vcs_type`, `vcs_revision`, `upstream_url`, `author`, `diff_summary`, and extensible `metadata: HashMap<String, String>`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Terminal & Process Telemetry
|
## 8. Terminal & Process Telemetry
|
||||||
|
|
||||||
The server ingests and tracks active terminal commands and sessions:
|
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.
|
* **Active Terminals**: Tracks PIDs, shell interpreters (`pwsh`, `bash`, `nu`, `zsh`), current working directories (`cwd`), command exit codes, and timestamps.
|
||||||
* **Terminal History Endpoint**: `/terminal/history` exposes recent shell commands and output streams to dashboard and LLMs to prevent lost shell context.
|
* **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.
|
||||||
* **Quality Gate Enforcement**: `GateRecord` captures pre-flight and pre-push validation passes with `gate_type`, `enforcer`, `status`, `validation_log`, and `repo_name`.
|
* **Zero-Latency UDP Streams**: Terminal and IDE telemetry stream over UDP (`MCP_UDP_PORT1`, `MCP_UDP_PORT2`) with zero disk I/O bottlenecks.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. High-Signal Tool Responses & Performance Guidelines
|
## 9. Automated Error Fix Auto-Matcher
|
||||||
|
|
||||||
To optimize context usage, response times, and LLM reasoning efficiency:
|
|
||||||
|
|
||||||
* **High-Signal Feedback**:
|
|
||||||
- MCP tool calls (such as `create_entities`, `create_relations`, `add_observations`, `pin_file`) return explicit, structured summaries containing created names, types, edge counts, and relation paths.
|
|
||||||
- LLMs do NOT need to execute follow-up `open_nodes` calls merely to confirm successful creation.
|
|
||||||
|
|
||||||
* **Batch Operations**:
|
|
||||||
- When creating or updating multiple entities, snippets, or observations, always batch items into a single tool call array (e.g. `create_entities` with multiple array items) rather than making separate calls.
|
|
||||||
- The server handles batch store mutations serially in a single transaction pass with single-permit event-driven flushes.
|
|
||||||
|
|
||||||
* **Real-time Tantivy Search Indexing**:
|
|
||||||
- The Tantivy search engine automatically checks pending commits and reloads search readers prior to executing `omni_search` or `search_nodes`. Search queries always return up-to-date document results immediately following mutations.
|
|
||||||
- Single-item deletions use targeted document removal rather than global index wipes.
|
|
||||||
|
|
||||||
* **Real-Time AST & Workspace Source Code Symbol Scanning**:
|
|
||||||
- `find_symbol_references`, `get_callers`, and `analyze_impact` scan both stored code snippets and physical workspace source code files on disk (`.rs`, `.ts`, `.py`, `.go`, `.java`, `.c`, `.cpp`), providing accurate AST symbol references and call site tracking.
|
|
||||||
|
|
||||||
* **Graph Entity Merge & Self-Loop Protection**:
|
|
||||||
- `merge_entities` re-points all relations from `source_entity` to `target_entity` and automatically prunes cyclic self-loops (`target -> target`).
|
|
||||||
|
|
||||||
* **Safe UTF-8 Token Truncation**:
|
|
||||||
- Large responses (e.g. `get_active_worktree_context`, `read_graph`, `summarize_subgraph`) are safely truncated along UTF-8 character boundaries (`floor_char_boundary`), ensuring response bounds without runtime panics.
|
|
||||||
|
|
||||||
## 4. Automated Error Fix Auto-Matcher
|
|
||||||
- **Tools:** `log_error_fix`, `search_error_fixes` (and alias `suggest_error_fix`)
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Memory State Checkpointing & Rollbacks
|
## 10. Memory State Checkpointing & Rollbacks
|
||||||
- **Tool:** `checkpoint_state`, `restore_state` (or `create_snapshot`, `restore_snapshot`)
|
- **Tool:** `manage_checkpoint` (action: `"create"` | `"restore"`)
|
||||||
- **When to use:** Before initiating a large refactor, running experimental subagent tasks, or executing destructive batch operations.
|
- **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.
|
- **Behavior:** Saves or restores a point-in-time snapshot of graph entities, active tasks, and tech debt backlogs.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Self-Healing Graph Health Sweeper
|
## 11. Self-Healing Graph Health Sweeper
|
||||||
- **Tool:** `sweep_graph_health`
|
- **Tool:** `sweep_graph_health`
|
||||||
- **When to use:** Periodically or before committing major graph changes to audit entity consistency.
|
- **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.
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Causal Lineage & Provenance Tracker
|
## 12. Causal Lineage & Provenance Tracker
|
||||||
- **Tool:** `query_lineage`
|
- **Tool:** `query_lineage`
|
||||||
- **When to use:** When asking *"Why was this component modified?"* or *"What task or ADR led to this code change?"*
|
- **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.
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Native Rust Invariants & Subprocess Prohibition (CRITICAL)
|
## 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:
|
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.
|
* **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.
|
* **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 (e.g. `test_no_subprocess_clipboard_regression`) verify at test time that forbidden subprocess patterns are absent from handler implementations.
|
* **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. High-Performance Concurrency & Resilience Guarantees
|
## 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.
|
* **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.
|
* **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.
|
* **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.
|
||||||
@@ -191,7 +291,6 @@ To maintain maximum security, speed, and cross-platform reliability:
|
|||||||
* **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.
|
* **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())`).
|
* **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.
|
* **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.
|
||||||
* **LLM Tool Schema Ergonomics & Context Guidance**: `ReadGraphHandler` schema explicitly instructs LLMs on `namespace` filtering and `search_nodes` / `get_subgraph` tools for large graph discovery.
|
|
||||||
* **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`.
|
* **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.
|
* **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`).
|
* **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`).
|
||||||
@@ -200,20 +299,13 @@ To maintain maximum security, speed, and cross-platform reliability:
|
|||||||
* **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.
|
* **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.
|
* **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`.
|
* **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`.
|
||||||
* **Sequential Snapshot Lock Scope Flattening**: `GenerateStandupReportHandler` reads `tasks`, `ledger`, and `session_summaries` sequentially rather than nesting read locks, preventing multi-lock deadlocks during concurrent store modifications.
|
|
||||||
* **Directory Tree Depth Safeguard**: `ReadDirectoryArchitectureHandler` caps directory recursion at depth 10 to prevent stack overflow on deep or cyclic directory structures.
|
* **Directory Tree Depth Safeguard**: `ReadDirectoryArchitectureHandler` caps directory recursion at depth 10 to prevent stack overflow on deep or cyclic directory structures.
|
||||||
* **Deterministic Total-Order Score Ranking**: `OmniSearchHandler` uses `f64::total_cmp` for Reciprocal Rank Fusion (RRF) score sorting, guaranteeing deterministic NaN-safe search result ordering.
|
* **Deterministic Total-Order Score Ranking**: `OmniSearchHandler` uses `f64::total_cmp` for Reciprocal Rank Fusion (RRF) score sorting, guaranteeing deterministic NaN-safe search result ordering.
|
||||||
* **RPC Timeout Memory Hygiene**: `nvim-core` maintains request hygiene by removing pending request entries from static RPC maps upon timeout or channel drop, eliminating orphan memory leaks.
|
* **RPC Timeout Memory Hygiene**: `nvim-core` maintains request hygiene by removing pending request entries from static RPC maps upon timeout or channel drop, eliminating orphan memory leaks.
|
||||||
* **Embedding Input Safeguard**: `generate_embedding_async` returns explicit errors for empty/0-length text inputs instead of returning empty vectors, preventing downstream vector dimension mismatches during cosine similarity calculations.
|
|
||||||
* **Path Traversal Security Guards**: `validate_safe_path` enforces path canonicalization and rejects relative parent traversal components (`..`) across file and process log handlers (`process_logs` / `ProcessLogsTool`).
|
* **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.
|
* **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.
|
* **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.
|
* **Two-Phase Graph Condensation**: `condense_graph_worker` uses a 2-phase commit (non-destructive `read_with` -> graph insert -> prune by timestamp/content) to prevent data loss if summarization or graph insertion fails.
|
||||||
* **Store Write Lock Minimization**: `Store::modify` and `Store::modify_async` unblock concurrent readers during JSON serialization by releasing the write lock immediately after mutating memory state.
|
|
||||||
* **Redb Database Lock Retry Backoff**: `init_db` retries transient Redb lock contention with exponential backoff (3 attempts, 150ms delay) before falling back.
|
* **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.
|
* **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.
|
* **Broadcast Watch-Based Shutdown Channels**: Background workers utilize `tokio::sync::watch` for broadcast shutdown notifications without consuming cancellation signals.
|
||||||
* **Consolidated Neovim Tool Suite (v2)**: The Neovim server exposes 7 consolidated domain tools (`nvim_buffer`, `nvim_window`, `nvim_view`, `nvim_diagnostics`, `nvim_visual`, `nvim_execute_lua`, `nvim_system`) with comprehensive action dispatching.
|
|
||||||
* **Fallback Vector Search Parity**: In-memory vector search fallback indexes Knowledge Graph entities, observations, and error fixes when external vector databases are unavailable.
|
|
||||||
|
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ deploy-win: deploy-server deploy-stub-win deploy-nvim-win deploy-rules-win
|
|||||||
# Stop the background server gracefully and verify port release
|
# 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) { $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 }; $stopped = $false; for ($i = 1; $i -le 10; $i++) { $conn = Get-NetTCPConnection -LocalPort {{port}} -State Listen -ErrorAction SilentlyContinue; $proc = Get-Process mcp-memory-server -ErrorAction SilentlyContinue; if (-not $conn -and -not $proc) { $stopped = $true; Write-Host '✅ Server process and port {{port}} fully terminated.' -ForegroundColor Green; break }; Start-Sleep -Seconds 1 }; if (-not $stopped) { Write-Host '⚠️ Server process did not exit within 10s. Force killing process...' -ForegroundColor Yellow; Get-Process mcp-memory-server -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue; Get-NetTCPConnection -LocalPort {{port}} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue }; Start-Sleep -Seconds 2 } }
|
-& { $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 } } }
|
||||||
@Write-Host "✅ Shutdown complete. Port {{port}} and process are free." -ForegroundColor Green
|
@Write-Host "✅ Shutdown complete. Port {{port}} and process are free." -ForegroundColor Green
|
||||||
|
|
||||||
# Start the server in the background and verify /health
|
# Start the server in the background and verify /health
|
||||||
@@ -102,7 +102,7 @@ build-nvim:
|
|||||||
# Stage updated server binary, perform graceful shutdown, and auto-restart (skips if binary is unchanged)
|
# Stage updated server binary, perform graceful shutdown, and auto-restart (skips if binary is unchanged)
|
||||||
deploy-server:
|
deploy-server:
|
||||||
@Write-Host "Deploying Windows Server (Staged Hot-Swap)..." -ForegroundColor Cyan
|
@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"); $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 } }
|
-& { $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 Windows-native stub binary (skips if identical checksum)
|
||||||
deploy-stub-win:
|
deploy-stub-win:
|
||||||
@@ -125,12 +125,12 @@ deploy-wsl: deploy-stub deploy-nvim deploy-rules-wsl
|
|||||||
# Deploy WSL-native stub binary (skips if identical checksum)
|
# Deploy WSL-native stub binary (skips if identical checksum)
|
||||||
deploy-stub:
|
deploy-stub:
|
||||||
@Write-Host "Deploying WSL Stub natively..." -ForegroundColor Cyan
|
@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 "$src" "$dest" && echo -e "\033[32mCopied updated $src to $dest\033[0m"; fi'
|
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 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="/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 "$src" "$dest" && echo -e "\033[32mCopied updated $src to $dest\033[0m"; fi'
|
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 global Agent rules to WSL config directory (skips identical files)
|
||||||
deploy-rules-wsl:
|
deploy-rules-wsl:
|
||||||
@@ -190,3 +190,9 @@ all-nvim-wsl: build-nvim deploy-nvim
|
|||||||
|
|
||||||
# Complete lifecycle: stop, build, deploy, start, and verify WSL server
|
# 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 }
|
||||||
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
rebuild_index: found 229 entities, 24 tasks
|
|
||||||
spawn_blocking started in rebuild_index
|
|
||||||
add_task_sync called for task: 8da665f7-107c-41c8-b203-d39a9ff1f9a5
|
|
||||||
Writer add_document returned id/result
|
|
||||||
Needs_commit set to true in add_task_sync
|
|
||||||
add_task_sync called for task: 01a788f0-7adb-456c-bf37-02bbd7e1d2f8
|
|
||||||
Writer add_document returned id/result
|
|
||||||
Needs_commit set to true in add_task_sync
|
|
||||||
add_task_sync called for task: 5e8d9b26-ae31-4136-a4c4-6ead74887bb5
|
|
||||||
Writer add_document returned id/result
|
|
||||||
Needs_commit set to true in add_task_sync
|
|
||||||
add_task_sync called for task: ed184dd6-55ce-4d37-a06a-b34733795e03
|
|
||||||
Writer add_document returned id/result
|
|
||||||
Needs_commit set to true in add_task_sync
|
|
||||||
add_task_sync called for task: 3cea859d-c1ce-4f6c-bdc5-6eb65dbd8e4f
|
|
||||||
Writer add_document returned id/result
|
|
||||||
Needs_commit set to true in add_task_sync
|
|
||||||
add_task_sync called for task: 62446699-39b3-496b-9af8-99b331fe6c08
|
|
||||||
Writer add_document returned id/result
|
|
||||||
Needs_commit set to true in add_task_sync
|
|
||||||
add_task_sync called for task: 4b0e2179-1e70-4b64-9c2d-ff2fe2d05fdf
|
|
||||||
Writer add_document returned id/result
|
|
||||||
Needs_commit set to true in add_task_sync
|
|
||||||
add_task_sync called for task: 16dd35e4-eadd-4597-a5b6-9844852cfb76
|
|
||||||
Writer add_document returned id/result
|
|
||||||
Needs_commit set to true in add_task_sync
|
|
||||||
add_task_sync called for task: c85f352b-f15b-4f90-a6ae-18fe914212b2
|
|
||||||
Writer add_document returned id/result
|
|
||||||
Needs_commit set to true in add_task_sync
|
|
||||||
add_task_sync called for task: d1e907a2-89a2-4814-8722-22c754cc943c
|
|
||||||
Writer add_document returned id/result
|
|
||||||
Needs_commit set to true in add_task_sync
|
|
||||||
add_task_sync called for task: c382a511-a5d6-4114-ad2c-f09fdd36d66a
|
|
||||||
Writer add_document returned id/result
|
|
||||||
Needs_commit set to true in add_task_sync
|
|
||||||
add_task_sync called for task: 42ca3765-5c69-4cdd-8324-5a174c34d4ae
|
|
||||||
Writer add_document returned id/result
|
|
||||||
Needs_commit set to true in add_task_sync
|
|
||||||
add_task_sync called for task: 05792eaa-4bc5-45e5-b9a0-d9027b3b3974
|
|
||||||
Writer add_document returned id/result
|
|
||||||
Needs_commit set to true in add_task_sync
|
|
||||||
add_task_sync called for task: 15c9fd58-8d8f-4c2f-80f4-15a46ba54588
|
|
||||||
Writer add_document returned id/result
|
|
||||||
Needs_commit set to true in add_task_sync
|
|
||||||
add_task_sync called for task: 9b55e54c-ac1b-4219-acf3-68f7cb34a1fd
|
|
||||||
Writer add_document returned id/result
|
|
||||||
Needs_commit set to true in add_task_sync
|
|
||||||
add_task_sync called for task: a04923ea-6abf-41ba-9c45-f7d9cdac3440
|
|
||||||
Writer add_document returned id/result
|
|
||||||
Needs_commit set to true in add_task_sync
|
|
||||||
add_task_sync called for task: 4233293e-6d93-4c46-b9aa-f9e712af0e86
|
|
||||||
Writer add_document returned id/result
|
|
||||||
Needs_commit set to true in add_task_sync
|
|
||||||
add_task_sync called for task: 93451cd9-25c5-4323-8dce-79bba124242d
|
|
||||||
Writer add_document returned id/result
|
|
||||||
Needs_commit set to true in add_task_sync
|
|
||||||
add_task_sync called for task: 949f1d59-e1e2-421c-9682-43a79f41679b
|
|
||||||
Writer add_document returned id/result
|
|
||||||
Needs_commit set to true in add_task_sync
|
|
||||||
add_task_sync called for task: cd680df7-2e1a-4486-bb9b-3881476c762e
|
|
||||||
Writer add_document returned id/result
|
|
||||||
Needs_commit set to true in add_task_sync
|
|
||||||
add_task_sync called for task: 561daa2b-ddb5-44ae-8fb8-8155405759fb
|
|
||||||
Writer add_document returned id/result
|
|
||||||
Needs_commit set to true in add_task_sync
|
|
||||||
add_task_sync called for task: e36b0eba-aa79-493f-b89b-3da46a44fa11
|
|
||||||
Writer add_document returned id/result
|
|
||||||
Needs_commit set to true in add_task_sync
|
|
||||||
add_task_sync called for task: a3a717b3-438a-42a7-83ec-2f2eb5d775b4
|
|
||||||
Writer add_document returned id/result
|
|
||||||
Needs_commit set to true in add_task_sync
|
|
||||||
add_task_sync called for task: 6a1b05d5-0ffd-4b0c-bb65-f45e0cb5540a
|
|
||||||
Writer add_document returned id/result
|
|
||||||
Needs_commit set to true in add_task_sync
|
|
||||||
@@ -23,7 +23,7 @@ pub async fn read_mcp_message<R: tokio::io::AsyncRead + Unpin>(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if trimmed_line.as_bytes().len() >= 15
|
if trimmed_line.len() >= 15
|
||||||
&& trimmed_line.as_bytes()[..15].eq_ignore_ascii_case(b"content-length:")
|
&& trimmed_line.as_bytes()[..15].eq_ignore_ascii_case(b"content-length:")
|
||||||
{
|
{
|
||||||
length = trimmed_line[15..].trim().parse().unwrap_or(0);
|
length = trimmed_line[15..].trim().parse().unwrap_or(0);
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
# MCP Tools Review & Enhancement Strategy
|
|
||||||
|
|
||||||
## Part 1: Current Arsenal Review
|
|
||||||
|
|
||||||
Our current MCP ecosystem is highly advanced, utilizing a **Dual-Transport Leader/Stub Architecture** (Windows Host + WSL Proxy) to completely eliminate cross-OS I/O latency.
|
|
||||||
|
|
||||||
### 1. Context & Token Optimization
|
|
||||||
* `read_file_skeleton`: Highly effective. Uses tree-sitter to extract ASTs (Rust, Python, TS). **Score: A+ (Massive token savings)**
|
|
||||||
* `get_active_worktree_context`: Native git2 integration. Bypasses shell parsing for clean JSON diffs. **Score: A**
|
|
||||||
* `process_logs`: Direct file seeking and daemon log management (`watch`, `get`, `clear`). Prevents LLMs from reading multi-megabyte log files. **Score: A**
|
|
||||||
|
|
||||||
### 2. Neovim IDE Integration (nvim-core)
|
|
||||||
* `nvim_buffer`, `nvim_window`, `nvim_view`, `nvim_diagnostics`, `nvim_visual`, `nvim_execute_lua`, `nvim_system`.
|
|
||||||
* **Review:** Exceptional human QoL. The agent interacts with the code where the human's eyes actually are. Ghost text and diagnostic extmarks provide an IDE-like experience usually reserved for closed-source tools like Cursor. **Score: S-Tier**
|
|
||||||
|
|
||||||
### 3. Clipboard & Workflow
|
|
||||||
* `clipboard` (`read`, `write`).
|
|
||||||
* **Review:** Native cross-OS clipboard-win and arboard implementation with on-demand image grab. Bridges the gap between manual human research and the agent's context. **Score: A**
|
|
||||||
|
|
||||||
### 4. Graph & Memory Management
|
|
||||||
* `create_entities`, `hypotheses`, `agent_signals`, `handoff_routine`.
|
|
||||||
* **Review:** Solid foundation for state persistence across branches and days. **Score: A**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 2: Proposed Enhancements (Focus: T2R, Token Cost, QoL)
|
|
||||||
|
|
||||||
To push the system to the absolute bleeding edge of autonomous coding, I propose the following 5 new tools/enhancements.
|
|
||||||
|
|
||||||
### 1. `replace_ast_node` (Robust Structural Editing)
|
|
||||||
* **The Problem:** Standard text replacement uses exact string matching and line numbers. Line numbers change when humans edit simultaneously, and string matching fails on whitespace/indentation.
|
|
||||||
* **The Solution:** An MCP tool that takes `(file_path, node_type, node_name, new_content)`. It uses tree-sitter to find the exact boundary of `fn execute(...)` and replaces just that AST node.
|
|
||||||
* **Impact:** Zero LLM syntax/indentation errors. 100% robust edits. Drastically lowers Time-to-Resolve (T2R) by eliminating failed edit loops.
|
|
||||||
|
|
||||||
### 2. `semantic_code_search` (Local Vector Embeddings)
|
|
||||||
* **The Problem:** Text search relies on exact regex. If the LLM guesses the wrong variable name, it wastes tokens searching and reading the wrong files.
|
|
||||||
* **The Solution:** Using Tantivy and BERT embeddings in our backend. We index the AST blocks of the codebase in the background. The LLM can query *"Where is the auth token validated?"* and get the exact 3 relevant functions instantly.
|
|
||||||
* **Impact:** Massive token cost reduction (no blind file reading). Instant T2R for codebase exploration.
|
|
||||||
|
|
||||||
### 3. `nvim_system` terminal execution (Interactive Execution QoL)
|
|
||||||
* **The Problem:** When the agent runs a background terminal command (`cargo build`, `npm run dev`), the output is hidden from the human, and interactive prompts cause the background task to hang indefinitely.
|
|
||||||
* **The Solution:** Dispatch to Neovim terminal splits where the human can watch the tests run natively, interact with prompts, see ANSI colors, and interact seamlessly.
|
|
||||||
* **Impact:** Massive Human QoL.
|
|
||||||
|
|
||||||
### 4. `read_directory_architecture` (Bird's-Eye View)
|
|
||||||
* **The Problem:** Single file inspection works for one file. When entering a new repository, the LLM usually runs `ls -R` and then has to guess what files do based on their names.
|
|
||||||
* **The Solution:** A tool that scans a directory structure and returns a clean hierarchical tree alongside summaries of what each directory and key file is responsible for.
|
|
||||||
* **Impact:** Immediate holistic context. Eliminates the "exploration phase" token tax.
|
|
||||||
|
|
||||||
### 5. `query_database_schema` (Introspection)
|
|
||||||
* **The Problem:** Working with databases usually involves the LLM writing clunky scripts to view table definitions, which often fail due to missing env vars or wrong dialects.
|
|
||||||
* **The Solution:** A direct MCP tool that parses the local `.env`, connects to the database (PostgreSQL), and returns a clean Markdown representation of the schema (Tables, Columns, Types, Foreign Keys).
|
|
||||||
* **Impact:** Prevents hallucinations about database structure. Fixes DB-related bugs significantly faster (T2R).
|
|
||||||
@@ -17,3 +17,6 @@ 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"
|
||||||
|
|
||||||
+20
-3
@@ -1,22 +1,39 @@
|
|||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let git_hash = Command::new("git")
|
let mut git_hash = Command::new("git")
|
||||||
.args(["rev-parse", "--short", "HEAD"])
|
.args(["rev-parse", "--short", "HEAD"])
|
||||||
.output()
|
.output()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|out| String::from_utf8(out.stdout).ok())
|
.and_then(|out| String::from_utf8(out.stdout).ok())
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
.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")
|
let git_date = Command::new("git")
|
||||||
.args(["log", "-1", "--format=%cd", "--date=format:%y.%m.%d.%H%M%S"])
|
.args(["log", "-1", "--format=%cd", "--date=format:%y.%m.%d.%H%M%S"])
|
||||||
.output()
|
.output()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|out| String::from_utf8(out.stdout).ok())
|
.and_then(|out| String::from_utf8(out.stdout).ok())
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
.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!("{} ({})", git_date.trim(), git_hash.trim());
|
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");
|
||||||
}
|
}
|
||||||
@@ -2,48 +2,22 @@
|
|||||||
|
|
||||||
When connected to this Neovim MCP server (`win-nvim`), you have powerful tools to interact directly with the active Neovim editor.
|
When connected to this Neovim MCP server (`win-nvim`), you have powerful tools to interact directly with the active Neovim editor.
|
||||||
|
|
||||||
## The Consolidated Tool Arsenal (v2)
|
## The Consolidated Tool Arsenal (v3)
|
||||||
The server consolidates granular Neovim operations into 7 smart domain tools:
|
The server consolidates granular Neovim operations into 5 smart mega-tools:
|
||||||
- **`nvim_buffer`**: Buffer and file management.
|
- **`nvim_buffer`**: Buffer and file management. Actions: `read`, `replace`, `save`, `undo`, `redo`, `create_scratch`.
|
||||||
- `action: "open_file"`: Open file in buffer (args: `file`, `line`, `col`).
|
- **`nvim_workspace`**: Window split and focus management. Actions: `list_buffers`, `list_windows`, `focus`, `split`, `cwd`.
|
||||||
- `action: "open"`: Open buffer (args: `bufnr`).
|
- **`nvim_intelligence`**: Code intelligence and LSP. Actions: `hover`, `definition`, `references`, `outline`, `query`, `diagnostics`, `rename`, `code_action`.
|
||||||
- `action: "close"`: Close buffer (args: `bufnr`, `force`).
|
- **`nvim_ui`**: Visual highlighting, diff previews, and ghost text. Actions: `highlight`, `ghost_text`, `clear`.
|
||||||
- `action: "reload"`: Reload buffer from disk (args: `bufnr`).
|
- **`nvim_exec`**: Escape hatch for raw evaluation. Actions: `lua`, `vimscript`, `terminal`.
|
||||||
- `action: "save"`: Save buffer to disk (args: `bufnr`).
|
|
||||||
- `action: "list"`: List all loaded buffers.
|
|
||||||
- **`nvim_window`**: Window split and focus management.
|
|
||||||
- `action: "split"`: Split window (args: `direction: "horizontal" | "vertical"`, `file`).
|
|
||||||
- `action: "close"`: Close window (args: `winnr`).
|
|
||||||
- `action: "list"`: List open windows.
|
|
||||||
- `action: "get_active"`: Get active window details.
|
|
||||||
- `action: "set_active"`: Set active window focus (args: `winnr`).
|
|
||||||
- **`nvim_view`**: Editor viewport and navigation.
|
|
||||||
- `action: "get_active_buffer"`: Get active buffer details.
|
|
||||||
- `action: "get_cursor"`: Get current cursor line/col.
|
|
||||||
- `action: "goto_line"`: Jump cursor to line (args: `line`, `col`).
|
|
||||||
- `action: "get_viewport"`: Get visible line range in viewport.
|
|
||||||
- `action: "get_messages"`: Get Neovim command-line messages.
|
|
||||||
- **`nvim_diagnostics`**: LSP diagnostics querying and publishing.
|
|
||||||
- `action: "get"`: Get diagnostics (args: `bufnr`, `severity`).
|
|
||||||
- `action: "set"`: Set buffer diagnostics (args: `bufnr`, `diagnostics`).
|
|
||||||
- **`nvim_visual`**: Visual highlighting, extmarks, and quickfix.
|
|
||||||
- `action: "get_selection"`: Get current visual selection text and range.
|
|
||||||
- `action: "highlight_lines"`: Highlight line ranges (args: `bufnr`, `hl_group`, `start_line`, `end_line`).
|
|
||||||
- `action: "set_extmark"`: Place virtual text or sign extmarks (args: `bufnr`, `ns_id`, `line`, `col`, `opts`).
|
|
||||||
- `action: "set_quickfix"`: Populate quickfix list (args: `items`, `title`).
|
|
||||||
- **`nvim_execute_lua`**: God Mode arbitrary Lua evaluation.
|
|
||||||
- Arguments: `code: String`.
|
|
||||||
- **`nvim_system`**: System diagnostics and connection heartbeat.
|
|
||||||
- `action: "ping"`: Heartbeat test.
|
|
||||||
- `action: "status"`: Server and socket bridge health status.
|
|
||||||
|
|
||||||
## 1. Using Consolidated Domain Tools First
|
## 1. Using Consolidated Domain Tools First
|
||||||
Always prefer the specific consolidated tools (like `nvim_buffer`, `nvim_window`, `nvim_visual`, etc.) over writing raw Lua scripts. These tools are strongly typed, tested, and safe.
|
Always prefer the specific consolidated tools (like `nvim_buffer`, `nvim_workspace`, `nvim_ui`, etc.) over writing raw Lua scripts. These tools are strongly typed, tested, and safe.
|
||||||
|
|
||||||
## 2. Lua God Mode (`nvim_execute_lua`)
|
## 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, custom 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 prompt functions or interactive confirmation flags in 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.
|
||||||
@@ -57,4 +31,3 @@ While the headless background instance is great for autonomous, routine tasks, i
|
|||||||
|
|
||||||
## 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).
|
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).
|
||||||
|
|
||||||
+736
-767
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
|
|
||||||
+18
-1
@@ -48,6 +48,10 @@ ignore = "0.4.33"
|
|||||||
notify = "7"
|
notify = "7"
|
||||||
blake3 = "1.8"
|
blake3 = "1.8"
|
||||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
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"
|
||||||
@@ -58,4 +62,17 @@ tempfile = "3.27.0"
|
|||||||
|
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
windows-sys = { version = "0.59.0", features = ["Win32_System_DataExchange", "Win32_System_Memory"] }
|
windows-sys = { version = "0.59.0", features = [
|
||||||
|
"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();
|
||||||
|
|||||||
+102
-19
@@ -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,33 +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(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
|
}
|
||||||
tracing::warn!("Event bus receiver lagged by {} messages; continuing wait.", skipped);
|
};
|
||||||
continue;
|
|
||||||
}
|
match timeout_duration {
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
Some(dur) => match tokio::time::timeout(dur, wait_future).await {
|
||||||
return axum::Json(GenericEvent {
|
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 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(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,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::*;
|
||||||
|
|||||||
+27
-1
@@ -36,6 +36,10 @@ 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.env.gates.modify(|gates| {
|
app_state.handler.state.env.gates.modify(|gates| {
|
||||||
@@ -44,6 +48,7 @@ pub async fn gate_verify_handler(
|
|||||||
&& 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 {
|
||||||
@@ -99,9 +104,30 @@ pub async fn gate_set_handler(
|
|||||||
};
|
};
|
||||||
app_state.handler.state.env.gates.modify(|gates| {
|
app_state.handler.state.env.gates.modify(|gates| {
|
||||||
gates.retain(|g| !(g.action == record.action && g.target == record.target));
|
gates.retain(|g| !(g.action == record.action && g.target == record.target));
|
||||||
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."))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,293 +0,0 @@
|
|||||||
if let Some(idx) = gates.iter().position(|g| {
|
|
||||||
g.action == q.action
|
|
||||||
&& g.target == q.target
|
|
||||||
&& g.namespace == q.namespace
|
|
||||||
&& g.params == q.params
|
|
||||||
}) {
|
|
||||||
found = Some(gates[idx].clone());
|
|
||||||
if q.consume {
|
|
||||||
to_remove = Some(idx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(idx) = to_remove {
|
|
||||||
gates.remove(idx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
match found {
|
|
||||||
Some(record) => {
|
|
||||||
if record.status == "authorized" {
|
|
||||||
(axum::http::StatusCode::OK, "Authorized").into_response()
|
|
||||||
} else {
|
|
||||||
let msg = if let Some(r) = record.reason {
|
|
||||||
format!("Action blocked. Reason: {}", r)
|
|
||||||
} else {
|
|
||||||
"Action blocked.".to_string()
|
|
||||||
};
|
|
||||||
(axum::http::StatusCode::FORBIDDEN, msg).into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => (
|
|
||||||
axum::http::StatusCode::NOT_FOUND,
|
|
||||||
"Action not yet authorized (no gate record found).",
|
|
||||||
)
|
|
||||||
.into_response(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn gate_set_handler(
|
|
||||||
State(app_state): State<Arc<AppState>>,
|
|
||||||
Json(body): Json<GateSetReq>,
|
|
||||||
) -> axum::response::Response {
|
|
||||||
let status = if body.block.unwrap_or(false) {
|
|
||||||
"blocked".to_string()
|
|
||||||
} else if body.authorize.unwrap_or(false) {
|
|
||||||
"authorized".to_string()
|
|
||||||
} else {
|
|
||||||
"pending".to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
let record = GateRecord {
|
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
|
||||||
action: body.action.clone(),
|
|
||||||
target: body.target.clone(),
|
|
||||||
namespace: body.namespace.clone(),
|
|
||||||
params: body.params.clone(),
|
|
||||||
status,
|
|
||||||
reason: body.reason.clone(),
|
|
||||||
timestamp: crate::handlers::utils::now_secs(),
|
|
||||||
};
|
|
||||||
app_state.handler.state.gates.modify(|gates| {
|
|
||||||
gates.retain(|g| !(g.action == record.action && g.target == record.target));
|
|
||||||
gates.push(record);
|
|
||||||
});
|
|
||||||
(axum::http::StatusCode::OK, "Gate state updated.").into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
state.rebuild_index().await;
|
|
||||||
tokio::spawn(index_committer_worker(Arc::clone(&state)));
|
|
||||||
let app_state = Arc::new(AppState {
|
|
||||||
handler: Arc::new(MemoryHandler::new(Arc::clone(&state))),
|
|
||||||
clients: RwLock::new(HashMap::new()),
|
|
||||||
next_id: AtomicUsize::new(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
let app_state_clone = Arc::clone(&app_state);
|
|
||||||
let mut rx = state.activity_tx.subscribe();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
while let Ok(msg) = rx.recv().await {
|
|
||||||
let senders: Vec<_> = app_state_clone
|
|
||||||
.clients
|
|
||||||
.read()
|
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
|
||||||
.values()
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
for client_tx in senders {
|
|
||||||
let _ = client_tx.try_send(msg.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let app = Router::new()
|
|
||||||
.route(
|
|
||||||
"/api/version",
|
|
||||||
get(|| async move {
|
|
||||||
axum::Json(serde_json::json!({
|
|
||||||
"version": env!("APP_VERSION"),
|
|
||||||
"git_hash": option_env!("GIT_HASH").unwrap_or("unknown")
|
|
||||||
}))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route("/ws", get(ws_handler))
|
|
||||||
.route("/health", get(health_handler))
|
|
||||||
.route("/nvim/telemetry", post(nvim_telemetry_handler))
|
|
||||||
.route("/gate/verify", get(gate_verify_handler))
|
|
||||||
.route("/gate/set", post(gate_set_handler))
|
|
||||||
.route(
|
|
||||||
"/shutdown",
|
|
||||||
post(
|
|
||||||
|headers: axum::http::HeaderMap, State(state): State<Arc<AppState>>| async move {
|
|
||||||
let token_path = state.handler.state.base_dir.join("admin.token");
|
|
||||||
let expected_token = tokio::fs::read_to_string(&token_path)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let auth_header = headers
|
|
||||||
.get(axum::http::header::AUTHORIZATION)
|
|
||||||
.and_then(|h| h.to_str().ok())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
if expected_token.is_empty() || auth_header != format!("Bearer {}", expected_token) {
|
|
||||||
return (axum::http::StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
std::thread::spawn(|| {
|
|
||||||
tracing::info!(
|
|
||||||
"Received shutdown request via /shutdown endpoint. Exiting process cleanly."
|
|
||||||
);
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
||||||
std::process::exit(0);
|
|
||||||
});
|
|
||||||
(axum::http::StatusCode::OK, "Shutting down...").into_response()
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/",
|
|
||||||
get(|| async move { axum::response::Html(include_str!("dashboard.html")) }),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/graph",
|
|
||||||
get({
|
|
||||||
let state_clone = app_state.handler.state.clone();
|
|
||||||
move || async move {
|
|
||||||
let graph_json = state_clone.read_graph(|g| serde_json::to_string(g).unwrap_or_else(|_| "{}".to_string()));
|
|
||||||
([(axum::http::header::CONTENT_TYPE, "application/json")], graph_json)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/tasks/{id}/complete",
|
|
||||||
post({
|
|
||||||
let state_clone = app_state.handler.state.clone();
|
|
||||||
move |axum::extract::Path(id): axum::extract::Path<String>| async move {
|
|
||||||
state_clone.tasks.modify(|tasks| {
|
|
||||||
for t in tasks.iter_mut() {
|
|
||||||
if t.id == id {
|
|
||||||
t.status = "completed".to_string();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
axum::Json(serde_json::json!({"status": "success"}))
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/tasks",
|
|
||||||
get({
|
|
||||||
let state_clone = app_state.handler.state.clone();
|
|
||||||
move || async move {
|
|
||||||
let tasks_json = state_clone.tasks.read_with(|t| serde_json::to_string(t).unwrap_or_else(|_| "[]".to_string()));
|
|
||||||
([(axum::http::header::CONTENT_TYPE, "application/json")], tasks_json)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/sticky",
|
|
||||||
get({
|
|
||||||
let state_clone = app_state.handler.state.clone();
|
|
||||||
move || async move {
|
|
||||||
let sticky_json = state_clone.sticky.read_with(|s| serde_json::to_string(s).unwrap_or_else(|_| "[]".to_string()));
|
|
||||||
([(axum::http::header::CONTENT_TYPE, "application/json")], sticky_json)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/search",
|
|
||||||
get({
|
|
||||||
let state_clone = app_state.handler.state.clone();
|
|
||||||
move |axum::extract::Query(params): axum::extract::Query<
|
|
||||||
std::collections::HashMap<String, String>,
|
|
||||||
>| async move {
|
|
||||||
if let Some(q) = params.get("q")
|
|
||||||
&& let Ok(idx) = state_clone.search_index.read()
|
|
||||||
&& let Ok(results) = idx.search(q, None) {
|
|
||||||
let mut formatted_results = Vec::new();
|
|
||||||
for (id, doc_type, title, body, score) in results {
|
|
||||||
formatted_results.push(serde_json::json!({
|
|
||||||
"id": id,
|
|
||||||
"type_name": doc_type,
|
|
||||||
"title": title,
|
|
||||||
"content": body,
|
|
||||||
"score": score
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return axum::Json(
|
|
||||||
serde_json::json!({ "results": formatted_results }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
axum::Json(serde_json::json!({ "results": [] }))
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/activity",
|
|
||||||
get({
|
|
||||||
let state_clone = app_state.handler.state.clone();
|
|
||||||
move || async move {
|
|
||||||
let activities_json = state_clone.recent_activities.read_with(|a| serde_json::to_string(a).unwrap_or_else(|_| "[]".to_string()));
|
|
||||||
([(axum::http::header::CONTENT_TYPE, "application/json")], activities_json)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/stats",
|
|
||||||
get({
|
|
||||||
let state_clone = app_state.handler.state.clone();
|
|
||||||
move || async move {
|
|
||||||
let (entities, relations) = state_clone.read_graph(|g| (g.entities.len(), g.relations.len()));
|
|
||||||
let tasks = state_clone.tasks.read_with(|items| items.len());
|
|
||||||
let snippets = state_clone.snippets.read_with(|items| items.len());
|
|
||||||
let tech_debts = state_clone.tech_debts.read_with(|items| items.len());
|
|
||||||
let adrs = state_clone.adrs.read_with(|items| items.len());
|
|
||||||
|
|
||||||
let ledger = state_clone.ledger.read_with(|items| items.len());
|
|
||||||
let error_fixes = state_clone.error_fixes.read_with(|items| items.len());
|
|
||||||
let session_summaries = state_clone.session_summaries.read_with(|items| items.len());
|
|
||||||
let handoff_memos = state_clone.handoff_memos.read_with(|items| items.len());
|
|
||||||
let env_fingerprints = state_clone.env_fingerprints.read_with(|items| items.len());
|
|
||||||
let env_requirements = state_clone.env_requirements.read_with(|items| items.len());
|
|
||||||
let milestones = state_clone.milestones.read_with(|items| items.len());
|
|
||||||
let environments = state_clone.environments.read_with(|items| items.len());
|
|
||||||
let gates = state_clone.gates.read_with(|items| items.len());
|
|
||||||
|
|
||||||
axum::Json(serde_json::json!({
|
|
||||||
"entities": entities,
|
|
||||||
"relations": relations,
|
|
||||||
"tasks": tasks,
|
|
||||||
"snippets": snippets,
|
|
||||||
"tech_debts": tech_debts,
|
|
||||||
"adrs": adrs,
|
|
||||||
"ledger": ledger,
|
|
||||||
"error_fixes": error_fixes,
|
|
||||||
"session_summaries": session_summaries,
|
|
||||||
"handoff_memos": handoff_memos,
|
|
||||||
"env_fingerprints": env_fingerprints,
|
|
||||||
"env_requirements": env_requirements,
|
|
||||||
"milestones": milestones,
|
|
||||||
"environments": environments,
|
|
||||||
"gates": gates
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.with_state(app_state);
|
|
||||||
|
|
||||||
tracing::info!("MCP Memory Server running on http://127.0.0.1:3000/sse");
|
|
||||||
let addr = std::env::var("MCP_PORT").unwrap_or_else(|_| "3000".to_string());
|
|
||||||
let addr: std::net::SocketAddr = format!("127.0.0.1:{}", addr)
|
|
||||||
.parse()
|
|
||||||
.expect("Invalid bind address");
|
|
||||||
|
|
||||||
let listener = match tokio::net::TcpListener::bind(&addr).await {
|
|
||||||
Ok(l) => l,
|
|
||||||
Err(e) => {
|
|
||||||
let log_path = dirs::home_dir()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.join(".gemini/mcp_memory/daemon_error.log");
|
|
||||||
let _ =
|
|
||||||
tokio::fs::write(&log_path, format!("Failed to bind to {}: {}\n", addr, e)).await;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Err(e) = axum::serve(listener, app.into_make_service()).await {
|
|
||||||
let log_path = dirs::home_dir()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.join(".gemini/mcp_memory/daemon_error.log");
|
|
||||||
let _ = tokio::fs::write(&log_path, format!("Server crashed: {}\n", e)).await;
|
|
||||||
}
|
|
||||||
+358
-20
@@ -6,7 +6,7 @@ use axum::{
|
|||||||
Router,
|
Router,
|
||||||
extract::State,
|
extract::State,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::{get, post},
|
routing::{delete, get, post},
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
@@ -63,10 +63,13 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.route("/nvim/telemetry", post(nvim_telemetry_handler))
|
.route("/nvim/telemetry", post(nvim_telemetry_handler))
|
||||||
|
.route("/api/nvim/projection", get(crate::api::telemetry::nvim_knowledge_projection_handler))
|
||||||
.route("/api/terminal/history", get(crate::api::telemetry::get_terminal_history_handler))
|
.route("/api/terminal/history", get(crate::api::telemetry::get_terminal_history_handler))
|
||||||
.route("/terminal/telemetry", post(crate::api::telemetry::terminal_telemetry_handler))
|
.route("/terminal/telemetry", post(crate::api::telemetry::terminal_telemetry_handler))
|
||||||
.route("/events/wait", get(crate::api::events::wait_for_event_handler))
|
.route("/events/wait", get(crate::api::events::wait_for_event_handler))
|
||||||
.route("/events", post(crate::api::events::post_event_handler))
|
.route("/events", post(crate::api::events::post_event_handler))
|
||||||
|
.route("/api/events/stream", get(crate::api::events::sse_events_handler))
|
||||||
|
.route("/events/stream", get(crate::api::events::sse_events_handler))
|
||||||
.route(
|
.route(
|
||||||
"/api/activity",
|
"/api/activity",
|
||||||
get({
|
get({
|
||||||
@@ -120,20 +123,8 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Received shutdown request via /shutdown endpoint. Creating session checkpoint and initiating graceful shutdown."
|
"Received shutdown request via /shutdown endpoint. Initiating graceful shutdown."
|
||||||
);
|
);
|
||||||
let mem_state = state.handler.state.clone();
|
|
||||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
|
|
||||||
let memo = crate::models::HandoffMemo {
|
|
||||||
id: format!("chk_shutdown_{}", now),
|
|
||||||
author: "ShutdownHook".to_string(),
|
|
||||||
content: format!("Automatic session checkpoint triggered on graceful server shutdown at epoch {}", now),
|
|
||||||
expires_at: None,
|
|
||||||
namespace: "global".to_string(),
|
|
||||||
timestamp: now,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
mem_state.telemetry.handoff_memos.modify(|m| m.push(memo));
|
|
||||||
|
|
||||||
if let Some(tx) = state.shutdown_tx.lock().unwrap().take() {
|
if let Some(tx) = state.shutdown_tx.lock().unwrap().take() {
|
||||||
let _ = tx.send(());
|
let _ = tx.send(());
|
||||||
@@ -151,8 +142,7 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
|
|||||||
"app_version": env!("APP_VERSION"),
|
"app_version": env!("APP_VERSION"),
|
||||||
"cargo_pkg_version": env!("CARGO_PKG_VERSION"),
|
"cargo_pkg_version": env!("CARGO_PKG_VERSION"),
|
||||||
"target_os": std::env::consts::OS,
|
"target_os": std::env::consts::OS,
|
||||||
"target_arch": std::env::consts::ARCH,
|
"target_arch": std::env::consts::ARCH
|
||||||
"shutdown_checkpoint": format!("chk_shutdown_{}", now)
|
|
||||||
}
|
}
|
||||||
})).into_response()
|
})).into_response()
|
||||||
},
|
},
|
||||||
@@ -187,11 +177,52 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
|
|||||||
for t in tasks.iter_mut() {
|
for t in tasks.iter_mut() {
|
||||||
if t.id == id {
|
if t.id == id {
|
||||||
t.status = "completed".to_string();
|
t.status = "completed".to_string();
|
||||||
|
t.updated_at = crate::handlers::utils::now_secs();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
axum::Json(serde_json::json!({"status": "success"}))
|
let unblocked = crate::handlers::reconciliation::cascade_unblock_tasks(&state_clone);
|
||||||
|
let milestones = crate::handlers::reconciliation::recalculate_milestones(&state_clone);
|
||||||
|
axum::Json(serde_json::json!({
|
||||||
|
"status": "success",
|
||||||
|
"unblocked_tasks": unblocked,
|
||||||
|
"updated_milestones": milestones
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/git/commit",
|
||||||
|
post({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move |axum::Json(payload): axum::Json<crate::handlers::reconciliation::GitCommitPayload>| async move {
|
||||||
|
let result = crate::handlers::reconciliation::reconcile_commit_or_code_change(
|
||||||
|
&state_clone,
|
||||||
|
&payload.message,
|
||||||
|
payload.files.first().map(|s| s.as_str()),
|
||||||
|
Some(&payload.commit),
|
||||||
|
payload.branch.as_deref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
axum::Json(result)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/reconcile/commit",
|
||||||
|
post({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move |axum::Json(payload): axum::Json<crate::handlers::reconciliation::GitCommitPayload>| async move {
|
||||||
|
let result = crate::handlers::reconciliation::reconcile_commit_or_code_change(
|
||||||
|
&state_clone,
|
||||||
|
&payload.message,
|
||||||
|
payload.files.first().map(|s| s.as_str()),
|
||||||
|
Some(&payload.commit),
|
||||||
|
payload.branch.as_deref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
axum::Json(result)
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -261,7 +292,31 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
|
|||||||
let state_clone = app_state.handler.state.clone();
|
let state_clone = app_state.handler.state.clone();
|
||||||
move || async move {
|
move || async move {
|
||||||
let json = state_clone.code.tech_debts.read_with(|items| serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string()));
|
let json = state_clone.code.tech_debts.read_with(|items| serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string()));
|
||||||
([(axum::http::header::CONTENT_TYPE, "application/json")], json)
|
([(axum::http::header::CONTENT_TYPE, "application/json"), (axum::http::header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")], json)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/tech_debts/{id}/resolve",
|
||||||
|
post({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move |axum::extract::Path(id): axum::extract::Path<String>| async move {
|
||||||
|
let mut found = false;
|
||||||
|
state_clone.code.tech_debts.modify(|debts| {
|
||||||
|
for d in debts.iter_mut() {
|
||||||
|
if d.id == id {
|
||||||
|
d.is_resolved = true;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if found {
|
||||||
|
state_clone.record_activity("tech_debt", &format!("Resolved tech debt: {}", id), Some("resolved"));
|
||||||
|
(axum::http::StatusCode::OK, axum::Json(serde_json::json!({ "status": "success", "message": format!("Tech debt {} resolved", id) })))
|
||||||
|
} else {
|
||||||
|
(axum::http::StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "status": "error", "message": "Tech debt not found" })))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -271,7 +326,7 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
|
|||||||
let state_clone = app_state.handler.state.clone();
|
let state_clone = app_state.handler.state.clone();
|
||||||
move || async move {
|
move || async move {
|
||||||
let json = state_clone.code.adrs.read_with(|items| serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string()));
|
let json = state_clone.code.adrs.read_with(|items| serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string()));
|
||||||
([(axum::http::header::CONTENT_TYPE, "application/json")], json)
|
([(axum::http::header::CONTENT_TYPE, "application/json"), (axum::http::header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")], json)
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -313,6 +368,25 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
|
|||||||
let json = state_clone.project.milestones.read_with(|items| serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string()));
|
let json = state_clone.project.milestones.read_with(|items| serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string()));
|
||||||
([(axum::http::header::CONTENT_TYPE, "application/json")], json)
|
([(axum::http::header::CONTENT_TYPE, "application/json")], json)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.post({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move |axum::Json(payload): axum::Json<serde_json::Value>| async move {
|
||||||
|
use crate::router::McpTool;
|
||||||
|
let handler = crate::handlers::tasks::MilestonesHandler;
|
||||||
|
match handler.execute(payload, state_clone).await {
|
||||||
|
Ok(msg) => (
|
||||||
|
axum::http::StatusCode::OK,
|
||||||
|
axum::Json(serde_json::json!({ "status": "success", "message": msg })),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(e) => (
|
||||||
|
axum::http::StatusCode::BAD_REQUEST,
|
||||||
|
axum::Json(serde_json::json!({ "status": "error", "message": e.to_string() })),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
@@ -335,6 +409,139 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/env_fingerprints",
|
||||||
|
get({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move || async move {
|
||||||
|
let json = state_clone.env.env_fingerprints.read_with(|items| {
|
||||||
|
let values: Vec<_> = items.values().cloned().collect();
|
||||||
|
serde_json::to_string(&values).unwrap_or_else(|_| "[]".to_string())
|
||||||
|
});
|
||||||
|
([(axum::http::header::CONTENT_TYPE, "application/json")], json)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/env_requirements",
|
||||||
|
get({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move || async move {
|
||||||
|
let json = state_clone.env.env_requirements.read_with(|items| {
|
||||||
|
serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string())
|
||||||
|
});
|
||||||
|
([(axum::http::header::CONTENT_TYPE, "application/json")], json)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/environments",
|
||||||
|
get({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move || async move {
|
||||||
|
let json = state_clone.env.environments.read_with(|items| {
|
||||||
|
serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string())
|
||||||
|
});
|
||||||
|
([(axum::http::header::CONTENT_TYPE, "application/json")], json)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/gates",
|
||||||
|
get({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move || async move {
|
||||||
|
let json = state_clone.env.gates.read_with(|items| {
|
||||||
|
serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string())
|
||||||
|
});
|
||||||
|
([(axum::http::header::CONTENT_TYPE, "application/json")], json)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/gates/{id}/authorize",
|
||||||
|
post({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move |axum::extract::Path(id): axum::extract::Path<String>| async move {
|
||||||
|
let mut found = false;
|
||||||
|
state_clone.env.gates.modify(|gates| {
|
||||||
|
for g in gates.iter_mut() {
|
||||||
|
if g.id == id {
|
||||||
|
g.status = "authorized".to_string();
|
||||||
|
g.reason = Some("Manually authorized via dashboard".to_string());
|
||||||
|
g.timestamp = crate::handlers::utils::now_secs();
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if found {
|
||||||
|
state_clone.record_activity("gate", &format!("Authorized push gate: {}", id), Some("authorized"));
|
||||||
|
let _ = state_clone.event_bus_tx.send(crate::state::GenericEvent {
|
||||||
|
topic: "gate:event".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({ "id": id, "status": "authorized", "active_gate": "authorized" }),
|
||||||
|
});
|
||||||
|
(axum::http::StatusCode::OK, axum::Json(serde_json::json!({ "status": "success" })))
|
||||||
|
} else {
|
||||||
|
(axum::http::StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "status": "error", "message": "Gate not found" })))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/gates/{id}/block",
|
||||||
|
post({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move |axum::extract::Path(id): axum::extract::Path<String>| async move {
|
||||||
|
let mut found = false;
|
||||||
|
state_clone.env.gates.modify(|gates| {
|
||||||
|
for g in gates.iter_mut() {
|
||||||
|
if g.id == id {
|
||||||
|
g.status = "blocked".to_string();
|
||||||
|
g.reason = Some("Manually blocked via dashboard".to_string());
|
||||||
|
g.timestamp = crate::handlers::utils::now_secs();
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if found {
|
||||||
|
state_clone.record_activity("gate", &format!("Blocked push gate: {}", id), Some("blocked"));
|
||||||
|
let _ = state_clone.event_bus_tx.send(crate::state::GenericEvent {
|
||||||
|
topic: "gate:event".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({ "id": id, "status": "blocked", "active_gate": "blocked" }),
|
||||||
|
});
|
||||||
|
(axum::http::StatusCode::OK, axum::Json(serde_json::json!({ "status": "success" })))
|
||||||
|
} else {
|
||||||
|
(axum::http::StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "status": "error", "message": "Gate not found" })))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/gates/{id}",
|
||||||
|
delete({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move |axum::extract::Path(id): axum::extract::Path<String>| async move {
|
||||||
|
let mut found = false;
|
||||||
|
state_clone.env.gates.modify(|gates| {
|
||||||
|
let initial_len = gates.len();
|
||||||
|
gates.retain(|g| g.id != id);
|
||||||
|
found = gates.len() < initial_len;
|
||||||
|
});
|
||||||
|
if found {
|
||||||
|
state_clone.record_activity("gate", &format!("Deleted push gate: {}", id), Some("deleted"));
|
||||||
|
let active_gate = state_clone.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()));
|
||||||
|
let _ = state_clone.event_bus_tx.send(crate::state::GenericEvent { topic: "gate:event".to_string(), session_id: None, payload: serde_json::json!({ "id": id, "status": "deleted", "active_gate": active_gate }) });
|
||||||
|
(axum::http::StatusCode::OK, axum::Json(serde_json::json!({ "status": "success" })))
|
||||||
|
} else {
|
||||||
|
(axum::http::StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "status": "error", "message": "Gate not found" })))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/stats",
|
"/api/stats",
|
||||||
get({
|
get({
|
||||||
@@ -904,8 +1111,139 @@ mod tests {
|
|||||||
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let summaries: Vec<crate::models::SessionSummary> = serde_json::from_slice(&body_bytes).unwrap();
|
let summaries: Vec<crate::models::SessionSummary> =
|
||||||
|
serde_json::from_slice(&body_bytes).unwrap();
|
||||||
assert_eq!(summaries.len(), 1);
|
assert_eq!(summaries.len(), 1);
|
||||||
assert_eq!(summaries[0].summary, "Session 2 summary");
|
assert_eq!(summaries[0].summary, "Session 2 summary");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_git_commit_endpoint() {
|
||||||
|
let (app, app_state, _dir) = setup_app().await;
|
||||||
|
|
||||||
|
app_state.handler.state.code.adrs.modify(|adrs| {
|
||||||
|
adrs.push(crate::models::Adr {
|
||||||
|
id: "ADR-0102".to_string(),
|
||||||
|
title: "Neovim Dynamic Projection".to_string(),
|
||||||
|
status: "accepted".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"commit": "git-hash-999",
|
||||||
|
"branch": "master",
|
||||||
|
"message": "feat: implement ADR-0102 and finish architecture",
|
||||||
|
"files": ["nvim-core/src/lib.rs"]
|
||||||
|
});
|
||||||
|
|
||||||
|
let request = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/git/commit")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(Body::from(payload.to_string()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let response = app.oneshot(request).await.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let adr = app_state
|
||||||
|
.handler
|
||||||
|
.state
|
||||||
|
.code
|
||||||
|
.adrs
|
||||||
|
.read_with(|adrs| adrs[0].clone());
|
||||||
|
assert_eq!(adr.status, "implemented");
|
||||||
|
assert_eq!(adr.git_commit.as_deref(), Some("git-hash-999"));
|
||||||
|
assert!(adr.resolved_at.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_environment_endpoints() {
|
||||||
|
let (app, app_state, _dir) = setup_app().await;
|
||||||
|
|
||||||
|
app_state.handler.state.env.environments.modify(|envs| {
|
||||||
|
envs.push(crate::models::EnvironmentDetail {
|
||||||
|
namespace: "default".to_string(),
|
||||||
|
name: "staging-k8s".to_string(),
|
||||||
|
url: "https://staging.internal.net".to_string(),
|
||||||
|
description: "Staging cluster".to_string(),
|
||||||
|
requires_vpn: true,
|
||||||
|
updated_at: 12345,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/api/environments")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let envs: Vec<crate::models::EnvironmentDetail> = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(envs.len(), 1);
|
||||||
|
assert_eq!(envs[0].name, "staging-k8s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_interactive_endpoints() {
|
||||||
|
let (app, app_state, _dir) = setup_app().await;
|
||||||
|
|
||||||
|
// 1. Tech Debt resolve
|
||||||
|
app_state.handler.state.code.tech_debts.modify(|debts| {
|
||||||
|
debts.push(crate::models::TechDebt {
|
||||||
|
id: "TD-001".to_string(),
|
||||||
|
description: "Fix memory leak".to_string(),
|
||||||
|
is_resolved: false,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/tech_debts/TD-001/resolve")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let debt = app_state
|
||||||
|
.handler
|
||||||
|
.state
|
||||||
|
.code
|
||||||
|
.tech_debts
|
||||||
|
.read_with(|d| d[0].clone());
|
||||||
|
assert!(debt.is_resolved);
|
||||||
|
|
||||||
|
// 2. Gate authorize
|
||||||
|
app_state.handler.state.env.gates.modify(|gates| {
|
||||||
|
gates.push(crate::models::GateRecord {
|
||||||
|
id: "gate-123".to_string(),
|
||||||
|
action: "push".to_string(),
|
||||||
|
target: "master".to_string(),
|
||||||
|
status: "blocked".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let app2 = create_router(app_state.clone());
|
||||||
|
let req2 = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/gates/gate-123/authorize")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp2 = app2.oneshot(req2).await.unwrap();
|
||||||
|
assert_eq!(resp2.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let gate = app_state
|
||||||
|
.handler
|
||||||
|
.state
|
||||||
|
.env
|
||||||
|
.gates
|
||||||
|
.read_with(|g| g[0].clone());
|
||||||
|
assert_eq!(gate.status, "authorized");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+397
-4
@@ -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,6 +185,52 @@ pub async fn nvim_telemetry_handler(
|
|||||||
let _ = state.handler.state.event_bus_tx.send(event);
|
let _ = 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);
|
||||||
|
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 {
|
if let Some(ref f) = payload.file {
|
||||||
state.handler.state.record_activity(
|
state.handler.state.record_activity(
|
||||||
"editor",
|
"editor",
|
||||||
@@ -79,7 +239,50 @@ pub async fn nvim_telemetry_handler(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
axum::Json(serde_json::json!({"status": "ok"}))
|
// 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;
|
||||||
@@ -205,4 +408,194 @@ mod tests {
|
|||||||
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+73
-46
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,19 +66,19 @@ pub async fn handle_socket(socket: WebSocket, state: Arc<AppState>, _client_type
|
|||||||
activities
|
activities
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|act_val| {
|
.filter_map(|act_val| {
|
||||||
if act_val["category"] == "TASK_EVENT" {
|
if act_val["category"] == "TASK_EVENT"
|
||||||
if let Some(details_str) = act_val["details"].as_str() {
|
&& let Some(details_str) = act_val["details"].as_str()
|
||||||
if let Ok(event_val) = serde_json::from_str::<serde_json::Value>(details_str) {
|
&& let Ok(event_val) =
|
||||||
return Some(
|
serde_json::from_str::<serde_json::Value>(details_str)
|
||||||
serde_json::json!({
|
{
|
||||||
"jsonrpc": "2.0",
|
return Some(
|
||||||
"method": "notifications/task/completed",
|
serde_json::json!({
|
||||||
"params": event_val
|
"jsonrpc": "2.0",
|
||||||
})
|
"method": "notifications/task/completed",
|
||||||
.to_string(),
|
"params": event_val
|
||||||
);
|
})
|
||||||
}
|
.to_string(),
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
})
|
})
|
||||||
@@ -105,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 {
|
||||||
@@ -121,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
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,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! {
|
||||||
@@ -207,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 {
|
||||||
@@ -214,6 +235,7 @@ 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
|
||||||
|
|
||||||
@@ -240,7 +262,10 @@ mod tests {
|
|||||||
mem_state.broadcast_task_event(task_event.clone());
|
mem_state.broadcast_task_event(task_event.clone());
|
||||||
|
|
||||||
// Verify recent activities recorded the event
|
// Verify recent activities recorded the event
|
||||||
let recorded = mem_state.telemetry.recent_activities.read_with(|act| act.clone());
|
let recorded = mem_state
|
||||||
|
.telemetry
|
||||||
|
.recent_activities
|
||||||
|
.read_with(|act| act.clone());
|
||||||
assert!(!recorded.is_empty());
|
assert!(!recorded.is_empty());
|
||||||
assert_eq!(recorded[0]["category"], "TASK_EVENT");
|
assert_eq!(recorded[0]["category"], "TASK_EVENT");
|
||||||
|
|
||||||
@@ -270,18 +295,17 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|act_val| {
|
.filter_map(|act_val| {
|
||||||
if act_val["category"] == "TASK_EVENT" {
|
if act_val["category"] == "TASK_EVENT" {
|
||||||
if let Some(details_str) = act_val["details"].as_str() {
|
let details_str = act_val["details"].as_str()?;
|
||||||
if let Ok(event_val) = serde_json::from_str::<serde_json::Value>(details_str) {
|
let event_val =
|
||||||
return Some(
|
serde_json::from_str::<serde_json::Value>(details_str).ok()?;
|
||||||
serde_json::json!({
|
return Some(
|
||||||
"jsonrpc": "2.0",
|
serde_json::json!({
|
||||||
"method": "notifications/task/completed",
|
"jsonrpc": "2.0",
|
||||||
"params": event_val
|
"method": "notifications/task/completed",
|
||||||
})
|
"params": event_val
|
||||||
.to_string(),
|
})
|
||||||
);
|
.to_string(),
|
||||||
}
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
})
|
})
|
||||||
@@ -293,7 +317,10 @@ mod tests {
|
|||||||
let _ = tx.try_send(notif);
|
let _ = tx.try_send(notif);
|
||||||
}
|
}
|
||||||
|
|
||||||
let replayed_msg = rx.recv().await.expect("Expected replayed task event notification");
|
let replayed_msg = rx
|
||||||
|
.recv()
|
||||||
|
.await
|
||||||
|
.expect("Expected replayed task event notification");
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&replayed_msg).unwrap();
|
let parsed: serde_json::Value = serde_json::from_str(&replayed_msg).unwrap();
|
||||||
assert_eq!(parsed["method"], "notifications/task/completed");
|
assert_eq!(parsed["method"], "notifications/task/completed");
|
||||||
assert_eq!(parsed["params"]["task_id"], "task-999");
|
assert_eq!(parsed["params"]["task_id"], "task-999");
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
use mcp_memory_server::router::MemoryHandler;
|
||||||
|
use mcp_memory_server::state::MemoryState;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use mcp_memory_server::router::MemoryHandler;
|
|
||||||
use mcp_memory_server::state::MemoryState;
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
@@ -13,7 +13,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let handler = MemoryHandler::new(state);
|
let handler = MemoryHandler::new(state);
|
||||||
|
|
||||||
let win_dir = PathBuf::from(r"C:\Users\reazul.ashraf\.gemini\antigravity-cli\mcp\mcp-memory");
|
let win_dir = PathBuf::from(r"C:\Users\reazul.ashraf\.gemini\antigravity-cli\mcp\mcp-memory");
|
||||||
let wsl_dir = PathBuf::from(r"\\wsl.localhost\Ubuntu\home\riz\.gemini\antigravity-cli\mcp\mcp-memory");
|
let wsl_dir =
|
||||||
|
PathBuf::from(r"\\wsl.localhost\Ubuntu\home\riz\.gemini\antigravity-cli\mcp\mcp-memory");
|
||||||
|
|
||||||
fs::create_dir_all(&win_dir)?;
|
fs::create_dir_all(&win_dir)?;
|
||||||
if !wsl_dir.exists() {
|
if !wsl_dir.exists() {
|
||||||
@@ -41,13 +42,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if let Ok(entries) = fs::read_dir(&win_dir) {
|
if let Ok(entries) = fs::read_dir(&win_dir) {
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.extension().map_or(false, |ext| ext == "json") {
|
if path.extension().is_some_and(|ext| ext == "json")
|
||||||
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
|
||||||
if !active_tools.contains(stem) {
|
&& !active_tools.contains(stem)
|
||||||
println!(" [STALE REMOVED Win] {}", path.display());
|
{
|
||||||
let _ = fs::remove_file(&path);
|
println!(" [STALE REMOVED Win] {}", path.display());
|
||||||
}
|
let _ = fs::remove_file(&path);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,23 +56,27 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if let Ok(entries) = fs::read_dir(&wsl_dir) {
|
if let Ok(entries) = fs::read_dir(&wsl_dir) {
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.extension().map_or(false, |ext| ext == "json") {
|
if path.extension().is_some_and(|ext| ext == "json")
|
||||||
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
|
||||||
if !active_tools.contains(stem) {
|
&& !active_tools.contains(stem)
|
||||||
println!(" [STALE REMOVED WSL] {}", path.display());
|
{
|
||||||
let _ = fs::remove_file(&path);
|
println!(" [STALE REMOVED WSL] {}", path.display());
|
||||||
}
|
let _ = fs::remove_file(&path);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync instructions.md
|
// Sync instructions.md
|
||||||
let instructions_src = PathBuf::from(r"C:\Users\reazul.ashraf\workspace\rust\mcp-memory\instructions.md");
|
let instructions_src =
|
||||||
|
PathBuf::from(r"C:\Users\reazul.ashraf\workspace\rust\mcp-memory\instructions.md");
|
||||||
if instructions_src.exists() {
|
if instructions_src.exists() {
|
||||||
let _ = fs::copy(&instructions_src, win_dir.join("instructions.md"));
|
let _ = fs::copy(&instructions_src, win_dir.join("instructions.md"));
|
||||||
let _ = fs::copy(&instructions_src, wsl_dir.join("instructions.md"));
|
let _ = fs::copy(&instructions_src, wsl_dir.join("instructions.md"));
|
||||||
println!(" [OK] Synchronized instructions.md to Win and WSL");
|
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);
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
fn main() {
|
||||||
|
let mut img = image::DynamicImage::new_rgb8(10, 10);
|
||||||
|
img.invert();
|
||||||
|
let _img2 = img.adjust_contrast(20.0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
use serde_json::Value;
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
pub fn load_mcp_config_env() {
|
||||||
|
let config_path = dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join(".gemini/config/mcp_config.json");
|
||||||
|
|
||||||
|
if !config_path.exists() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let config_str = match fs::read_to_string(&config_path) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let parsed: Value = match serde_json::from_str(&config_str) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(env_obj) = parsed
|
||||||
|
.get("mcpServers")
|
||||||
|
.and_then(|v| v.get("mcp-memory"))
|
||||||
|
.and_then(|v| v.get("env"))
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
{
|
||||||
|
for (key, val) in env_obj {
|
||||||
|
if let Some(val_str) = val.as_str()
|
||||||
|
&& env::var(key).is_err()
|
||||||
|
{
|
||||||
|
unsafe {
|
||||||
|
env::set_var(key, val_str);
|
||||||
|
}
|
||||||
|
info!("Loaded env var {} from mcp_config.json", key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+363
-1
@@ -21,6 +21,7 @@
|
|||||||
--btn-border: #dcdde1;
|
--btn-border: #dcdde1;
|
||||||
--btn-hover: #f8f9fa;
|
--btn-hover: #f8f9fa;
|
||||||
--search-hover: #f0f2f5;
|
--search-hover: #f0f2f5;
|
||||||
|
--input-bg: #ffffff;
|
||||||
--input-text: #2c3e50;
|
--input-text: #2c3e50;
|
||||||
--inspector-bg: rgba(255,255,255,0.98);
|
--inspector-bg: rgba(255,255,255,0.98);
|
||||||
--success-color: #27ae60;
|
--success-color: #27ae60;
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
--btn-border: #3d4a57;
|
--btn-border: #3d4a57;
|
||||||
--btn-hover: #3d4a57;
|
--btn-hover: #3d4a57;
|
||||||
--search-hover: #2c3643;
|
--search-hover: #2c3643;
|
||||||
|
--input-bg: #1e242b;
|
||||||
--input-text: #d1d8e0;
|
--input-text: #d1d8e0;
|
||||||
--inspector-bg: rgba(30, 36, 43, 0.98);
|
--inspector-bg: rgba(30, 36, 43, 0.98);
|
||||||
--success-color: #2ecc71;
|
--success-color: #2ecc71;
|
||||||
@@ -464,6 +466,317 @@
|
|||||||
color: #7f8c8d;
|
color: #7f8c8d;
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Enterprise UX/HCI Styles --- */
|
||||||
|
.enterprise-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
background: var(--panel-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.enterprise-search-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 240px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.enterprise-search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 30px 8px 32px !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
border: 1px solid var(--border-color) !important;
|
||||||
|
background: var(--input-bg) !important;
|
||||||
|
color: var(--input-text) !important;
|
||||||
|
font-size: 0.88em !important;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.enterprise-search-input:focus {
|
||||||
|
border-color: #3498db !important;
|
||||||
|
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2) !important;
|
||||||
|
}
|
||||||
|
.enterprise-search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
pointer-events: none;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.enterprise-clear-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.1em;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.enterprise-clear-btn:hover {
|
||||||
|
color: var(--error-color);
|
||||||
|
}
|
||||||
|
.enterprise-filters-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.enterprise-select {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--input-text);
|
||||||
|
font-size: 0.85em;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.enterprise-select:focus {
|
||||||
|
border-color: #3498db;
|
||||||
|
}
|
||||||
|
.enterprise-btn {
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--btn-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.85em;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.enterprise-btn:hover {
|
||||||
|
background: var(--btn-hover);
|
||||||
|
border-color: #3498db;
|
||||||
|
}
|
||||||
|
.enterprise-btn.active {
|
||||||
|
background: #3498db;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #2980b9;
|
||||||
|
}
|
||||||
|
.enterprise-meta-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.82em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.enterprise-pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.enterprise-pagination button {
|
||||||
|
min-width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--btn-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85em;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.enterprise-pagination button:hover:not(:disabled) {
|
||||||
|
background: var(--btn-hover);
|
||||||
|
border-color: #3498db;
|
||||||
|
}
|
||||||
|
.enterprise-pagination button.active {
|
||||||
|
background: #3498db;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #2980b9;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.enterprise-pagination button:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.enterprise-empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 36px 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.95em;
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.enterprise-kbd {
|
||||||
|
font-size: 0.72em;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: monospace;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.enterprise-cards-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Command Palette Modal */
|
||||||
|
#cmd-palette-backdrop {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 10000;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-top: 12vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
#cmd-palette-modal {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 680px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: paletteFadeIn 0.15s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes paletteFadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(-10px) scale(0.98); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
.palette-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--canvas-bg);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.palette-search-icon {
|
||||||
|
font-size: 1.1em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.palette-input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-size: 1.05em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.palette-kbd-badge {
|
||||||
|
font-size: 0.72em;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.palette-results {
|
||||||
|
max-height: 380px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.palette-item {
|
||||||
|
padding: 10px 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.palette-item:hover, .palette-item.selected {
|
||||||
|
background: rgba(52, 152, 219, 0.12);
|
||||||
|
}
|
||||||
|
.palette-item-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.palette-item-icon {
|
||||||
|
font-size: 1em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.palette-item-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.palette-item-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.92em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.palette-item-sub {
|
||||||
|
font-size: 0.78em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.palette-item-badge {
|
||||||
|
font-size: 0.72em;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--canvas-bg);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.palette-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 18px;
|
||||||
|
background: var(--canvas-bg);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
font-size: 0.75em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -473,12 +786,35 @@
|
|||||||
<span id="app-version" style="font-size:0.8em; color:var(--text-secondary); margin-left:12px; font-family:monospace;"></span>
|
<span id="app-version" style="font-size:0.8em; color:var(--text-secondary); margin-left:12px; font-family:monospace;"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<button class="theme-toggle" onclick="openCommandPalette()" title="Command Palette (Ctrl+K)" style="background:var(--card-bg); border:1px solid var(--border-color); color:var(--text-primary); border-radius:6px; padding:5px 10px; font-size:13px; display:flex; align-items:center; gap:6px; cursor:pointer;">
|
||||||
|
<span>🔍</span>
|
||||||
|
<span>Command Palette</span>
|
||||||
|
<span class="enterprise-kbd">Ctrl+K</span>
|
||||||
|
</button>
|
||||||
<button class="theme-toggle" onclick="testClipboard()" title="Test Clipboard Vision Tool" style="background:#3498db; color:white; border-radius:4px; padding:4px 8px; font-size:14px; display:flex; align-items:center; gap:5px;">📋 Inspect Clipboard</button>
|
<button class="theme-toggle" onclick="testClipboard()" title="Test Clipboard Vision Tool" style="background:#3498db; color:white; border-radius:4px; padding:4px 8px; font-size:14px; display:flex; align-items:center; gap:5px;">📋 Inspect Clipboard</button>
|
||||||
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle Dark Mode">🌓</button>
|
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle Dark Mode">🌓</button>
|
||||||
<div style="font-weight: 500;"><span class="status-dot"></span>System Online</div>
|
<div style="font-weight: 500;"><span class="status-dot"></span>System Online</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Command Palette Modal -->
|
||||||
|
<div id="cmd-palette-backdrop" onclick="onPaletteBackdropClick(event)">
|
||||||
|
<div id="cmd-palette-modal">
|
||||||
|
<div class="palette-header">
|
||||||
|
<span class="palette-search-icon">🔍</span>
|
||||||
|
<input id="palette-input" class="palette-input" type="text" placeholder="Type a command or search tasks, debts, ADRs, environments..." autocomplete="off" oninput="onPaletteSearch(this.value)" onkeydown="onPaletteKeyDown(event)">
|
||||||
|
<span class="palette-kbd-badge">ESC</span>
|
||||||
|
</div>
|
||||||
|
<div id="palette-results" class="palette-results">
|
||||||
|
<!-- Injected dynamically -->
|
||||||
|
</div>
|
||||||
|
<div class="palette-footer">
|
||||||
|
<div><span>↑↓</span> to navigate • <span>↵</span> to jump • <span>ESC</span> to close</div>
|
||||||
|
<div id="palette-count" style="font-family: monospace;">0 items</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Clipboard Modal -->
|
<!-- Clipboard Modal -->
|
||||||
<div id="clipboard-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.8); z-index:9999; justify-content:center; align-items:center; flex-direction:column;">
|
<div id="clipboard-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.8); z-index:9999; justify-content:center; align-items:center; flex-direction:column;">
|
||||||
<div style="background:var(--card-bg); padding:20px; border-radius:8px; max-width:80%; max-height:80%; display:flex; flex-direction:column; gap:15px; border:1px solid var(--border-color); box-shadow: 0 10px 30px rgba(0,0,0,0.5);">
|
<div style="background:var(--card-bg); padding:20px; border-radius:8px; max-width:80%; max-height:80%; display:flex; flex-direction:column; gap:15px; border:1px solid var(--border-color); box-shadow: 0 10px 30px rgba(0,0,0,0.5);">
|
||||||
@@ -503,6 +839,7 @@
|
|||||||
<button class="tab-button" onclick="switchTab('memos-tab', this)">Handoffs</button>
|
<button class="tab-button" onclick="switchTab('memos-tab', this)">Handoffs</button>
|
||||||
<button class="tab-button" onclick="switchTab('snippets-tab', this)">Snippets</button>
|
<button class="tab-button" onclick="switchTab('snippets-tab', this)">Snippets</button>
|
||||||
<button class="tab-button" onclick="switchTab('terminal-tab', this)">Terminal</button>
|
<button class="tab-button" onclick="switchTab('terminal-tab', this)">Terminal</button>
|
||||||
|
<button class="tab-button" onclick="switchTab('env-tab', this)">Environment</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="graph-tab" class="tab-content">
|
<div id="graph-tab" class="tab-content">
|
||||||
@@ -541,7 +878,19 @@
|
|||||||
|
|
||||||
<div id="task-tab" class="tab-content">
|
<div id="task-tab" class="tab-content">
|
||||||
<div class="panel kanban-panel" style="flex:1; display:flex; flex-direction:column;">
|
<div class="panel kanban-panel" style="flex:1; display:flex; flex-direction:column;">
|
||||||
<h3 style="margin-top:0;">Task Network (HTN)</h3>
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; flex-wrap:wrap; gap:10px;">
|
||||||
|
<h3 style="margin:0;">Task Network (HTN)</h3>
|
||||||
|
<div style="display:flex; gap:8px; align-items:center;">
|
||||||
|
<input type="text" id="task-filter-input" placeholder="Filter tasks..." oninput="handleTaskFilter(this.value)" style="padding:6px 10px; font-size:0.85em; border-radius:6px; border:1px solid var(--border-color); background:var(--input-bg); color:var(--input-text); width:200px; margin-bottom:0;">
|
||||||
|
<select id="task-status-filter" onchange="handleTaskStatusFilter(this.value)" class="enterprise-select">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="in_progress">In Progress</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="blocked">Blocked</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="kanban-items" id="task-tree-container" style="flex:1; border: 1px solid var(--border-color); padding:15px; border-radius:6px; background:var(--canvas-bg);"></div>
|
<div class="kanban-items" id="task-tree-container" style="flex:1; border: 1px solid var(--border-color); padding:15px; border-radius:6px; background:var(--canvas-bg);"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -595,6 +944,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="env-tab" class="tab-content">
|
||||||
|
<div class="panel" style="flex:1; display:flex; flex-direction:column; overflow-y:auto;">
|
||||||
|
<h2>Target Environments & Infrastructure</h2>
|
||||||
|
<div id="environments-container" style="display:flex; flex-direction:column; gap:15px; margin-bottom: 30px;"></div>
|
||||||
|
<h2>Tool & System Fingerprints</h2>
|
||||||
|
<div id="fingerprints-container" style="display:flex; flex-direction:column; gap:15px; margin-bottom: 30px;"></div>
|
||||||
|
<h2>Environment Requirements & Secrets</h2>
|
||||||
|
<div id="requirements-container" style="display:flex; flex-direction:column; gap:15px; margin-bottom: 30px;"></div>
|
||||||
|
<h2>Quality & Authorization Gates</h2>
|
||||||
|
<div id="gates-container" style="display:flex; flex-direction:column; gap:15px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="activity-tab" class="tab-content active">
|
<div id="activity-tab" class="tab-content active">
|
||||||
<div class="panel activity-panel" style="flex:1; display:flex; flex-direction:column;">
|
<div class="panel activity-panel" style="flex:1; display:flex; flex-direction:column;">
|
||||||
<div id="activity-feed" style="flex:1;"></div>
|
<div id="activity-feed" style="flex:1;"></div>
|
||||||
|
|||||||
+1183
-64
File diff suppressed because it is too large.
Load diff
+1561
-137
File diff suppressed because it is too large.
Load diff
+5
-60
@@ -1,5 +1,5 @@
|
|||||||
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;
|
||||||
|
|
||||||
@@ -117,77 +117,22 @@ pub fn init_redb(base: &Path) -> Arc<Database> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure the table exists and migrate legacy JSON files
|
// Ensure the table exists
|
||||||
match db.begin_write() {
|
match db.begin_write() {
|
||||||
Ok(write_txn) => {
|
Ok(write_txn) => {
|
||||||
let mut opened_ok = false;
|
let mut opened_ok = false;
|
||||||
if let Ok(mut table) = write_txn.open_table(STORE_TABLE) {
|
if let Ok(_) = write_txn.open_table(STORE_TABLE) {
|
||||||
opened_ok = true;
|
opened_ok = true;
|
||||||
if !is_in_memory {
|
|
||||||
let stores = vec![
|
|
||||||
("knowledge_graph_master", "knowledge_graph_master.json"),
|
|
||||||
("audit_ledger", "audit_ledger.json"),
|
|
||||||
("tasks", "tasks.json"),
|
|
||||||
("snippets", "snippets.json"),
|
|
||||||
("adrs", "adrs.json"),
|
|
||||||
("error_fixes", "error_fixes.json"),
|
|
||||||
("session_summaries", "session_summaries.json"),
|
|
||||||
("handoff_memos", "handoff_memos.json"),
|
|
||||||
("env_fingerprints", "env_fingerprints.json"),
|
|
||||||
("env_requirements", "env_requirements.json"),
|
|
||||||
("milestones", "milestones.json"),
|
|
||||||
("environments", "environments.json"),
|
|
||||||
("tech_debts", "tech_debts.json"),
|
|
||||||
("gates", "gates.json"),
|
|
||||||
("state_snapshots", "state_snapshots.json"),
|
|
||||||
("hypotheses", "hypotheses.json"),
|
|
||||||
("agent_signals", "agent_signals.json"),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (key, file_name) in stores.iter() {
|
|
||||||
let is_missing = match table.get(*key) {
|
|
||||||
Ok(res) => res.is_none(),
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("Failed to read key '{}' from redb: {}", key, e);
|
|
||||||
false
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if is_missing {
|
|
||||||
let json_path = base.join(file_name);
|
|
||||||
if json_path.exists()
|
|
||||||
&& let Ok(data) = std::fs::read(&json_path)
|
|
||||||
&& serde_json::from_slice::<serde_json::Value>(&data).is_ok()
|
|
||||||
{
|
|
||||||
if let Err(e) = table.insert(*key, data.as_slice()) {
|
|
||||||
tracing::error!(
|
|
||||||
"Failed to insert migrated key '{}': {}",
|
|
||||||
key,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
let migrated_path = json_path.with_extension("json.migrated");
|
|
||||||
if let Err(_) = std::fs::rename(&json_path, &migrated_path) {
|
|
||||||
if migrated_path.exists() {
|
|
||||||
let _ = std::fs::remove_file(&migrated_path);
|
|
||||||
let _ = std::fs::rename(&json_path, &migrated_path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if opened_ok {
|
if opened_ok {
|
||||||
if let Err(e) = write_txn.commit() {
|
if let Err(e) = write_txn.commit() {
|
||||||
tracing::error!("Failed to commit database migration transaction: {}", e);
|
tracing::error!("Failed to commit database initialization transaction: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
"Failed to begin write transaction for redb migration: {}",
|
"Failed to begin write transaction for redb initialization: {}",
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-15
@@ -152,7 +152,6 @@ fn fallback_embed(text: &str) -> Vec<f32> {
|
|||||||
return vec;
|
return vec;
|
||||||
}
|
}
|
||||||
|
|
||||||
use std::hash::{Hash, Hasher};
|
|
||||||
for word in words {
|
for word in words {
|
||||||
let clean: String = word
|
let clean: String = word
|
||||||
.chars()
|
.chars()
|
||||||
@@ -163,18 +162,17 @@ fn fallback_embed(text: &str) -> Vec<f32> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
let hash_bytes = blake3::hash(clean.as_bytes());
|
||||||
clean.hash(&mut hasher);
|
let h = u64::from_le_bytes(hash_bytes.as_bytes()[0..8].try_into().unwrap());
|
||||||
let h = hasher.finish();
|
|
||||||
let idx = (h as usize) % DIM;
|
let idx = (h as usize) % DIM;
|
||||||
let sign = if (h >> 32) & 1 == 0 { 1.0f32 } else { -1.0f32 };
|
let sign = if (h >> 32) & 1 == 0 { 1.0f32 } else { -1.0f32 };
|
||||||
vec[idx] += sign;
|
vec[idx] += sign;
|
||||||
|
|
||||||
let chars: Vec<char> = clean.chars().collect();
|
let chars: Vec<char> = clean.chars().collect();
|
||||||
for window in chars.windows(3) {
|
for window in chars.windows(3) {
|
||||||
let mut h2 = std::collections::hash_map::DefaultHasher::new();
|
let window_str: String = window.iter().collect();
|
||||||
window.hash(&mut h2);
|
let h2_bytes = blake3::hash(window_str.as_bytes());
|
||||||
let hv = h2.finish();
|
let hv = u64::from_le_bytes(h2_bytes.as_bytes()[0..8].try_into().unwrap());
|
||||||
let idx2 = (hv as usize) % DIM;
|
let idx2 = (hv as usize) % DIM;
|
||||||
let s2 = if (hv >> 32) & 1 == 0 { 0.5f32 } else { -0.5f32 };
|
let s2 = if (hv >> 32) & 1 == 0 { 0.5f32 } else { -0.5f32 };
|
||||||
vec[idx2] += s2;
|
vec[idx2] += s2;
|
||||||
@@ -195,14 +193,14 @@ fn fallback_embed(text: &str) -> Vec<f32> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum EmbeddingModel {
|
enum EmbeddingModel {
|
||||||
Candle(CandleEmbeddingModel),
|
Candle(Box<CandleEmbeddingModel>),
|
||||||
Fallback,
|
Fallback,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EmbeddingModel {
|
impl EmbeddingModel {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
match CandleEmbeddingModel::new() {
|
match CandleEmbeddingModel::new() {
|
||||||
Ok(model) => EmbeddingModel::Candle(model),
|
Ok(model) => EmbeddingModel::Candle(Box::new(model)),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"Failed to initialize Candle BERT model ({e}); falling back to deterministic offline embeddings."
|
"Failed to initialize Candle BERT model ({e}); falling back to deterministic offline embeddings."
|
||||||
@@ -261,13 +259,10 @@ pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
|||||||
let mut norm_a_sq = 0.0f32;
|
let mut norm_a_sq = 0.0f32;
|
||||||
let mut norm_b_sq = 0.0f32;
|
let mut norm_b_sq = 0.0f32;
|
||||||
|
|
||||||
let chunks_a = a.chunks_exact(8);
|
let (chunks_a, remainder_a) = a.as_chunks::<8>();
|
||||||
let chunks_b = b.chunks_exact(8);
|
let (chunks_b, remainder_b) = b.as_chunks::<8>();
|
||||||
|
|
||||||
let remainder_a = chunks_a.remainder();
|
for (ca, cb) in chunks_a.iter().zip(chunks_b.iter()) {
|
||||||
let remainder_b = chunks_b.remainder();
|
|
||||||
|
|
||||||
for (ca, cb) in chunks_a.zip(chunks_b) {
|
|
||||||
for i in 0..8 {
|
for i in 0..8 {
|
||||||
let x = ca[i];
|
let x = ca[i];
|
||||||
let y = cb[i];
|
let y = cb[i];
|
||||||
@@ -378,4 +373,14 @@ mod tests {
|
|||||||
assert_eq!(embedding.len(), 384);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+20
-3
@@ -17,7 +17,11 @@ pub enum AppError {
|
|||||||
EntityNotFound { name: String, namespace: String },
|
EntityNotFound { name: String, namespace: String },
|
||||||
|
|
||||||
#[error("Invalid relation between '{from}' and '{to}': {reason}")]
|
#[error("Invalid relation between '{from}' and '{to}': {reason}")]
|
||||||
InvalidRelation { from: String, to: String, reason: String },
|
InvalidRelation {
|
||||||
|
from: String,
|
||||||
|
to: String,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
|
||||||
#[error("Database error for key '{key}': {message}")]
|
#[error("Database error for key '{key}': {message}")]
|
||||||
DatabaseError { key: String, message: String },
|
DatabaseError { key: String, message: String },
|
||||||
@@ -36,6 +40,9 @@ pub enum AppError {
|
|||||||
|
|
||||||
#[error("I/O Error: {0}")]
|
#[error("I/O Error: {0}")]
|
||||||
IoError(#[from] std::io::Error),
|
IoError(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("YAML Serialization Error: {0}")]
|
||||||
|
YamlError(#[from] serde_yaml::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<AppError> for String {
|
impl From<AppError> for String {
|
||||||
@@ -50,11 +57,17 @@ impl IntoResponse for AppError {
|
|||||||
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
||||||
AppError::EntityNotFound { name, namespace } => (
|
AppError::EntityNotFound { name, namespace } => (
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
format!("Entity '{}' was not found in namespace '{}'", name, namespace),
|
format!(
|
||||||
|
"Entity '{}' was not found in namespace '{}'",
|
||||||
|
name, namespace
|
||||||
|
),
|
||||||
),
|
),
|
||||||
AppError::InvalidRelation { from, to, reason } => (
|
AppError::InvalidRelation { from, to, reason } => (
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
format!("Invalid relation between '{}' and '{}': {}", from, to, reason),
|
format!(
|
||||||
|
"Invalid relation between '{}' and '{}': {}",
|
||||||
|
from, to, reason
|
||||||
|
),
|
||||||
),
|
),
|
||||||
AppError::DatabaseError { key, message } => (
|
AppError::DatabaseError { key, message } => (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
@@ -71,6 +84,10 @@ impl IntoResponse for AppError {
|
|||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
format!("I/O Error: {}", e),
|
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!({
|
||||||
|
|||||||
+11
-11
@@ -119,10 +119,10 @@ impl McpTool for ReadFileSkeletonHandler {
|
|||||||
header.push('\n');
|
header.push('\n');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if header.is_empty() {
|
if header.is_empty()
|
||||||
if let Some(first_line) = node_text.lines().next() {
|
&& let Some(first_line) = node_text.lines().next()
|
||||||
header = first_line.to_string();
|
{
|
||||||
}
|
header = first_line.to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
out.push_str(&indent);
|
out.push_str(&indent);
|
||||||
@@ -425,10 +425,10 @@ fn scan_workspace_for_symbol(
|
|||||||
if scanned_files > 500 {
|
if scanned_files > 500 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if let Ok(meta) = std::fs::metadata(path) {
|
if let Ok(meta) = std::fs::metadata(path)
|
||||||
if meta.len() > 1024 * 1024 {
|
&& meta.len() > 1024 * 1024
|
||||||
continue;
|
{
|
||||||
}
|
continue;
|
||||||
}
|
}
|
||||||
if let Ok(content) = std::fs::read_to_string(path) {
|
if let Ok(content) = std::fs::read_to_string(path) {
|
||||||
for (line_num, line) in content.lines().enumerate() {
|
for (line_num, line) in content.lines().enumerate() {
|
||||||
@@ -506,7 +506,7 @@ impl McpTool for FindSymbolReferencesHandler {
|
|||||||
matches.extend(disk_matches);
|
matches.extend(disk_matches);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(serde_json::to_string_pretty(&matches)?)
|
Ok(serde_yaml::to_string(&matches)?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,7 +562,7 @@ impl McpTool for GetCallersHandler {
|
|||||||
callers.extend(disk_callers);
|
callers.extend(disk_callers);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(serde_json::to_string_pretty(&callers)?)
|
Ok(serde_yaml::to_string(&callers)?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,7 +651,7 @@ impl McpTool for AnalyzeImpactHandler {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(serde_json::to_string_pretty(&result)?)
|
Ok(serde_yaml::to_string(&result)?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+42
-18
@@ -22,17 +22,40 @@ impl McpTool for EnvironmentHandler {
|
|||||||
|
|
||||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||||
let req: EnvironmentTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
let req: EnvironmentTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||||
let ns = req.namespace.unwrap_or_else(|| crate::models::default_namespace());
|
let ns = req
|
||||||
|
.namespace
|
||||||
|
.unwrap_or_else(crate::models::default_namespace);
|
||||||
match req.action {
|
match req.action {
|
||||||
EnvAction::UpdateFingerprint => {
|
EnvAction::UpdateFingerprint => {
|
||||||
let tool_versions = req.tool_versions.unwrap_or_default();
|
let tool_versions = req.tool_versions.unwrap_or_default();
|
||||||
|
let detected_os = req.os.unwrap_or_else(|| std::env::consts::OS.to_string());
|
||||||
|
let detected_shell = req
|
||||||
|
.shell
|
||||||
|
.or_else(|| std::env::var("SHELL").ok())
|
||||||
|
.or_else(|| std::env::var("STARSHIP_SHELL").ok())
|
||||||
|
.or_else(|| {
|
||||||
|
if std::env::var("PSModulePath").is_ok() {
|
||||||
|
Some("pwsh".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
if std::env::var("COMSPEC").is_ok() {
|
||||||
|
Some("cmd".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
state.env.env_fingerprints.modify(|fps| {
|
state.env.env_fingerprints.modify(|fps| {
|
||||||
fps.insert(
|
fps.insert(
|
||||||
ns.clone(),
|
ns.clone(),
|
||||||
crate::models::EnvFingerprint {
|
crate::models::EnvFingerprint {
|
||||||
namespace: ns.clone(),
|
namespace: ns.clone(),
|
||||||
os: std::env::consts::OS.to_string(),
|
os: detected_os,
|
||||||
shell: std::env::var("SHELL").unwrap_or_else(|_| "unknown".to_string()),
|
shell: detected_shell,
|
||||||
tool_versions,
|
tool_versions,
|
||||||
updated_at: crate::handlers::utils::now_secs(),
|
updated_at: crate::handlers::utils::now_secs(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -47,7 +70,7 @@ impl McpTool for EnvironmentHandler {
|
|||||||
.env_fingerprints
|
.env_fingerprints
|
||||||
.read_with(|fps| fps.get(&ns).cloned());
|
.read_with(|fps| fps.get(&ns).cloned());
|
||||||
if let Some(fp) = data {
|
if let Some(fp) = data {
|
||||||
let data = Ok::<String, crate::error::AppError>(serde_json::to_string(&fp)?)?;
|
let data = Ok::<String, crate::error::AppError>(serde_yaml::to_string(&fp)?)?;
|
||||||
Ok(data)
|
Ok(data)
|
||||||
} else {
|
} else {
|
||||||
Ok("{}".to_string())
|
Ok("{}".to_string())
|
||||||
@@ -94,11 +117,8 @@ impl McpTool for EnvironmentHandler {
|
|||||||
}
|
}
|
||||||
EnvAction::GetDetails => {
|
EnvAction::GetDetails => {
|
||||||
let data = state.env.environments.read_with(|envs| {
|
let data = state.env.environments.read_with(|envs| {
|
||||||
let filtered: Vec<_> = envs
|
let filtered: Vec<_> = envs.iter().filter(|e| e.namespace == ns).collect();
|
||||||
.iter()
|
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&filtered)?)
|
||||||
.filter(|e| e.namespace == ns)
|
|
||||||
.collect();
|
|
||||||
Ok::<String, crate::error::AppError>(serde_json::to_string(&filtered)?)
|
|
||||||
})?;
|
})?;
|
||||||
Ok(data)
|
Ok(data)
|
||||||
}
|
}
|
||||||
@@ -106,7 +126,6 @@ impl McpTool for EnvironmentHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -135,7 +154,10 @@ mod tests {
|
|||||||
assert_eq!(res, "Env fingerprint updated");
|
assert_eq!(res, "Env fingerprint updated");
|
||||||
|
|
||||||
let res2 = handler
|
let res2 = handler
|
||||||
.execute(json!({"action": "read_fingerprint", "namespace": "global"}), state.clone())
|
.execute(
|
||||||
|
json!({"action": "read_fingerprint", "namespace": "global"}),
|
||||||
|
state.clone(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -163,7 +185,10 @@ mod tests {
|
|||||||
|
|
||||||
let handler = EnvironmentHandler;
|
let handler = EnvironmentHandler;
|
||||||
let res = handler
|
let res = handler
|
||||||
.execute(json!({"action": "get_details", "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()))
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -207,7 +232,10 @@ mod tests {
|
|||||||
assert_eq!(res2, "Environment registered");
|
assert_eq!(res2, "Environment registered");
|
||||||
|
|
||||||
let res3 = handler
|
let res3 = handler
|
||||||
.execute(json!({"action": "get_details", "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()))
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -229,12 +257,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let res = handler
|
let res = handler.execute(args, state.clone()).await;
|
||||||
.execute(args, state.clone())
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(res.is_ok());
|
assert!(res.is_ok());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
+23
-24
@@ -87,7 +87,7 @@ impl McpTool for GetActiveWorktreeContextHandler {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??;
|
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??;
|
||||||
|
|
||||||
Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&result)?)
|
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&result)?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,25 +139,25 @@ impl McpTool for QueryGitDiffsHandler {
|
|||||||
let git_matches = tokio::task::spawn_blocking(move || {
|
let git_matches = tokio::task::spawn_blocking(move || {
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
let cwd = env::current_dir().unwrap_or_default();
|
let cwd = env::current_dir().unwrap_or_default();
|
||||||
if let Ok(repo) = git2::Repository::discover(&cwd) {
|
if let Ok(repo) = git2::Repository::discover(&cwd)
|
||||||
if let Ok(mut revwalk) = repo.revwalk() {
|
&& let Ok(mut revwalk) = repo.revwalk()
|
||||||
let _ = revwalk.push_head();
|
{
|
||||||
let mut count = 0;
|
let _ = revwalk.push_head();
|
||||||
for oid in revwalk.flatten() {
|
let mut count = 0;
|
||||||
if count >= remaining {
|
for oid in revwalk.flatten() {
|
||||||
break;
|
if count >= remaining {
|
||||||
}
|
break;
|
||||||
if let Ok(commit) = repo.find_commit(oid) {
|
}
|
||||||
let summary = commit.summary().unwrap_or("");
|
if let Ok(commit) = repo.find_commit(oid) {
|
||||||
if summary.to_lowercase().contains(&q) {
|
let summary = commit.summary().unwrap_or("");
|
||||||
count += 1;
|
if summary.to_lowercase().contains(&q) {
|
||||||
results.push(json!({
|
count += 1;
|
||||||
"commit_id": oid.to_string(),
|
results.push(json!({
|
||||||
"author": commit.author().name().unwrap_or("unknown"),
|
"commit_id": oid.to_string(),
|
||||||
"message": summary,
|
"author": commit.author().name().unwrap_or("unknown"),
|
||||||
"timestamp": commit.time().seconds(),
|
"message": summary,
|
||||||
}));
|
"timestamp": commit.time().seconds(),
|
||||||
}
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,7 +169,7 @@ impl McpTool for QueryGitDiffsHandler {
|
|||||||
matches.extend(git_matches);
|
matches.extend(git_matches);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(serde_json::to_string_pretty(&matches)?)
|
Ok(serde_yaml::to_string(&matches)?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@ mod tests {
|
|||||||
.map_err(|e| format!("Failed to get worktree context: {}", e))
|
.map_err(|e| format!("Failed to get worktree context: {}", e))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
let parsed: serde_json::Value = serde_yaml::from_str(&result).unwrap();
|
||||||
assert!(parsed.get("branch").is_some());
|
assert!(parsed.get("branch").is_some());
|
||||||
assert!(parsed.get("modified_files").is_some());
|
assert!(parsed.get("modified_files").is_some());
|
||||||
assert!(parsed.get("diff").is_some());
|
assert!(parsed.get("diff").is_some());
|
||||||
@@ -211,8 +211,7 @@ mod tests {
|
|||||||
.map_err(|e| format!("Failed to get worktree context: {}", e))
|
.map_err(|e| format!("Failed to get worktree context: {}", e))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
let parsed: serde_json::Value = serde_yaml::from_str(&result).unwrap();
|
||||||
assert!(parsed.get("branch").is_some() || parsed.is_object());
|
assert!(parsed.get("branch").is_some() || parsed.is_object());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+201
-90
@@ -62,15 +62,15 @@ impl<'a> GraphQueryBuilder<'a> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
nodes_at_current_depth -= 1;
|
nodes_at_current_depth -= 1;
|
||||||
if current_depth < self.max_depth {
|
if current_depth < self.max_depth
|
||||||
if let Some(neighbors) = adj.get(current) {
|
&& let Some(neighbors) = adj.get(current)
|
||||||
for &(neighbor, rel_type, is_inverse) in neighbors {
|
{
|
||||||
if !visited.contains(neighbor) {
|
for &(neighbor, rel_type, is_inverse) in neighbors {
|
||||||
visited.insert(neighbor);
|
if !visited.contains(neighbor) {
|
||||||
parents.insert(neighbor, (current, rel_type, is_inverse));
|
visited.insert(neighbor);
|
||||||
queue.push_back(neighbor);
|
parents.insert(neighbor, (current, rel_type, is_inverse));
|
||||||
nodes_at_next_depth += 1;
|
queue.push_back(neighbor);
|
||||||
}
|
nodes_at_next_depth += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,7 +138,9 @@ impl McpTool for QueryGraphPathHandler {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| crate::error::AppError::Internal(format!("Graph traversal task failed: {}", e)))?
|
.map_err(|e| {
|
||||||
|
crate::error::AppError::Internal(format!("Graph traversal task failed: {}", e))
|
||||||
|
})?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,12 +171,19 @@ impl McpTool for CreateEntitiesHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let names: Vec<String> = inserted.iter().map(|e| format!("{} ({})", e.name, e.entity_type)).collect();
|
let names: Vec<String> = inserted
|
||||||
|
.iter()
|
||||||
|
.map(|e| format!("{} ({})", e.name, e.entity_type))
|
||||||
|
.collect();
|
||||||
if !inserted.is_empty() {
|
if !inserted.is_empty() {
|
||||||
let idx = state.get_search_index().await;
|
let idx = state.get_search_index().await;
|
||||||
let _ = idx.index_entities_batch(&inserted).await;
|
let _ = idx.index_entities_batch(&inserted).await;
|
||||||
}
|
}
|
||||||
Ok(format!("Successfully created {} entity/entities: {}", names.len(), names.join(", ")))
|
Ok(format!(
|
||||||
|
"Successfully created {} entity/entities: {}",
|
||||||
|
names.len(),
|
||||||
|
names.join(", ")
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +245,9 @@ impl McpTool for CreateRelationsHandler {
|
|||||||
crate::models::Entity {
|
crate::models::Entity {
|
||||||
name: node_name.clone(),
|
name: node_name.clone(),
|
||||||
entity_type: "Entity".to_string(),
|
entity_type: "Entity".to_string(),
|
||||||
observations: vec!["Auto-created stub entity for relation endpoint".to_string()],
|
observations: vec![
|
||||||
|
"Auto-created stub entity for relation endpoint".to_string(),
|
||||||
|
],
|
||||||
namespace: crate::models::default_namespace(),
|
namespace: crate::models::default_namespace(),
|
||||||
git_branch: None,
|
git_branch: None,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -247,16 +258,28 @@ impl McpTool for CreateRelationsHandler {
|
|||||||
}
|
}
|
||||||
for mut relation in req.relations {
|
for mut relation in req.relations {
|
||||||
if !relation.from.is_empty() && !relation.to.is_empty() {
|
if !relation.from.is_empty() && !relation.to.is_empty() {
|
||||||
relation.relation_type = crate::models::normalize_relation_type(&relation.relation_type);
|
relation.relation_type =
|
||||||
added_relations.push(format!("{} -[{}]-> {}", relation.from, relation.relation_type, relation.to));
|
crate::models::normalize_relation_type(&relation.relation_type);
|
||||||
|
added_relations.push(format!(
|
||||||
|
"{} -[{}]-> {}",
|
||||||
|
relation.from, relation.relation_type, relation.to
|
||||||
|
));
|
||||||
g.relations.push(relation);
|
g.relations.push(relation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut msg = format!("Successfully created {} relation(s):\n{}", added_relations.len(), added_relations.join("\n"));
|
let mut msg = format!(
|
||||||
|
"Successfully created {} relation(s):\n{}",
|
||||||
|
added_relations.len(),
|
||||||
|
added_relations.join("\n")
|
||||||
|
);
|
||||||
if !auto_created.is_empty() {
|
if !auto_created.is_empty() {
|
||||||
msg.push_str(&format!("\nNote: Auto-created {} missing stub entity/entities: {}", auto_created.len(), auto_created.join(", ")));
|
msg.push_str(&format!(
|
||||||
|
"\nNote: Auto-created {} missing stub entity/entities: {}",
|
||||||
|
auto_created.len(),
|
||||||
|
auto_created.join(", ")
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(msg)
|
Ok(msg)
|
||||||
}
|
}
|
||||||
@@ -462,7 +485,7 @@ impl McpTool for ReadGraphHandler {
|
|||||||
let filtered_entities: Vec<(&String, &crate::models::Entity)> = full
|
let filtered_entities: Vec<(&String, &crate::models::Entity)> = full
|
||||||
.entities
|
.entities
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, v)| req.namespace.as_ref().map_or(true, |ns| &v.namespace == ns))
|
.filter(|(_, v)| req.namespace.as_ref().is_none_or(|ns| &v.namespace == ns))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let total_count = filtered_entities.len();
|
let total_count = filtered_entities.len();
|
||||||
@@ -481,7 +504,7 @@ impl McpTool for ReadGraphHandler {
|
|||||||
.relations
|
.relations
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|r| {
|
.filter(|r| {
|
||||||
let ns_match = req.namespace.as_ref().map_or(true, |ns| &r.namespace == ns);
|
let ns_match = req.namespace.as_ref().is_none_or(|ns| &r.namespace == ns);
|
||||||
ns_match && (paged_entities.contains_key(&r.from) || paged_entities.contains_key(&r.to))
|
ns_match && (paged_entities.contains_key(&r.from) || paged_entities.contains_key(&r.to))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -499,7 +522,7 @@ impl McpTool for ReadGraphHandler {
|
|||||||
let rel_vec: Vec<&crate::models::Relation> = full
|
let rel_vec: Vec<&crate::models::Relation> = full
|
||||||
.relations
|
.relations
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|r| req.namespace.as_ref().map_or(true, |ns| &r.namespace == ns))
|
.filter(|r| req.namespace.as_ref().is_none_or(|ns| &r.namespace == ns))
|
||||||
.collect();
|
.collect();
|
||||||
(ent_map, rel_vec, None)
|
(ent_map, rel_vec, None)
|
||||||
};
|
};
|
||||||
@@ -520,7 +543,7 @@ impl McpTool for ReadGraphHandler {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut result_json = serde_json::to_string(&serialized)?;
|
let mut result_json = serde_yaml::to_string(&serialized)?;
|
||||||
|
|
||||||
if let Some(max_tok) = max_tokens {
|
if let Some(max_tok) = max_tokens {
|
||||||
let max_chars = max_tok * 4;
|
let max_chars = max_tok * 4;
|
||||||
@@ -549,21 +572,19 @@ impl McpTool for ReadGraphHandler {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(meta) = &meta_json {
|
if let Some(meta) = &meta_json
|
||||||
if let Some(obj) = truncated_val.as_object_mut() {
|
&& let Some(obj) = truncated_val.as_object_mut() {
|
||||||
obj.insert("total_count".to_string(), meta["total_count"].clone());
|
obj.insert("total_count".to_string(), meta["total_count"].clone());
|
||||||
obj.insert("offset".to_string(), meta["offset"].clone());
|
obj.insert("offset".to_string(), meta["offset"].clone());
|
||||||
obj.insert("limit".to_string(), meta["limit"].clone());
|
obj.insert("limit".to_string(), meta["limit"].clone());
|
||||||
obj.insert("has_more".to_string(), serde_json::Value::Bool(true));
|
obj.insert("has_more".to_string(), serde_json::Value::Bool(true));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(trunc_str) = serde_json::to_string(&truncated_val) {
|
if let Ok(trunc_str) = serde_yaml::to_string(&truncated_val)
|
||||||
if trunc_str.len() <= max_chars || keep_count == 1 {
|
&& (trunc_str.len() <= max_chars || keep_count == 1) {
|
||||||
result_json = trunc_str;
|
result_json = trunc_str;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
keep_count = keep_count.saturating_sub(keep_count / 3 + 1);
|
keep_count = keep_count.saturating_sub(keep_count / 3 + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -618,7 +639,7 @@ impl McpTool for SearchNodesHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok::<String, crate::error::AppError>(serde_json::to_string(&matched_entities)?)
|
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&matched_entities)?)
|
||||||
})?;
|
})?;
|
||||||
Ok(data)
|
Ok(data)
|
||||||
}
|
}
|
||||||
@@ -659,7 +680,7 @@ impl McpTool for OpenNodesHandler {
|
|||||||
result.entities.insert(name, e);
|
result.entities.insert(name, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok::<String, crate::error::AppError>(serde_json::to_string(&result)?)
|
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&result)?)
|
||||||
})?;
|
})?;
|
||||||
Ok(data)
|
Ok(data)
|
||||||
}
|
}
|
||||||
@@ -804,7 +825,7 @@ impl McpTool for CondenseEntityHandler {
|
|||||||
req.entity_name,
|
req.entity_name,
|
||||||
unique_obs.join("\n- ")
|
unique_obs.join("\n- ")
|
||||||
);
|
);
|
||||||
if let Ok(summary) = state.ollama.generate(&prompt, None, None).await {
|
if let Ok(summary) = state.ollama.generate(&prompt, None, None, None).await {
|
||||||
let lines: Vec<String> = summary
|
let lines: Vec<String> = summary
|
||||||
.lines()
|
.lines()
|
||||||
.map(|l| l.trim().trim_start_matches('-').trim().to_string())
|
.map(|l| l.trim().trim_start_matches('-').trim().to_string())
|
||||||
@@ -820,7 +841,10 @@ impl McpTool for CondenseEntityHandler {
|
|||||||
e.observations = unique_obs;
|
e.observations = unique_obs;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return Ok(format!("Entity '{}' observations condensed with deduplication/AI summary.", req.entity_name));
|
return Ok(format!(
|
||||||
|
"Entity '{}' observations condensed with deduplication/AI summary.",
|
||||||
|
req.entity_name
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
state.modify_graph(|master| {
|
state.modify_graph(|master| {
|
||||||
@@ -931,7 +955,7 @@ impl McpTool for FindOrphansHandler {
|
|||||||
.cloned()
|
.cloned()
|
||||||
.collect::<Vec<String>>()
|
.collect::<Vec<String>>()
|
||||||
});
|
});
|
||||||
let data = Ok::<String, crate::error::AppError>(serde_json::to_string(&orphans)?)?;
|
let data = Ok::<String, crate::error::AppError>(serde_yaml::to_string(&orphans)?)?;
|
||||||
Ok(data)
|
Ok(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -953,10 +977,9 @@ impl McpTool for GetSubgraphHandler {
|
|||||||
|
|
||||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||||
let req: GetSubgraphTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
let req: GetSubgraphTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||||
let root = req
|
let root = req.root_entity.ok_or_else(|| {
|
||||||
.root_entity
|
crate::error::AppError::Internal("root_entity is required".to_string())
|
||||||
.or(req.root_node)
|
})?;
|
||||||
.ok_or_else(|| crate::error::AppError::Internal("root_entity or root_node is required".to_string()))?;
|
|
||||||
let depth = req.depth.unwrap_or(2);
|
let depth = req.depth.unwrap_or(2);
|
||||||
let format = req.format.unwrap_or(SubgraphFormat::Json);
|
let format = req.format.unwrap_or(SubgraphFormat::Json);
|
||||||
|
|
||||||
@@ -974,12 +997,12 @@ impl McpTool for GetSubgraphHandler {
|
|||||||
visited.insert(root.clone());
|
visited.insert(root.clone());
|
||||||
|
|
||||||
while let Some((curr, d)) = queue.pop_front() {
|
while let Some((curr, d)) = queue.pop_front() {
|
||||||
if d < depth {
|
if d < depth
|
||||||
if let Some(neighbors) = adj.get(curr) {
|
&& let Some(neighbors) = adj.get(curr)
|
||||||
for &next in neighbors {
|
{
|
||||||
if visited.insert(next.to_string()) {
|
for &next in neighbors {
|
||||||
queue.push_back((next, d + 1));
|
if visited.insert(next.to_string()) {
|
||||||
}
|
queue.push_back((next, d + 1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1011,19 +1034,22 @@ impl McpTool for GetSubgraphHandler {
|
|||||||
|
|
||||||
markdown.push_str("\n## Relations\n");
|
markdown.push_str("\n## Relations\n");
|
||||||
for r in &matched_relations {
|
for r in &matched_relations {
|
||||||
markdown.push_str(&format!("- `{}` --[{}]--> `{}`\n", r.from, r.relation_type, r.to));
|
markdown.push_str(&format!(
|
||||||
|
"- `{}` --[{}]--> `{}`\n",
|
||||||
|
r.from, r.relation_type, r.to
|
||||||
|
));
|
||||||
}
|
}
|
||||||
return Ok(markdown);
|
return Ok(markdown);
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = serde_json::json!({
|
let result = serde_json::json!({
|
||||||
"root_node": root,
|
"root_entity": root,
|
||||||
"depth": depth,
|
"depth": depth,
|
||||||
"entities": matched_entities,
|
"entities": matched_entities,
|
||||||
"relations": matched_relations,
|
"relations": matched_relations,
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(serde_json::to_string_pretty(&result)?)
|
Ok(serde_yaml::to_string(&result)?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1038,15 +1064,20 @@ impl McpTool for SweepGraphHealthHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn schema(&self) -> Value {
|
fn schema(&self) -> Value {
|
||||||
crate::mcp::tool_def::<SweepGraphHealthTool>("sweep_graph_health", "Audit and sweep graph health")
|
crate::mcp::tool_def::<SweepGraphHealthTool>(
|
||||||
|
"sweep_graph_health",
|
||||||
|
"Audit and sweep graph health",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||||
let req: SweepGraphHealthTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
let req: SweepGraphHealthTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||||
let auto_prune = req.auto_prune_orphans.unwrap_or(false);
|
let auto_prune = req.auto_prune_orphans.unwrap_or(false);
|
||||||
|
let auto_prune_stale = req.auto_prune_stale_files.unwrap_or(false);
|
||||||
|
|
||||||
let mut orphans = Vec::new();
|
let mut orphans = Vec::new();
|
||||||
let mut duplicates = Vec::new();
|
let mut duplicates = Vec::new();
|
||||||
|
let mut stale_entities = Vec::new();
|
||||||
|
|
||||||
state.modify_graph(|g| {
|
state.modify_graph(|g| {
|
||||||
// 1. Identify Orphans
|
// 1. Identify Orphans
|
||||||
@@ -1062,12 +1093,31 @@ impl McpTool for SweepGraphHealthHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ADR-0111: Automated Stale Symbol Pruning & Graph Tombstoning
|
||||||
|
for (name, entity) in g.entities.iter() {
|
||||||
|
if entity.entity_type == "File" || name.contains(".rs") || name.contains(".ts") || name.contains(".js") || name.contains(".py") {
|
||||||
|
let check_path = entity.file_path.as_deref().unwrap_or(name.as_str());
|
||||||
|
// Strip any symbol qualifiers like File::symbol
|
||||||
|
let base_file = check_path.split("::").next().unwrap_or(check_path);
|
||||||
|
if (base_file.contains('/') || base_file.contains('\\') || base_file.ends_with(".rs") || base_file.ends_with(".ts")) && !std::path::Path::new(base_file).exists() {
|
||||||
|
stale_entities.push(name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if auto_prune {
|
if auto_prune {
|
||||||
for orphan in &orphans {
|
for orphan in &orphans {
|
||||||
g.entities.remove(orphan);
|
g.entities.remove(orphan);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if auto_prune_stale {
|
||||||
|
for stale in &stale_entities {
|
||||||
|
g.entities.remove(stale);
|
||||||
|
g.relations.retain(|r| &r.from != stale && &r.to != stale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Compute similarity pairs for duplicate detection using pre-computed lowercase names
|
// 2. Compute similarity pairs for duplicate detection using pre-computed lowercase names
|
||||||
let names: Vec<_> = g.entities.keys().cloned().collect();
|
let names: Vec<_> = g.entities.keys().cloned().collect();
|
||||||
let lower_names: Vec<String> = names.iter().map(|n| n.to_lowercase()).collect();
|
let lower_names: Vec<String> = names.iter().map(|n| n.to_lowercase()).collect();
|
||||||
@@ -1092,11 +1142,13 @@ impl McpTool for SweepGraphHealthHandler {
|
|||||||
let report = serde_json::json!({
|
let report = serde_json::json!({
|
||||||
"orphaned_entities": orphans,
|
"orphaned_entities": orphans,
|
||||||
"orphans_pruned": auto_prune,
|
"orphans_pruned": auto_prune,
|
||||||
|
"stale_entities": stale_entities,
|
||||||
|
"stale_pruned": auto_prune_stale,
|
||||||
"potential_duplicates": duplicates,
|
"potential_duplicates": duplicates,
|
||||||
"health_score": if orphans.is_empty() && duplicates.is_empty() { "100%" } else { "Needs Maintenance" }
|
"health_score": if orphans.is_empty() && duplicates.is_empty() && stale_entities.is_empty() { "100%" } else { "Needs Maintenance" }
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(serde_json::to_string_pretty(&report)?)
|
Ok(serde_yaml::to_string(&report)?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1109,26 +1161,33 @@ impl McpTool for ResolveStaleSymbolsHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn schema(&self) -> Value {
|
fn schema(&self) -> Value {
|
||||||
crate::mcp::tool_def::<ResolveStaleSymbolsTool>("resolve_stale_symbols", "Inspect Knowledge Graph entities and tech debt symbol/line references against files on disk, healing stale pointers.")
|
crate::mcp::tool_def::<ResolveStaleSymbolsTool>(
|
||||||
|
"resolve_stale_symbols",
|
||||||
|
"Inspect Knowledge Graph entities and tech debt symbol/line references against files on disk, healing stale pointers.",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||||
let req: ResolveStaleSymbolsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
let req: ResolveStaleSymbolsTool =
|
||||||
|
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||||
let auto_heal = req.auto_heal.unwrap_or(true);
|
let auto_heal = req.auto_heal.unwrap_or(true);
|
||||||
|
|
||||||
let mut checked = 0;
|
let mut checked = 0;
|
||||||
let mut stale_entities = Vec::new();
|
let mut stale_entities = Vec::new();
|
||||||
|
|
||||||
state.modify_graph(|g| {
|
state.modify_graph(|g| {
|
||||||
for (name, _entity) in g.entities.iter_mut() {
|
for name in g.entities.keys() {
|
||||||
if let Some(target) = &req.target {
|
if let Some(target) = &req.target
|
||||||
if name != target && !name.contains(target) {
|
&& name != target
|
||||||
continue;
|
&& !name.contains(target)
|
||||||
}
|
{
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
checked += 1;
|
checked += 1;
|
||||||
let path = std::path::Path::new(name);
|
let path = std::path::Path::new(name);
|
||||||
if (name.contains('/') || name.contains('\\') || name.contains('.')) && !path.exists() {
|
if (name.contains('/') || name.contains('\\') || name.contains('.'))
|
||||||
|
&& !path.exists()
|
||||||
|
{
|
||||||
stale_entities.push(name.clone());
|
stale_entities.push(name.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1148,7 +1207,7 @@ impl McpTool for ResolveStaleSymbolsHandler {
|
|||||||
"status": "success"
|
"status": "success"
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(serde_json::to_string_pretty(&report)?)
|
Ok(serde_yaml::to_string(&report)?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1161,7 +1220,10 @@ impl McpTool for SummarizeSubgraphHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn schema(&self) -> Value {
|
fn schema(&self) -> Value {
|
||||||
crate::mcp::tool_def::<SummarizeSubgraphTool>("summarize_subgraph", "Generate compact, LLM-optimized Markdown topology of a graph component capped within a token budget.")
|
crate::mcp::tool_def::<SummarizeSubgraphTool>(
|
||||||
|
"summarize_subgraph",
|
||||||
|
"Generate compact, LLM-optimized Markdown topology of a graph component capped within a token budget.",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||||
@@ -1189,8 +1251,18 @@ impl McpTool for SummarizeSubgraphHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let ent: Vec<_> = g.entities.values().filter(|e| visited.contains(&e.name)).cloned().collect();
|
let ent: Vec<_> = g
|
||||||
let rel: Vec<_> = g.relations.iter().filter(|r| visited.contains(&r.from) && visited.contains(&r.to)).cloned().collect();
|
.entities
|
||||||
|
.values()
|
||||||
|
.filter(|e| visited.contains(&e.name))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let rel: Vec<_> = g
|
||||||
|
.relations
|
||||||
|
.iter()
|
||||||
|
.filter(|r| visited.contains(&r.from) && visited.contains(&r.to))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
(ent, rel)
|
(ent, rel)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1205,7 +1277,10 @@ impl McpTool for SummarizeSubgraphHandler {
|
|||||||
|
|
||||||
markdown.push_str("\n## Relations\n");
|
markdown.push_str("\n## Relations\n");
|
||||||
for r in &sub_relations {
|
for r in &sub_relations {
|
||||||
markdown.push_str(&format!("- `{}` --[{}]--> `{}`\n", r.from, r.relation_type, r.to));
|
markdown.push_str(&format!(
|
||||||
|
"- `{}` --[{}]--> `{}`\n",
|
||||||
|
r.from, r.relation_type, r.to
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let max_tokens = req.max_tokens.unwrap_or(1000);
|
let max_tokens = req.max_tokens.unwrap_or(1000);
|
||||||
@@ -1226,7 +1301,6 @@ mod tests {
|
|||||||
use crate::handlers::meta::AgentSignalsHandler;
|
use crate::handlers::meta::AgentSignalsHandler;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_create_and_read_entities() {
|
async fn test_create_and_read_entities() {
|
||||||
let state = Arc::new(MemoryState::new_in_memory());
|
let state = Arc::new(MemoryState::new_in_memory());
|
||||||
@@ -1301,10 +1375,7 @@ mod tests {
|
|||||||
{"source": "A", "target": "B", "relationType": "knows"}
|
{"source": "A", "target": "B", "relationType": "knows"}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
let alias_res = handler
|
let alias_res = handler.execute(alias_args, state.clone()).await.unwrap();
|
||||||
.execute(alias_args, state.clone())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(alias_res.contains("Successfully created 1 relation(s)"));
|
assert!(alias_res.contains("Successfully created 1 relation(s)"));
|
||||||
|
|
||||||
// Test semantic LLM schema feedback on missing fields
|
// Test semantic LLM schema feedback on missing fields
|
||||||
@@ -1476,7 +1547,10 @@ mod tests {
|
|||||||
{"name": "Beta", "entity_type": "Node", "observations": ["Obs3"], "namespace": "global"}
|
{"name": "Beta", "entity_type": "Node", "observations": ["Obs3"], "namespace": "global"}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
create_handler.execute(args_ent, state.clone()).await.unwrap();
|
create_handler
|
||||||
|
.execute(args_ent, state.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let rel_handler = CreateRelationsHandler;
|
let rel_handler = CreateRelationsHandler;
|
||||||
let args_rel = json!({
|
let args_rel = json!({
|
||||||
@@ -1487,57 +1561,94 @@ mod tests {
|
|||||||
rel_handler.execute(args_rel, state.clone()).await.unwrap();
|
rel_handler.execute(args_rel, state.clone()).await.unwrap();
|
||||||
|
|
||||||
let path_handler = QueryGraphPathHandler;
|
let path_handler = QueryGraphPathHandler;
|
||||||
let path_res = path_handler.execute(json!({"start_node": "Alpha", "end_node": "Beta"}), state.clone()).await.unwrap();
|
let path_res = path_handler
|
||||||
|
.execute(
|
||||||
|
json!({"start_node": "Alpha", "end_node": "Beta"}),
|
||||||
|
state.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert!(path_res.contains("Alpha"));
|
assert!(path_res.contains("Alpha"));
|
||||||
|
|
||||||
let sub_handler = GetSubgraphHandler;
|
let sub_handler = GetSubgraphHandler;
|
||||||
let sub_res = sub_handler.execute(json!({"root_entity": "Alpha"}), state.clone()).await.unwrap();
|
let sub_res = sub_handler
|
||||||
|
.execute(json!({"root_entity": "Alpha"}), state.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert!(sub_res.contains("Alpha"));
|
assert!(sub_res.contains("Alpha"));
|
||||||
|
|
||||||
let sum_handler = SummarizeSubgraphHandler;
|
let sum_handler = SummarizeSubgraphHandler;
|
||||||
let sum_res = sum_handler.execute(json!({"root_entity": "Alpha"}), state.clone()).await.unwrap();
|
let sum_res = sum_handler
|
||||||
|
.execute(json!({"root_entity": "Alpha"}), state.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert!(!sum_res.is_empty());
|
assert!(!sum_res.is_empty());
|
||||||
|
|
||||||
|
|
||||||
let sweep_handler = SweepGraphHealthHandler;
|
let sweep_handler = SweepGraphHealthHandler;
|
||||||
let sweep_res = sweep_handler.execute(json!({}), state.clone()).await.unwrap();
|
let sweep_res = sweep_handler
|
||||||
|
.execute(json!({}), state.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert!(!sweep_res.is_empty());
|
assert!(!sweep_res.is_empty());
|
||||||
|
|
||||||
let del_obs_handler = DeleteObservationsHandler;
|
let del_obs_handler = DeleteObservationsHandler;
|
||||||
let del_obs_res = del_obs_handler.execute(json!({
|
let del_obs_res = del_obs_handler
|
||||||
"deletions": [{"entity_name": "Alpha", "observations": ["Obs1"]}]
|
.execute(
|
||||||
}), state.clone()).await.unwrap();
|
json!({
|
||||||
|
"deletions": [{"entity_name": "Alpha", "observations": ["Obs1"]}]
|
||||||
|
}),
|
||||||
|
state.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(del_obs_res, "Observations deleted");
|
assert_eq!(del_obs_res, "Observations deleted");
|
||||||
|
|
||||||
let del_rel_handler = DeleteRelationsHandler;
|
let del_rel_handler = DeleteRelationsHandler;
|
||||||
let del_rel_res = del_rel_handler.execute(json!({
|
let del_rel_res = del_rel_handler
|
||||||
"relations": [{"from": "Alpha", "to": "Beta", "relation_type": "links_to"}]
|
.execute(
|
||||||
}), state.clone()).await.unwrap();
|
json!({
|
||||||
|
"relations": [{"from": "Alpha", "to": "Beta", "relation_type": "links_to"}]
|
||||||
|
}),
|
||||||
|
state.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(del_rel_res, "Relations deleted");
|
assert_eq!(del_rel_res, "Relations deleted");
|
||||||
|
|
||||||
let bcast_handler = AgentSignalsHandler;
|
let bcast_handler = AgentSignalsHandler;
|
||||||
let bcast_res = bcast_handler.execute(json!({
|
let bcast_res = bcast_handler
|
||||||
"action": "broadcast",
|
.execute(
|
||||||
"sender": "agent1",
|
json!({
|
||||||
"signal_type": "task_completed",
|
"action": "broadcast",
|
||||||
"payload": "fix_bug"
|
"sender": "agent1",
|
||||||
}), state.clone()).await.unwrap();
|
"signal_type": "task_completed",
|
||||||
|
"payload": "fix_bug"
|
||||||
|
}),
|
||||||
|
state.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert!(bcast_res.contains("Broadcasted signal"));
|
assert!(bcast_res.contains("Broadcasted signal"));
|
||||||
|
|
||||||
let qsignal_handler = AgentSignalsHandler;
|
let qsignal_handler = AgentSignalsHandler;
|
||||||
let qsignal_res = qsignal_handler.execute(json!({
|
let qsignal_res = qsignal_handler
|
||||||
"action": "query",
|
.execute(
|
||||||
"sender": "agent1"
|
json!({
|
||||||
}), state.clone()).await.unwrap();
|
"action": "query",
|
||||||
|
"sender": "agent1"
|
||||||
|
}),
|
||||||
|
state.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert!(qsignal_res.contains("task_completed"));
|
assert!(qsignal_res.contains("task_completed"));
|
||||||
let read_paged_handler = ReadGraphHandler;
|
let read_paged_handler = ReadGraphHandler;
|
||||||
let paged_res = read_paged_handler
|
let paged_res = read_paged_handler
|
||||||
.execute(json!({"limit": 1, "offset": 0}), state.clone())
|
.execute(json!({"limit": 1, "offset": 0}), state.clone())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let parsed_paged: serde_json::Value = serde_json::from_str(&paged_res).unwrap();
|
let parsed_paged: serde_json::Value = serde_yaml::from_str(&paged_res).unwrap();
|
||||||
assert_eq!(parsed_paged["total_count"], 2);
|
assert_eq!(parsed_paged["total_count"], 2);
|
||||||
assert_eq!(parsed_paged["has_more"], true);
|
assert_eq!(parsed_paged["has_more"], true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+24
-22
@@ -38,32 +38,34 @@ impl McpTool for ProcessLogsHandler {
|
|||||||
}
|
}
|
||||||
ProcessLogAction::Get => {
|
ProcessLogAction::Get => {
|
||||||
let max_lines = tool_args.max_lines.unwrap_or(100);
|
let max_lines = tool_args.max_lines.unwrap_or(100);
|
||||||
let result = tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
|
let result =
|
||||||
let mut file = File::open(&safe_path).map_err(|e| {
|
tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
|
||||||
crate::error::AppError::Internal(format!("Failed to open file: {}", e))
|
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 len = file.metadata().map_err(|e| e.to_string())?.len();
|
||||||
|
|
||||||
let read_size = std::cmp::min(16 * 1024, len);
|
let read_size = std::cmp::min(16 * 1024, len);
|
||||||
file.seek(SeekFrom::End(-(read_size as i64)))
|
file.seek(SeekFrom::End(-(read_size as i64)))
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let mut vec_buf = Vec::new();
|
let mut vec_buf = Vec::new();
|
||||||
file.read_to_end(&mut vec_buf)
|
file.read_to_end(&mut vec_buf).map_err(|e| e.to_string())?;
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let buffer = String::from_utf8_lossy(&vec_buf).to_string();
|
let buffer = String::from_utf8_lossy(&vec_buf).to_string();
|
||||||
let lines: Vec<&str> = buffer.lines().collect();
|
let lines: Vec<&str> = buffer.lines().collect();
|
||||||
let recent_lines = if lines.len() > max_lines {
|
let recent_lines = if lines.len() > max_lines {
|
||||||
lines[lines.len() - max_lines..].join("\n")
|
lines[lines.len() - max_lines..].join("\n")
|
||||||
} else {
|
} else {
|
||||||
buffer
|
buffer
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(recent_lines)
|
Ok(recent_lines)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??;
|
.map_err(|e| {
|
||||||
|
crate::error::AppError::Internal(format!("Task panic: {}", e))
|
||||||
|
})??;
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|||||||
+250
-145
@@ -33,7 +33,7 @@ impl McpTool for LogErrorFixHandler {
|
|||||||
);
|
);
|
||||||
if let Ok(summary) = state
|
if let Ok(summary) = state
|
||||||
.ollama
|
.ollama
|
||||||
.generate(&prompt, Some(&state.ollama.reasoning_model), None)
|
.generate(&prompt, Some(&state.ollama.reasoning_model), None, None)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
let clean = summary.trim();
|
let clean = summary.trim();
|
||||||
@@ -110,10 +110,10 @@ impl McpTool for SearchErrorFixesHandler {
|
|||||||
} else if st_lower.contains(&sol_lower) || sol_lower.contains(&st_lower) {
|
} else if st_lower.contains(&sol_lower) || sol_lower.contains(&st_lower) {
|
||||||
score += 0.5;
|
score += 0.5;
|
||||||
}
|
}
|
||||||
if let Some(emb) = &f.embedding {
|
if let Some(emb) = &f.embedding
|
||||||
if !query_emb.is_empty() {
|
&& !query_emb.is_empty()
|
||||||
score += crate::embedding::cosine_similarity(&query_emb, emb);
|
{
|
||||||
}
|
score += crate::embedding::cosine_similarity(&query_emb, emb);
|
||||||
}
|
}
|
||||||
(f, score)
|
(f, score)
|
||||||
})
|
})
|
||||||
@@ -143,7 +143,7 @@ impl McpTool for SearchErrorFixesHandler {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&suggestions)?)
|
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&suggestions)?)
|
||||||
})?;
|
})?;
|
||||||
return Ok(data);
|
return Ok(data);
|
||||||
}
|
}
|
||||||
@@ -170,7 +170,7 @@ impl McpTool for SearchErrorFixesHandler {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&filtered)?)
|
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&filtered)?)
|
||||||
})?;
|
})?;
|
||||||
Ok(data)
|
Ok(data)
|
||||||
}
|
}
|
||||||
@@ -198,10 +198,10 @@ impl McpTool for LogCodeChangeHandler {
|
|||||||
if let Some(range) = &req.line_range {
|
if let Some(range) = &req.line_range {
|
||||||
description = format!("{} [Line Range: {}]", description, range);
|
description = format!("{} [Line Range: {}]", description, range);
|
||||||
}
|
}
|
||||||
if let Some(symbols) = &req.symbol_references {
|
if let Some(symbols) = &req.symbol_references
|
||||||
if !symbols.is_empty() {
|
&& !symbols.is_empty()
|
||||||
description = format!("{} [Symbols: {}]", description, symbols.join(", "));
|
{
|
||||||
}
|
description = format!("{} [Symbols: {}]", description, symbols.join(", "));
|
||||||
}
|
}
|
||||||
|
|
||||||
if state.ollama.is_available().await {
|
if state.ollama.is_available().await {
|
||||||
@@ -209,7 +209,7 @@ impl McpTool for LogCodeChangeHandler {
|
|||||||
"Summarize in 1 concise sentence the architectural impact of changing file '{}': {}",
|
"Summarize in 1 concise sentence the architectural impact of changing file '{}': {}",
|
||||||
req.file_path, description
|
req.file_path, description
|
||||||
);
|
);
|
||||||
if let Ok(summary) = state.ollama.generate(&prompt, None, None).await {
|
if let Ok(summary) = state.ollama.generate(&prompt, None, None, None).await {
|
||||||
let clean = summary.trim();
|
let clean = summary.trim();
|
||||||
if !clean.is_empty() {
|
if !clean.is_empty() {
|
||||||
description = format!("{} (AI Summary: {})", description, clean);
|
description = format!("{} (AI Summary: {})", description, clean);
|
||||||
@@ -246,13 +246,12 @@ impl McpTool for LogCodeChangeHandler {
|
|||||||
let detected_vcs = if let Some(vcs) = req.vcs_type.clone() {
|
let detected_vcs = if let Some(vcs) = req.vcs_type.clone() {
|
||||||
Some(vcs)
|
Some(vcs)
|
||||||
} else if let Some(ref rev) = effective_rev {
|
} else if let Some(ref rev) = effective_rev {
|
||||||
if rev.starts_with('r') && rev[1..].chars().all(|c| c.is_ascii_digit()) {
|
if (rev.starts_with('r') && rev[1..].chars().all(|c| c.is_ascii_digit()))
|
||||||
Some("svn".to_string())
|
|| req
|
||||||
} else if req
|
.repo_url
|
||||||
.repo_url
|
.as_deref()
|
||||||
.as_deref()
|
.map(|u| u.contains("/svn/"))
|
||||||
.map(|u| u.contains("/svn/"))
|
.unwrap_or(false)
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
{
|
||||||
Some("svn".to_string())
|
Some("svn".to_string())
|
||||||
} else {
|
} else {
|
||||||
@@ -323,13 +322,14 @@ impl McpTool for QueryRecentChangesHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||||
let req: QueryRecentChangesTool = serde_json::from_value(args).unwrap_or(QueryRecentChangesTool {
|
let req: QueryRecentChangesTool =
|
||||||
namespace: None,
|
serde_json::from_value(args).unwrap_or(QueryRecentChangesTool {
|
||||||
repo_name: None,
|
namespace: None,
|
||||||
vcs_type: None,
|
repo_name: None,
|
||||||
limit: None,
|
vcs_type: None,
|
||||||
offset: None,
|
limit: None,
|
||||||
});
|
offset: None,
|
||||||
|
});
|
||||||
let limit = req.limit.unwrap_or(50);
|
let limit = req.limit.unwrap_or(50);
|
||||||
let offset = req.offset.unwrap_or(0);
|
let offset = req.offset.unwrap_or(0);
|
||||||
let data = state.code.ledger.read_with(|l| {
|
let data = state.code.ledger.read_with(|l| {
|
||||||
@@ -337,24 +337,23 @@ impl McpTool for QueryRecentChangesHandler {
|
|||||||
.iter()
|
.iter()
|
||||||
.rev()
|
.rev()
|
||||||
.filter(|c| {
|
.filter(|c| {
|
||||||
if let Some(ns) = &req.namespace {
|
if let Some(ns) = &req.namespace
|
||||||
if !c.namespace.eq_ignore_ascii_case(ns) {
|
&& !c.namespace.eq_ignore_ascii_case(ns)
|
||||||
return false;
|
{
|
||||||
}
|
return false;
|
||||||
}
|
}
|
||||||
if let Some(repo) = &req.repo_name {
|
if let Some(repo) = &req.repo_name
|
||||||
if c.repo_name
|
&& c.repo_name
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|rn| !rn.eq_ignore_ascii_case(repo))
|
.map(|rn| !rn.eq_ignore_ascii_case(repo))
|
||||||
.unwrap_or(true)
|
.unwrap_or(true)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if let Some(vcs) = &req.vcs_type {
|
if let Some(vcs) = &req.vcs_type
|
||||||
if !c.effective_vcs().eq_ignore_ascii_case(vcs) {
|
&& !c.effective_vcs().eq_ignore_ascii_case(vcs)
|
||||||
return false;
|
{
|
||||||
}
|
return false;
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
})
|
})
|
||||||
@@ -362,7 +361,7 @@ impl McpTool for QueryRecentChangesHandler {
|
|||||||
.take(limit)
|
.take(limit)
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
Ok::<String, crate::error::AppError>(serde_json::to_string(&filtered)?)
|
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&filtered)?)
|
||||||
})?;
|
})?;
|
||||||
Ok(data)
|
Ok(data)
|
||||||
}
|
}
|
||||||
@@ -386,7 +385,7 @@ impl McpTool for DecisionsHandler {
|
|||||||
let req: DecisionsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
let req: DecisionsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||||
let ns = req
|
let ns = req
|
||||||
.namespace
|
.namespace
|
||||||
.unwrap_or_else(|| crate::models::default_namespace());
|
.unwrap_or_else(crate::models::default_namespace);
|
||||||
match req.action {
|
match req.action {
|
||||||
DecisionAction::Log => {
|
DecisionAction::Log => {
|
||||||
let title = req.title.ok_or_else(|| {
|
let title = req.title.ok_or_else(|| {
|
||||||
@@ -464,13 +463,14 @@ impl McpTool for DecisionsHandler {
|
|||||||
state.code.adrs.modify(|adrs| {
|
state.code.adrs.modify(|adrs| {
|
||||||
let target_pos = adrs.iter().position(|a| a.id.eq_ignore_ascii_case(&id));
|
let target_pos = adrs.iter().position(|a| a.id.eq_ignore_ascii_case(&id));
|
||||||
if let Some(pos) = target_pos {
|
if let Some(pos) = target_pos {
|
||||||
if let Some(superseded_id) = &req.supersedes {
|
if let Some(superseded_id) = &req.supersedes
|
||||||
if let Some(s_pos) = adrs.iter().position(|a| a.id.eq_ignore_ascii_case(superseded_id)) {
|
&& let Some(s_pos) = adrs
|
||||||
if s_pos != pos {
|
.iter()
|
||||||
adrs[s_pos].status = "superseded".to_string();
|
.position(|a| a.id.eq_ignore_ascii_case(superseded_id))
|
||||||
adrs_to_index.push(adrs[s_pos].clone());
|
&& s_pos != pos
|
||||||
}
|
{
|
||||||
}
|
adrs[s_pos].status = "superseded".to_string();
|
||||||
|
adrs_to_index.push(adrs[s_pos].clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
let a = &mut adrs[pos];
|
let a = &mut adrs[pos];
|
||||||
@@ -488,7 +488,9 @@ impl McpTool for DecisionsHandler {
|
|||||||
}
|
}
|
||||||
if let Some(s) = req.status {
|
if let Some(s) = req.status {
|
||||||
let s_lower = s.to_ascii_lowercase();
|
let s_lower = s.to_ascii_lowercase();
|
||||||
if (s_lower == "implemented" || s_lower == "resolved") && a.resolved_at.is_none() {
|
if (s_lower == "implemented" || s_lower == "resolved")
|
||||||
|
&& a.resolved_at.is_none()
|
||||||
|
{
|
||||||
a.resolved_at = Some(crate::handlers::utils::now_secs());
|
a.resolved_at = Some(crate::handlers::utils::now_secs());
|
||||||
} else if s_lower != "implemented" && s_lower != "resolved" {
|
} else if s_lower != "implemented" && s_lower != "resolved" {
|
||||||
a.resolved_at = None;
|
a.resolved_at = None;
|
||||||
@@ -535,11 +537,15 @@ impl McpTool for DecisionsHandler {
|
|||||||
&format!("Updated {}: {}", adr.id, adr.title),
|
&format!("Updated {}: {}", adr.id, adr.title),
|
||||||
Some(&adr.status),
|
Some(&adr.status),
|
||||||
);
|
);
|
||||||
Ok(format!("Updated decision {}: {} (status: {})", adr.id, adr.title, adr.status))
|
Ok(format!(
|
||||||
} else {
|
"Updated decision {}: {} (status: {})",
|
||||||
Err(crate::error::AppError::Internal(
|
adr.id, adr.title, adr.status
|
||||||
format!("Decision with id '{}' not found", id),
|
|
||||||
))
|
))
|
||||||
|
} else {
|
||||||
|
Err(crate::error::AppError::Internal(format!(
|
||||||
|
"Decision with id '{}' not found",
|
||||||
|
id
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DecisionAction::Query => {
|
DecisionAction::Query => {
|
||||||
@@ -554,9 +560,17 @@ impl McpTool for DecisionsHandler {
|
|||||||
}
|
}
|
||||||
if let Some(q) = &req.query {
|
if let Some(q) = &req.query {
|
||||||
crate::handlers::utils::contains_ignore_ascii_case(&a.title, q)
|
crate::handlers::utils::contains_ignore_ascii_case(&a.title, q)
|
||||||
|| crate::handlers::utils::contains_ignore_ascii_case(&a.context, q)
|
|| crate::handlers::utils::contains_ignore_ascii_case(
|
||||||
|| crate::handlers::utils::contains_ignore_ascii_case(&a.decision, q)
|
&a.context, q,
|
||||||
|| crate::handlers::utils::contains_ignore_ascii_case(&a.consequence, q)
|
)
|
||||||
|
|| crate::handlers::utils::contains_ignore_ascii_case(
|
||||||
|
&a.decision,
|
||||||
|
q,
|
||||||
|
)
|
||||||
|
|| crate::handlers::utils::contains_ignore_ascii_case(
|
||||||
|
&a.consequence,
|
||||||
|
q,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -565,7 +579,7 @@ impl McpTool for DecisionsHandler {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if include_body {
|
if include_body {
|
||||||
Ok::<String, crate::error::AppError>(serde_json::to_string(&filtered)?)
|
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&filtered)?)
|
||||||
} else {
|
} else {
|
||||||
let compact: Vec<_> = filtered
|
let compact: Vec<_> = filtered
|
||||||
.iter()
|
.iter()
|
||||||
@@ -582,7 +596,7 @@ impl McpTool for DecisionsHandler {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok::<String, crate::error::AppError>(serde_json::to_string(&compact)?)
|
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&compact)?)
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
Ok(data)
|
Ok(data)
|
||||||
@@ -632,17 +646,14 @@ impl McpTool for TechDebtHandler {
|
|||||||
let req: TechDebtTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
let req: TechDebtTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||||
let ns = req
|
let ns = req
|
||||||
.namespace
|
.namespace
|
||||||
.unwrap_or_else(|| crate::models::default_namespace());
|
.unwrap_or_else(crate::models::default_namespace);
|
||||||
match req.action {
|
match req.action {
|
||||||
TechDebtAction::Log => {
|
TechDebtAction::Log => {
|
||||||
let desc = req.description.or(req.title).ok_or_else(|| {
|
let desc = req.description.or(req.title).ok_or_else(|| {
|
||||||
crate::error::AppError::Internal("Missing required parameter 'description' for action 'log'. Next step: Provide tech debt 'description' in request and retry.".to_string())
|
crate::error::AppError::Internal("Missing required parameter 'description' for action 'log'. Next step: Provide tech debt 'description' in request and retry.".to_string())
|
||||||
})?;
|
})?;
|
||||||
let ideal = req.ideal_solution.unwrap_or_default();
|
let ideal = req.ideal_solution.unwrap_or_default();
|
||||||
let text_to_embed = format!(
|
let text_to_embed = format!("Description: {}\nIdeal Solution: {}", desc, ideal);
|
||||||
"Description: {}\nIdeal Solution: {}",
|
|
||||||
desc, ideal
|
|
||||||
);
|
|
||||||
let embedding = crate::embedding::generate_embedding_async(text_to_embed)
|
let embedding = crate::embedding::generate_embedding_async(text_to_embed)
|
||||||
.await
|
.await
|
||||||
.ok();
|
.ok();
|
||||||
@@ -665,7 +676,23 @@ impl McpTool for TechDebtHandler {
|
|||||||
effort_estimate: req.effort_estimate,
|
effort_estimate: req.effort_estimate,
|
||||||
});
|
});
|
||||||
if debts.len() > 300 {
|
if debts.len() > 300 {
|
||||||
debts.remove(0);
|
let severity_rank =
|
||||||
|
|sev: Option<&str>| match sev.unwrap_or("").to_lowercase().as_str() {
|
||||||
|
"critical" => 4,
|
||||||
|
"high" => 3,
|
||||||
|
"medium" => 2,
|
||||||
|
"low" => 1,
|
||||||
|
_ => 1,
|
||||||
|
};
|
||||||
|
if let Some((idx_to_remove, _)) =
|
||||||
|
debts.iter().enumerate().min_by_key(|(_, d)| {
|
||||||
|
let status_score = if d.is_resolved { 0 } else { 10 };
|
||||||
|
let sev_score = severity_rank(d.severity.as_deref());
|
||||||
|
(status_score + sev_score, d.created_at)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
debts.remove(idx_to_remove);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Ok("Tech debt logged".to_string())
|
Ok("Tech debt logged".to_string())
|
||||||
@@ -699,9 +726,7 @@ impl McpTool for TechDebtHandler {
|
|||||||
let data = state.code.tech_debts.read_with(|debts| {
|
let data = state.code.tech_debts.read_with(|debts| {
|
||||||
let filtered: Vec<_> = debts
|
let filtered: Vec<_> = debts
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|d| {
|
.filter(|d| d.namespace == ns && (inc || !d.is_resolved))
|
||||||
d.namespace == ns && (inc || !d.is_resolved)
|
|
||||||
})
|
|
||||||
.map(|d| match level {
|
.map(|d| match level {
|
||||||
"compact" => serde_json::json!({
|
"compact" => serde_json::json!({
|
||||||
"id": d.id,
|
"id": d.id,
|
||||||
@@ -717,7 +742,7 @@ impl McpTool for TechDebtHandler {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let mut json_str = serde_json::to_string(&filtered)?;
|
let mut json_str = serde_yaml::to_string(&filtered)?;
|
||||||
if let Some(max_t) = req.max_tokens {
|
if let Some(max_t) = req.max_tokens {
|
||||||
let char_limit = max_t * 4;
|
let char_limit = max_t * 4;
|
||||||
if json_str.len() > char_limit {
|
if json_str.len() > char_limit {
|
||||||
@@ -765,16 +790,7 @@ impl McpTool for OmniSearchHandler {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Reciprocal Rank Fusion (RRF) algorithm
|
// Reciprocal Rank Fusion (RRF) algorithm
|
||||||
#[allow(dead_code)]
|
let mut rrf_scores: std::collections::HashMap<String, (f64, crate::search::SearchResult)> =
|
||||||
#[derive(Clone)]
|
|
||||||
struct MatchItem {
|
|
||||||
id: String,
|
|
||||||
doc_type: String,
|
|
||||||
title: String,
|
|
||||||
body: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut rrf_scores: std::collections::HashMap<String, (f64, MatchItem)> =
|
|
||||||
std::collections::HashMap::new();
|
std::collections::HashMap::new();
|
||||||
|
|
||||||
for (rank, (id, doc_type, title, body, _score)) in keyword_matches.into_iter().enumerate() {
|
for (rank, (id, doc_type, title, body, _score)) in keyword_matches.into_iter().enumerate() {
|
||||||
@@ -783,11 +799,12 @@ impl McpTool for OmniSearchHandler {
|
|||||||
id.clone(),
|
id.clone(),
|
||||||
(
|
(
|
||||||
score,
|
score,
|
||||||
MatchItem {
|
crate::search::SearchResult {
|
||||||
id,
|
id,
|
||||||
doc_type,
|
doc_type,
|
||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
|
score: 0.0,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -799,20 +816,15 @@ impl McpTool for OmniSearchHandler {
|
|||||||
if let Some(existing) = rrf_scores.get_mut(&item_id) {
|
if let Some(existing) = rrf_scores.get_mut(&item_id) {
|
||||||
existing.0 += score;
|
existing.0 += score;
|
||||||
} else {
|
} else {
|
||||||
let item = MatchItem {
|
rrf_scores.insert(item_id, (score, v_match));
|
||||||
id: v_match.id.clone(),
|
|
||||||
doc_type: v_match.doc_type,
|
|
||||||
title: v_match.title,
|
|
||||||
body: v_match.body,
|
|
||||||
};
|
|
||||||
rrf_scores.insert(item_id, (score, item));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut ranked_items: Vec<_> = rrf_scores.into_values().collect();
|
let mut ranked_items: Vec<_> = rrf_scores.into_values().collect();
|
||||||
ranked_items.sort_by(|a, b| b.0.total_cmp(&a.0));
|
ranked_items.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||||
|
|
||||||
let matches: Vec<MatchItem> = ranked_items.into_iter().map(|(_, item)| item).collect();
|
let matches: Vec<crate::search::SearchResult> =
|
||||||
|
ranked_items.into_iter().map(|(_, item)| item).collect();
|
||||||
|
|
||||||
let kg_json = state.read_graph(|full| {
|
let kg_json = state.read_graph(|full| {
|
||||||
let mut kg_results = serde_json::Map::new();
|
let mut kg_results = serde_json::Map::new();
|
||||||
@@ -1264,24 +1276,23 @@ impl McpTool for ManageCheckpointHandler {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(graph_content) = std::fs::read_to_string(target_dir.join("graph.json")) {
|
if let Ok(graph_content) = std::fs::read_to_string(target_dir.join("graph.json"))
|
||||||
if let Ok(graph) = serde_json::from_str(&graph_content) {
|
&& let Ok(graph) = serde_json::from_str(&graph_content)
|
||||||
state.graph.modify(|g| *g = graph);
|
{
|
||||||
}
|
state.graph.modify(|g| *g = graph);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(tasks_content) = std::fs::read_to_string(target_dir.join("tasks.json")) {
|
if let Ok(tasks_content) = std::fs::read_to_string(target_dir.join("tasks.json"))
|
||||||
if let Ok(tasks) = serde_json::from_str(&tasks_content) {
|
&& let Ok(tasks) = serde_json::from_str(&tasks_content)
|
||||||
state.project.tasks.modify(|t| *t = tasks);
|
{
|
||||||
}
|
state.project.tasks.modify(|t| *t = tasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(debts_content) =
|
if let Ok(debts_content) =
|
||||||
std::fs::read_to_string(target_dir.join("tech_debts.json"))
|
std::fs::read_to_string(target_dir.join("tech_debts.json"))
|
||||||
|
&& let Ok(debts) = serde_json::from_str(&debts_content)
|
||||||
{
|
{
|
||||||
if let Ok(debts) = serde_json::from_str(&debts_content) {
|
state.code.tech_debts.modify(|d| *d = debts);
|
||||||
state.code.tech_debts.modify(|d| *d = debts);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(format!("Checkpoint '{}' restored successfully.", name))
|
Ok(format!("Checkpoint '{}' restored successfully.", name))
|
||||||
@@ -1291,10 +1302,10 @@ impl McpTool for ManageCheckpointHandler {
|
|||||||
let checkpoints_dir = state.base_dir.join("checkpoints");
|
let checkpoints_dir = state.base_dir.join("checkpoints");
|
||||||
if let Ok(entries) = std::fs::read_dir(&checkpoints_dir) {
|
if let Ok(entries) = std::fs::read_dir(&checkpoints_dir) {
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
if entry.path().is_dir() {
|
if entry.path().is_dir()
|
||||||
if let Some(n) = entry.file_name().to_str() {
|
&& let Some(n) = entry.file_name().to_str()
|
||||||
list.push(serde_json::json!({"type": "checkpoint", "name": n}));
|
{
|
||||||
}
|
list.push(serde_json::json!({"type": "checkpoint", "name": n}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1302,7 +1313,7 @@ impl McpTool for ManageCheckpointHandler {
|
|||||||
for s in snaps {
|
for s in snaps {
|
||||||
list.push(serde_json::json!({"type": "snapshot", "id": s.id, "description": s.description, "namespace": s.namespace}));
|
list.push(serde_json::json!({"type": "snapshot", "id": s.id, "description": s.description, "namespace": s.namespace}));
|
||||||
}
|
}
|
||||||
Ok(serde_json::to_string_pretty(&list)?)
|
Ok(serde_yaml::to_string(&list)?)
|
||||||
}
|
}
|
||||||
CheckpointAction::Delete => {
|
CheckpointAction::Delete => {
|
||||||
let name = req.name_or_id.ok_or_else(|| {
|
let name = req.name_or_id.ok_or_else(|| {
|
||||||
@@ -1424,7 +1435,7 @@ impl McpTool for QueryLineageHandler {
|
|||||||
"timeline": timeline
|
"timeline": timeline
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(serde_json::to_string_pretty(&res)?)
|
Ok(serde_yaml::to_string(&res)?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1462,12 +1473,11 @@ impl McpTool for GetNextActionableTasksHandler {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(branch) = &req.git_branch {
|
if let Some(branch) = &req.git_branch
|
||||||
if let Some(tb) = &task.git_branch {
|
&& let Some(tb) = &task.git_branch
|
||||||
if tb != branch {
|
&& tb != branch
|
||||||
continue;
|
{
|
||||||
}
|
continue;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let unblocked = task.dependencies.is_empty()
|
let unblocked = task.dependencies.is_empty()
|
||||||
@@ -1485,7 +1495,7 @@ impl McpTool for GetNextActionableTasksHandler {
|
|||||||
"tasks": actionable
|
"tasks": actionable
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(serde_json::to_string_pretty(&res)?)
|
Ok(serde_yaml::to_string(&res)?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1510,7 +1520,9 @@ impl McpTool for HypothesesHandler {
|
|||||||
match req.action {
|
match req.action {
|
||||||
HypothesisAction::Log => {
|
HypothesisAction::Log => {
|
||||||
let hyp_text = req.hypothesis.ok_or_else(|| {
|
let hyp_text = req.hypothesis.ok_or_else(|| {
|
||||||
crate::error::AppError::Internal("Missing required 'hypothesis' for action 'log'".to_string())
|
crate::error::AppError::Internal(
|
||||||
|
"Missing required 'hypothesis' for action 'log'".to_string(),
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
let hyp_id = format!(
|
let hyp_id = format!(
|
||||||
"HYP-{}",
|
"HYP-{}",
|
||||||
@@ -1537,23 +1549,23 @@ impl McpTool for HypothesesHandler {
|
|||||||
let filtered: Vec<_> = hypotheses
|
let filtered: Vec<_> = hypotheses
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|h| {
|
.filter(|h| {
|
||||||
if let Some(tid) = &req.task_id {
|
if let Some(tid) = &req.task_id
|
||||||
if h.task_id.as_ref() != Some(tid) {
|
&& h.task_id.as_ref() != Some(tid)
|
||||||
return false;
|
{
|
||||||
}
|
return false;
|
||||||
}
|
}
|
||||||
if let Some(q) = &req.query {
|
if let Some(q) = &req.query {
|
||||||
let lq = q.to_lowercase();
|
let lq = q.to_lowercase();
|
||||||
return h.hypothesis.to_lowercase().contains(&lq)
|
return h.hypothesis.to_lowercase().contains(&lq)
|
||||||
|| h.evidence
|
|| h.evidence
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or(false, |e| e.to_lowercase().contains(&lq));
|
.is_some_and(|e| e.to_lowercase().contains(&lq));
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(serde_json::to_string_pretty(&filtered)?)
|
Ok(serde_yaml::to_string(&filtered)?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1632,7 +1644,7 @@ impl McpTool for GetPreflightContextHandler {
|
|||||||
"recent_activities": recent_activities
|
"recent_activities": recent_activities
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(serde_json::to_string_pretty(&preflight)?)
|
Ok(serde_yaml::to_string(&preflight)?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1657,13 +1669,19 @@ impl McpTool for AgentSignalsHandler {
|
|||||||
match req.action {
|
match req.action {
|
||||||
AgentSignalAction::Broadcast => {
|
AgentSignalAction::Broadcast => {
|
||||||
let sender = req.sender.ok_or_else(|| {
|
let sender = req.sender.ok_or_else(|| {
|
||||||
crate::error::AppError::Internal("Missing required 'sender' for action 'broadcast'".to_string())
|
crate::error::AppError::Internal(
|
||||||
|
"Missing required 'sender' for action 'broadcast'".to_string(),
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
let signal_type = req.signal_type.ok_or_else(|| {
|
let signal_type = req.signal_type.ok_or_else(|| {
|
||||||
crate::error::AppError::Internal("Missing required 'signal_type' for action 'broadcast'".to_string())
|
crate::error::AppError::Internal(
|
||||||
|
"Missing required 'signal_type' for action 'broadcast'".to_string(),
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
let payload = req.payload.ok_or_else(|| {
|
let payload = req.payload.ok_or_else(|| {
|
||||||
crate::error::AppError::Internal("Missing required 'payload' for action 'broadcast'".to_string())
|
crate::error::AppError::Internal(
|
||||||
|
"Missing required 'payload' for action 'broadcast'".to_string(),
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let timestamp = std::time::SystemTime::now()
|
let timestamp = std::time::SystemTime::now()
|
||||||
@@ -1716,29 +1734,29 @@ impl McpTool for AgentSignalsHandler {
|
|||||||
signals
|
signals
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|s| {
|
.filter(|s| {
|
||||||
if let Some(ttl) = s.ttl_seconds {
|
if let Some(ttl) = s.ttl_seconds
|
||||||
if now > s.timestamp + ttl {
|
&& now > s.timestamp + ttl
|
||||||
return false;
|
{
|
||||||
}
|
return false;
|
||||||
}
|
}
|
||||||
if let Some(sender) = &req.sender {
|
if let Some(sender) = &req.sender
|
||||||
if s.sender.to_lowercase() != sender.to_lowercase() {
|
&& s.sender.to_lowercase() != sender.to_lowercase()
|
||||||
return false;
|
{
|
||||||
}
|
return false;
|
||||||
}
|
}
|
||||||
if let Some(st) = &req.signal_type {
|
if let Some(st) = &req.signal_type
|
||||||
if s.signal_type.to_lowercase() != st.to_lowercase() {
|
&& s.signal_type.to_lowercase() != st.to_lowercase()
|
||||||
return false;
|
{
|
||||||
}
|
return false;
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
})
|
})
|
||||||
.cloned()
|
|
||||||
.take(req.limit.unwrap_or(20))
|
.take(req.limit.unwrap_or(20))
|
||||||
|
.cloned()
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(serde_json::to_string_pretty(&filtered)?)
|
Ok(serde_yaml::to_string(&filtered)?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1981,8 +1999,8 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let uuid_start = debt_list.find("id\":\"").unwrap() + 5;
|
let debt_list_val: serde_json::Value = serde_yaml::from_str(&debt_list).unwrap();
|
||||||
let uuid = &debt_list[uuid_start..uuid_start + 36];
|
let uuid = debt_list_val[0]["id"].as_str().unwrap();
|
||||||
|
|
||||||
let resolve_debt = TechDebtHandler;
|
let resolve_debt = TechDebtHandler;
|
||||||
resolve_debt
|
resolve_debt
|
||||||
@@ -2111,7 +2129,10 @@ mod tests {
|
|||||||
assert!(q_dec_res.contains("Fast async API routing"));
|
assert!(q_dec_res.contains("Fast async API routing"));
|
||||||
|
|
||||||
let q_by_consequence = handler_dec
|
let q_by_consequence = handler_dec
|
||||||
.execute(serde_json::json!({"action": "query", "query": "Fast async"}), state.clone())
|
.execute(
|
||||||
|
serde_json::json!({"action": "query", "query": "Fast async"}),
|
||||||
|
state.clone(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(q_by_consequence.contains("Use Axum"));
|
assert!(q_by_consequence.contains("Use Axum"));
|
||||||
@@ -2134,7 +2155,10 @@ mod tests {
|
|||||||
assert!(update_res.contains("implemented"));
|
assert!(update_res.contains("implemented"));
|
||||||
|
|
||||||
let q_after_update = handler_dec
|
let q_after_update = handler_dec
|
||||||
.execute(serde_json::json!({"action": "query", "include_body": false}), state.clone())
|
.execute(
|
||||||
|
serde_json::json!({"action": "query", "include_body": false}),
|
||||||
|
state.clone(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(q_after_update.contains("implemented"));
|
assert!(q_after_update.contains("implemented"));
|
||||||
@@ -2144,7 +2168,10 @@ mod tests {
|
|||||||
assert!(q_after_update.contains("resolved_at"));
|
assert!(q_after_update.contains("resolved_at"));
|
||||||
|
|
||||||
let del_dec_res = handler_dec
|
let del_dec_res = handler_dec
|
||||||
.execute(serde_json::json!({"action": "delete", "id": "ADR-0001"}), state.clone())
|
.execute(
|
||||||
|
serde_json::json!({"action": "delete", "id": "ADR-0001"}),
|
||||||
|
state.clone(),
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
assert!(del_dec_res.is_ok());
|
assert!(del_dec_res.is_ok());
|
||||||
|
|
||||||
@@ -2164,14 +2191,20 @@ mod tests {
|
|||||||
assert!(td_res.contains("Tech debt logged"));
|
assert!(td_res.contains("Tech debt logged"));
|
||||||
|
|
||||||
let list_td_res = handler_td
|
let list_td_res = handler_td
|
||||||
.execute(serde_json::json!({"action": "list", "include_resolved": true}), state.clone())
|
.execute(
|
||||||
|
serde_json::json!({"action": "list", "include_resolved": true}),
|
||||||
|
state.clone(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(list_td_res.contains("Replace unwraps"));
|
assert!(list_td_res.contains("Replace unwraps"));
|
||||||
|
|
||||||
let debt_id = state.code.tech_debts.read_with(|debts| debts[0].id.clone());
|
let debt_id = state.code.tech_debts.read_with(|debts| debts[0].id.clone());
|
||||||
let res_td_res = handler_td
|
let res_td_res = handler_td
|
||||||
.execute(serde_json::json!({"action": "resolve", "id": debt_id}), state.clone())
|
.execute(
|
||||||
|
serde_json::json!({"action": "resolve", "id": debt_id}),
|
||||||
|
state.clone(),
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
assert!(res_td_res.is_ok());
|
assert!(res_td_res.is_ok());
|
||||||
|
|
||||||
@@ -2395,7 +2428,10 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(rest_state_res.contains("restored successfully") || rest_state_res.contains("restored memory state from snapshot"));
|
assert!(
|
||||||
|
rest_state_res.contains("restored successfully")
|
||||||
|
|| rest_state_res.contains("restored memory state from snapshot")
|
||||||
|
);
|
||||||
|
|
||||||
let mg_list = mg_chk
|
let mg_list = mg_chk
|
||||||
.execute(serde_json::json!({"action": "list"}), state.clone())
|
.execute(serde_json::json!({"action": "list"}), state.clone())
|
||||||
@@ -2608,7 +2644,10 @@ mod tests {
|
|||||||
|
|
||||||
// DeleteDecision non-existent
|
// DeleteDecision non-existent
|
||||||
let del_dec_err = handler_dec
|
let del_dec_err = handler_dec
|
||||||
.execute(serde_json::json!({"action": "delete", "id": "ADR-9999"}), state.clone())
|
.execute(
|
||||||
|
serde_json::json!({"action": "delete", "id": "ADR-9999"}),
|
||||||
|
state.clone(),
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
assert!(del_dec_err.is_err());
|
assert!(del_dec_err.is_err());
|
||||||
|
|
||||||
@@ -2827,4 +2866,70 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(pre_res.contains("active_tasks"));
|
assert!(pre_res.contains("active_tasks"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_tech_debt_priority_aware_eviction() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||||
|
let handler = TechDebtHandler;
|
||||||
|
|
||||||
|
// Populate 300 tech debts: 1 critical, 1 resolved, 298 low
|
||||||
|
state.code.tech_debts.modify(|debts| {
|
||||||
|
debts.push(crate::models::TechDebt {
|
||||||
|
id: "critical-debt".to_string(),
|
||||||
|
namespace: "global".to_string(),
|
||||||
|
description: "Critical security issue".to_string(),
|
||||||
|
severity: Some("critical".to_string()),
|
||||||
|
is_resolved: false,
|
||||||
|
created_at: 100,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
debts.push(crate::models::TechDebt {
|
||||||
|
id: "resolved-debt".to_string(),
|
||||||
|
namespace: "global".to_string(),
|
||||||
|
description: "Old resolved issue".to_string(),
|
||||||
|
severity: Some("high".to_string()),
|
||||||
|
is_resolved: true,
|
||||||
|
created_at: 50,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
for i in 0..298 {
|
||||||
|
debts.push(crate::models::TechDebt {
|
||||||
|
id: format!("low-debt-{}", i),
|
||||||
|
namespace: "global".to_string(),
|
||||||
|
description: format!("Low debt {}", i),
|
||||||
|
severity: Some("low".to_string()),
|
||||||
|
is_resolved: false,
|
||||||
|
created_at: 200 + i,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add 301st item: should evict the resolved debt first
|
||||||
|
let res = handler
|
||||||
|
.execute(
|
||||||
|
serde_json::json!({
|
||||||
|
"action": "log",
|
||||||
|
"description": "New medium debt",
|
||||||
|
"severity": "medium"
|
||||||
|
}),
|
||||||
|
state.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res, "Tech debt logged");
|
||||||
|
|
||||||
|
state.code.tech_debts.read_with(|debts| {
|
||||||
|
assert_eq!(debts.len(), 300);
|
||||||
|
assert!(
|
||||||
|
debts.iter().any(|d| d.id == "critical-debt"),
|
||||||
|
"Critical unresolved debt must be retained"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!debts.iter().any(|d| d.id == "resolved-debt"),
|
||||||
|
"Resolved debt should have been evicted first"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ pub mod graph;
|
|||||||
pub mod logs;
|
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 vision;
|
pub mod vision;
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ impl McpTool for HandoffMemosHandler {
|
|||||||
let content = req.content.ok_or_else(|| {
|
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())
|
crate::error::AppError::Internal("Missing required parameter 'content' for action 'leave'. Next step: Provide non-empty 'content' string in request and retry.".to_string())
|
||||||
})?;
|
})?;
|
||||||
let ns = req.namespace.unwrap_or_else(|| crate::models::default_namespace());
|
let ns = req
|
||||||
|
.namespace
|
||||||
|
.unwrap_or_else(crate::models::default_namespace);
|
||||||
state.telemetry.handoff_memos.modify(|memos| {
|
state.telemetry.handoff_memos.modify(|memos| {
|
||||||
memos.push(crate::models::HandoffMemo {
|
memos.push(crate::models::HandoffMemo {
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
@@ -58,7 +60,7 @@ impl McpTool for HandoffMemosHandler {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok::<String, crate::error::AppError>(serde_json::to_string(&filtered)?)
|
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&filtered)?)
|
||||||
})?;
|
})?;
|
||||||
Ok(data)
|
Ok(data)
|
||||||
}
|
}
|
||||||
@@ -108,7 +110,16 @@ impl McpTool for AddSessionSummaryHandler {
|
|||||||
next_steps: req.next_steps.unwrap_or_default(),
|
next_steps: req.next_steps.unwrap_or_default(),
|
||||||
});
|
});
|
||||||
if summaries.len() > 200 {
|
if summaries.len() > 200 {
|
||||||
summaries.remove(0);
|
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())
|
||||||
@@ -136,13 +147,25 @@ impl McpTool for GenerateStandupReportHandler {
|
|||||||
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 filtered_tasks = state.project.tasks.read_with(|items| {
|
let filtered_tasks = state.project.tasks.read_with(|items| {
|
||||||
items.iter().filter(|t| t.updated_at >= cutoff).cloned().collect::<Vec<_>>()
|
items
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.updated_at >= cutoff)
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
});
|
});
|
||||||
let filtered_changes = state.code.ledger.read_with(|changes| {
|
let filtered_changes = state.code.ledger.read_with(|changes| {
|
||||||
changes.iter().filter(|c| c.timestamp >= cutoff).cloned().collect::<Vec<_>>()
|
changes
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.timestamp >= cutoff)
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
});
|
});
|
||||||
let filtered_summaries = state.telemetry.session_summaries.read_with(|summaries| {
|
let filtered_summaries = state.telemetry.session_summaries.read_with(|summaries| {
|
||||||
summaries.iter().filter(|s| s.namespace == req.namespace && s.timestamp >= cutoff).cloned().collect::<Vec<_>>()
|
summaries
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.namespace == req.namespace && s.timestamp >= cutoff)
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
});
|
});
|
||||||
|
|
||||||
let report = serde_json::json!({
|
let report = serde_json::json!({
|
||||||
@@ -171,8 +194,7 @@ impl McpTool for PromoteToEntityHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||||
let req: PromoteToEntityTool =
|
let req: PromoteToEntityTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let entity_name = req.entity_name.clone();
|
let entity_name = req.entity_name.clone();
|
||||||
let obs = req.content.clone();
|
let obs = req.content.clone();
|
||||||
@@ -229,7 +251,10 @@ mod tests {
|
|||||||
assert_eq!(res, "Handoff memo left.");
|
assert_eq!(res, "Handoff memo left.");
|
||||||
|
|
||||||
let res2 = handoff_handler
|
let res2 = handoff_handler
|
||||||
.execute(json!({"action": "read", "namespace": "global"}), state.clone())
|
.execute(
|
||||||
|
json!({"action": "read", "namespace": "global"}),
|
||||||
|
state.clone(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -259,5 +284,3 @@ mod tests {
|
|||||||
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,6 @@ impl McpTool for TasksHandler {
|
|||||||
priority: req.priority,
|
priority: req.priority,
|
||||||
assigned_agent: req.assigned_agent,
|
assigned_agent: req.assigned_agent,
|
||||||
verification_command: req.verification_command,
|
verification_command: req.verification_command,
|
||||||
..Default::default()
|
|
||||||
};
|
};
|
||||||
let idx = state.get_search_index().await;
|
let idx = state.get_search_index().await;
|
||||||
drop(idx.index_task(&task));
|
drop(idx.index_task(&task));
|
||||||
@@ -192,6 +191,10 @@ impl McpTool for TasksHandler {
|
|||||||
session_id: None,
|
session_id: None,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
|
if target_status == "completed" {
|
||||||
|
crate::handlers::reconciliation::cascade_unblock_tasks(&state);
|
||||||
|
crate::handlers::reconciliation::recalculate_milestones(&state);
|
||||||
|
}
|
||||||
Ok("Task status updated.".to_string())
|
Ok("Task status updated.".to_string())
|
||||||
} else {
|
} else {
|
||||||
Err(crate::error::AppError::Internal(
|
Err(crate::error::AppError::Internal(
|
||||||
@@ -293,12 +296,12 @@ impl McpTool for TasksHandler {
|
|||||||
_ => serde_json::json!({ "id": t.id, "title": t.title, "status": t.status, "description": t.description, "git_branch": t.git_branch }),
|
_ => serde_json::json!({ "id": t.id, "title": t.title, "status": t.status, "description": t.description, "git_branch": t.git_branch }),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let mut json_str = serde_json::to_string(&filtered)?;
|
let mut json_str = serde_yaml::to_string(&filtered)?;
|
||||||
if let Some(max_t) = req.max_tokens {
|
if let Some(max_t) = req.max_tokens {
|
||||||
let char_limit = max_t * 4;
|
let char_limit = max_t * 4;
|
||||||
while json_str.len() > char_limit && !filtered.is_empty() {
|
while json_str.len() > char_limit && !filtered.is_empty() {
|
||||||
filtered.pop();
|
filtered.pop();
|
||||||
json_str = serde_json::to_string(&filtered)?;
|
json_str = serde_yaml::to_string(&filtered)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok::<String, crate::error::AppError>(json_str)
|
Ok::<String, crate::error::AppError>(json_str)
|
||||||
@@ -362,14 +365,14 @@ impl McpTool for TasksHandler {
|
|||||||
let mut updated_task: Option<Task> = None;
|
let mut updated_task: Option<Task> = None;
|
||||||
state.project.tasks.modify(|tasks| {
|
state.project.tasks.modify(|tasks| {
|
||||||
if let Some(task) = tasks.iter_mut().find(|t| t.id == id || t.title == id) {
|
if let Some(task) = tasks.iter_mut().find(|t| t.id == id || t.title == id) {
|
||||||
if let Some(ac) = task.acceptance_criteria.iter_mut().next() {
|
if let Some(ac) = task.acceptance_criteria.first_mut() {
|
||||||
if ac.is_met {
|
if ac.is_met {
|
||||||
already_met = true;
|
already_met = true;
|
||||||
} else {
|
} else {
|
||||||
ac.is_met = true;
|
ac.is_met = true;
|
||||||
success = true;
|
success = true;
|
||||||
task.updated_at = crate::handlers::utils::now_secs();
|
task.updated_at = crate::handlers::utils::now_secs();
|
||||||
updated_task = Some(task.clone());
|
updated_task = Some(task.clone());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
task.acceptance_criteria
|
task.acceptance_criteria
|
||||||
@@ -440,7 +443,7 @@ impl McpTool for MilestonesHandler {
|
|||||||
})?;
|
})?;
|
||||||
let ns = req
|
let ns = req
|
||||||
.namespace
|
.namespace
|
||||||
.unwrap_or_else(|| crate::models::default_namespace());
|
.unwrap_or_else(crate::models::default_namespace);
|
||||||
state.project.milestones.modify(|ms| {
|
state.project.milestones.modify(|ms| {
|
||||||
ms.push(crate::models::Milestone {
|
ms.push(crate::models::Milestone {
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
@@ -452,11 +455,13 @@ impl McpTool for MilestonesHandler {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(|s| s.parse::<u64>().ok()),
|
.and_then(|s| s.parse::<u64>().ok()),
|
||||||
description: req.description,
|
description: req.description,
|
||||||
|
task_ids: req.task_ids.unwrap_or_default(),
|
||||||
deliverables: req.deliverables.unwrap_or_default(),
|
deliverables: req.deliverables.unwrap_or_default(),
|
||||||
repo_name: req.repo_name,
|
repo_name: req.repo_name,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
crate::handlers::reconciliation::recalculate_milestones(&state);
|
||||||
Ok("Milestone added".to_string())
|
Ok("Milestone added".to_string())
|
||||||
}
|
}
|
||||||
MilestoneAction::Update => {
|
MilestoneAction::Update => {
|
||||||
@@ -471,12 +476,25 @@ impl McpTool for MilestonesHandler {
|
|||||||
for m in ms.iter_mut() {
|
for m in ms.iter_mut() {
|
||||||
if m.id == id {
|
if m.id == id {
|
||||||
m.status = status.clone();
|
m.status = status.clone();
|
||||||
|
if (status.eq_ignore_ascii_case("completed")
|
||||||
|
|| status.eq_ignore_ascii_case("done"))
|
||||||
|
&& m.completed_at.is_none()
|
||||||
|
{
|
||||||
|
m.completed_at = Some(crate::handlers::utils::now_secs());
|
||||||
|
}
|
||||||
|
if let Some(task_ids) = &req.task_ids {
|
||||||
|
m.task_ids = task_ids.clone();
|
||||||
|
if let Some(deliverables) = &req.deliverables {
|
||||||
|
m.deliverables = deliverables.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
found = true;
|
found = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if found {
|
if found {
|
||||||
|
crate::handlers::reconciliation::recalculate_milestones(&state);
|
||||||
Ok("Milestone updated".to_string())
|
Ok("Milestone updated".to_string())
|
||||||
} else {
|
} else {
|
||||||
Err(crate::error::AppError::Internal(
|
Err(crate::error::AppError::Internal(
|
||||||
@@ -496,7 +514,7 @@ impl McpTool for MilestonesHandler {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok::<String, crate::error::AppError>(serde_json::to_string(&filtered)?)
|
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&filtered)?)
|
||||||
})?;
|
})?;
|
||||||
Ok(data)
|
Ok(data)
|
||||||
}
|
}
|
||||||
|
|||||||
+866
-117
File diff suppressed because it is too large.
Load diff
@@ -6,14 +6,6 @@ use async_trait::async_trait;
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
pub struct SnippetsHandler;
|
pub struct SnippetsHandler;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -57,7 +49,7 @@ impl McpTool for SnippetsHandler {
|
|||||||
embedding,
|
embedding,
|
||||||
namespace: req
|
namespace: req
|
||||||
.namespace
|
.namespace
|
||||||
.unwrap_or_else(|| crate::models::default_namespace()),
|
.unwrap_or_else(crate::models::default_namespace),
|
||||||
repo_name: req.repo_name,
|
repo_name: req.repo_name,
|
||||||
origin_file: req.origin_file,
|
origin_file: req.origin_file,
|
||||||
line_range: req.line_range,
|
line_range: req.line_range,
|
||||||
@@ -89,8 +81,9 @@ impl McpTool for SnippetsHandler {
|
|||||||
let mut scored: Vec<_> = snippets
|
let mut scored: Vec<_> = snippets
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|s| {
|
.filter_map(|s| {
|
||||||
let text = format!("{} {} {} {}", s.name, s.description, s.language, s.code)
|
let text =
|
||||||
.to_lowercase();
|
format!("{} {} {} {}", s.name, s.description, s.language, s.code)
|
||||||
|
.to_lowercase();
|
||||||
let mut score = 0.0;
|
let mut score = 0.0;
|
||||||
for tok in &q_tokens {
|
for tok in &q_tokens {
|
||||||
if text.contains(tok) {
|
if text.contains(tok) {
|
||||||
@@ -129,7 +122,7 @@ impl McpTool for SnippetsHandler {
|
|||||||
.total_cmp(&a["score"].as_f64().unwrap_or(0.0))
|
.total_cmp(&a["score"].as_f64().unwrap_or(0.0))
|
||||||
});
|
});
|
||||||
scored.truncate(10);
|
scored.truncate(10);
|
||||||
Ok(serde_json::to_string_pretty(&scored)?)
|
Ok(serde_yaml::to_string(&scored)?)
|
||||||
} else {
|
} else {
|
||||||
let data = state.code.snippets.read_with(|snippets| {
|
let data = state.code.snippets.read_with(|snippets| {
|
||||||
let results: Vec<_> = snippets
|
let results: Vec<_> = snippets
|
||||||
@@ -143,9 +136,9 @@ impl McpTool for SnippetsHandler {
|
|||||||
|| s.tags.iter().any(|t| contains_ignore_ascii_case(t, &query));
|
|| s.tags.iter().any(|t| contains_ignore_ascii_case(t, &query));
|
||||||
|
|
||||||
let matches_tags = tags_filter.is_empty()
|
let matches_tags = tags_filter.is_empty()
|
||||||
|| tags_filter
|
|| tags_filter.iter().any(|tf| {
|
||||||
.iter()
|
s.tags.iter().any(|st| contains_ignore_ascii_case(st, tf))
|
||||||
.any(|tf| s.tags.iter().any(|st| contains_ignore_ascii_case(st, tf)));
|
});
|
||||||
|
|
||||||
matches_text && matches_tags
|
matches_text && matches_tags
|
||||||
})
|
})
|
||||||
@@ -160,7 +153,7 @@ impl McpTool for SnippetsHandler {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
serde_json::to_string_pretty(&results)
|
serde_yaml::to_string(&results)
|
||||||
})?;
|
})?;
|
||||||
Ok(data)
|
Ok(data)
|
||||||
}
|
}
|
||||||
@@ -352,13 +345,28 @@ impl McpTool for SemanticCodeSearchHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut out = format!("Semantic Search Results for '{}':\n", tool_args.query);
|
let mut out = format!("Semantic Search Results for '{}':\n", tool_args.query);
|
||||||
for res in results {
|
for res in &results {
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
"- [{:.2}] {}: {}\n",
|
"- [{:.2}] {}: {}\n",
|
||||||
res.score, res.title, res.body
|
res.score, res.title, res.body
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if state.ollama.is_available().await {
|
||||||
|
let prompt = format!(
|
||||||
|
"You are an expert software architect. Synthesize a concise GraphRAG summary of the following graph nodes relevant to the query: '{}'.\n\nData:\n{}",
|
||||||
|
tool_args.query, out
|
||||||
|
);
|
||||||
|
if let Ok(summary) = state
|
||||||
|
.ollama
|
||||||
|
.generate(&prompt, None, Some("Respond clearly and concisely."), None)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
out.push_str("\n\n--- Local GraphRAG Summary ---\n");
|
||||||
|
out.push_str(&summary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-3
@@ -131,6 +131,13 @@ pub async fn start_background_indexer(state: Arc<MemoryState>) {
|
|||||||
snippets.drain(0..overflow);
|
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 {
|
for snippet in &snippets_to_index {
|
||||||
let _ = idx.index_snippet(snippet).await;
|
let _ = idx.index_snippet(snippet).await;
|
||||||
@@ -143,7 +150,12 @@ 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);
|
extract_chunks_with_parent(node, code, chunks, ext, None, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +163,7 @@ fn extract_chunks_with_parent(
|
|||||||
node: Node,
|
node: Node,
|
||||||
code: &str,
|
code: &str,
|
||||||
chunks: &mut Vec<(String, String, String)>,
|
chunks: &mut Vec<(String, String, String)>,
|
||||||
ext: &str,
|
_ext: &str,
|
||||||
parent_scope: Option<&str>,
|
parent_scope: Option<&str>,
|
||||||
depth: usize,
|
depth: usize,
|
||||||
) {
|
) {
|
||||||
@@ -217,7 +229,7 @@ fn extract_chunks_with_parent(
|
|||||||
} 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_with_parent(child, code, chunks, ext, current_scope, depth + 1);
|
extract_chunks_with_parent(child, code, chunks, _ext, current_scope, depth + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+148
-51
@@ -1,7 +1,7 @@
|
|||||||
# Memory MCP Strategic Guidelines
|
# Memory MCP Strategic Guidelines
|
||||||
|
|
||||||
This document outlines the STRATEGY, SEMANTICS, and CASING STANDARDS 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 `tools/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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -19,9 +19,47 @@ To prevent graph fragmentation and ensure seamless LLM context retrieval:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Consolidated Smart Tools Architecture
|
## 2. The Two-Tier Context Paradigm
|
||||||
|
|
||||||
The server consolidates granular single-purpose tools into domain-named smart tools. Always prefer the consolidated tools over legacy aliases:
|
> [!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.
|
* **`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: "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`).
|
||||||
@@ -48,7 +86,8 @@ The server consolidates granular single-purpose tools into domain-named smart to
|
|||||||
- `action: "tag"`: Attach classification tags (requires `id`, `tags: Vec<String>`).
|
- `action: "tag"`: Attach classification tags (requires `id`, `tags: Vec<String>`).
|
||||||
|
|
||||||
* **`decisions`**: Architectural Decision Records (ADRs).
|
* **`decisions`**: Architectural Decision Records (ADRs).
|
||||||
- `action: "log"`: Log ADR (requires `title`, optional `status: "accepted" | "proposed" | "deprecated" | "superseded"`, `context`, `decision`, `consequence`, `author`, `affected_components: Vec<String>`, `alternatives_considered: Vec<String>`, `supersedes`, `repo_name`).
|
- `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: "query"`: Query ADRs (optional `query`).
|
||||||
- `action: "delete"`: Delete ADR (requires `id`).
|
- `action: "delete"`: Delete ADR (requires `id`).
|
||||||
|
|
||||||
@@ -83,89 +122,155 @@ The server consolidates granular single-purpose tools into domain-named smart to
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. VCS & SVN Agnosticism & Multi-Repo Provenance
|
## 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):
|
To support diverse enterprise repositories (Git, Subversion / SVN, Mercurial / Hg, Monorepos):
|
||||||
* **`vcs_type`**: Designates the VCS engine (`"git"`, `"svn"`, `"hg"`, `"perforce"`, or `"none"`).
|
* **`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"`).
|
* **`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`).
|
* **`upstream_url`**: Canonical remote repository URL (e.g. `https://svn.corp/repo/trunk`, `git@bitbucket.org:org/repo.git`).
|
||||||
* **`repo_name`**: Logical project or repository identifier allowing multiple repositories to share or partition memory namespaces cleanly without collision.
|
* **`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>`.
|
* **Audit Ledger (`log_code_change`)**: Enriched with `vcs_type`, `vcs_revision`, `upstream_url`, `author`, `diff_summary`, and extensible `metadata: HashMap<String, String>`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Terminal & Process Telemetry
|
## 8. Terminal & Process Telemetry
|
||||||
|
|
||||||
The server ingests and tracks active terminal commands and sessions:
|
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.
|
* **Active Terminals**: Tracks PIDs, shell interpreters (`pwsh`, `bash`, `nu`, `zsh`), current working directories (`cwd`), command exit codes, and timestamps.
|
||||||
* **Terminal History Endpoint**: `/terminal/history` exposes recent shell commands and output streams to dashboard and LLMs to prevent lost shell context.
|
* **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.
|
||||||
* **Quality Gate Enforcement**: `GateRecord` captures pre-flight and pre-push validation passes with `gate_type`, `enforcer`, `status`, `validation_log`, and `repo_name`.
|
* **Zero-Latency UDP Streams**: Terminal and IDE telemetry stream over UDP (`MCP_UDP_PORT1`, `MCP_UDP_PORT2`) with zero disk I/O bottlenecks.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. High-Signal Tool Responses & Performance Guidelines
|
## 9. Automated Error Fix Auto-Matcher
|
||||||
|
|
||||||
To optimize context usage, response times, and LLM reasoning efficiency:
|
|
||||||
|
|
||||||
* **High-Signal Feedback**:
|
|
||||||
- MCP tool calls (such as `create_entities`, `create_relations`, `add_observations`, `pin_file`) return explicit, structured summaries containing created names, types, edge counts, and relation paths.
|
|
||||||
- LLMs do NOT need to execute follow-up `open_nodes` calls merely to confirm successful creation.
|
|
||||||
|
|
||||||
* **Batch Operations**:
|
|
||||||
- When creating or updating multiple entities, snippets, or observations, always batch items into a single tool call array (e.g. `create_entities` with multiple array items) rather than making separate calls.
|
|
||||||
- The server handles batch store mutations serially in a single transaction pass with single-permit event-driven flushes.
|
|
||||||
|
|
||||||
* **Real-time Tantivy Search Indexing**:
|
|
||||||
- The Tantivy search engine automatically checks pending commits and reloads search readers prior to executing `omni_search` or `search_nodes`. Search queries always return up-to-date document results immediately following mutations.
|
|
||||||
- Single-item deletions use targeted document removal rather than global index wipes.
|
|
||||||
|
|
||||||
* **Real-Time AST & Workspace Source Code Symbol Scanning**:
|
|
||||||
- `find_symbol_references`, `get_callers`, and `analyze_impact` scan both stored code snippets and physical workspace source code files on disk (`.rs`, `.ts`, `.py`, `.go`, `.java`, `.c`, `.cpp`), providing accurate AST symbol references and call site tracking.
|
|
||||||
|
|
||||||
* **Graph Entity Merge & Self-Loop Protection**:
|
|
||||||
- `merge_entities` re-points all relations from `source_entity` to `target_entity` and automatically prunes cyclic self-loops (`target -> target`).
|
|
||||||
|
|
||||||
* **Safe UTF-8 Token Truncation**:
|
|
||||||
- Large responses (e.g. `get_active_worktree_context`, `read_graph`, `summarize_subgraph`) are safely truncated along UTF-8 character boundaries (`floor_char_boundary`), ensuring response bounds without runtime panics.
|
|
||||||
|
|
||||||
## 4. Automated Error Fix Auto-Matcher
|
|
||||||
- **Tools:** `log_error_fix`, `search_error_fixes` (and alias `suggest_error_fix`)
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Memory State Checkpointing & Rollbacks
|
## 10. Memory State Checkpointing & Rollbacks
|
||||||
- **Tool:** `checkpoint_state`, `restore_state` (or `create_snapshot`, `restore_snapshot`)
|
- **Tool:** `manage_checkpoint` (action: `"create"` | `"restore"`)
|
||||||
- **When to use:** Before initiating a large refactor, running experimental subagent tasks, or executing destructive batch operations.
|
- **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.
|
- **Behavior:** Saves or restores a point-in-time snapshot of graph entities, active tasks, and tech debt backlogs.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Self-Healing Graph Health Sweeper
|
## 11. Self-Healing Graph Health Sweeper
|
||||||
- **Tool:** `sweep_graph_health`
|
- **Tool:** `sweep_graph_health`
|
||||||
- **When to use:** Periodically or before committing major graph changes to audit entity consistency.
|
- **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.
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Causal Lineage & Provenance Tracker
|
## 12. Causal Lineage & Provenance Tracker
|
||||||
- **Tool:** `query_lineage`
|
- **Tool:** `query_lineage`
|
||||||
- **When to use:** When asking *"Why was this component modified?"* or *"What task or ADR led to this code change?"*
|
- **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.
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Native Rust Invariants & Subprocess Prohibition (CRITICAL)
|
## 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:
|
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.
|
* **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.
|
* **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 (e.g. `test_no_subprocess_clipboard_regression`) verify at test time that forbidden subprocess patterns are absent from handler implementations.
|
* **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. High-Performance Concurrency & Resilience Guarantees
|
## 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.
|
* **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.
|
* **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.
|
* **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.
|
||||||
@@ -186,7 +291,6 @@ To maintain maximum security, speed, and cross-platform reliability:
|
|||||||
* **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.
|
* **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())`).
|
* **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.
|
* **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.
|
||||||
* **LLM Tool Schema Ergonomics & Context Guidance**: `ReadGraphHandler` schema explicitly instructs LLMs on `namespace` filtering and `search_nodes` / `get_subgraph` tools for large graph discovery.
|
|
||||||
* **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`.
|
* **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.
|
* **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`).
|
* **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`).
|
||||||
@@ -195,20 +299,13 @@ To maintain maximum security, speed, and cross-platform reliability:
|
|||||||
* **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.
|
* **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.
|
* **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`.
|
* **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`.
|
||||||
* **Sequential Snapshot Lock Scope Flattening**: `GenerateStandupReportHandler` reads `tasks`, `ledger`, and `session_summaries` sequentially rather than nesting read locks, preventing multi-lock deadlocks during concurrent store modifications.
|
|
||||||
* **Directory Tree Depth Safeguard**: `ReadDirectoryArchitectureHandler` caps directory recursion at depth 10 to prevent stack overflow on deep or cyclic directory structures.
|
* **Directory Tree Depth Safeguard**: `ReadDirectoryArchitectureHandler` caps directory recursion at depth 10 to prevent stack overflow on deep or cyclic directory structures.
|
||||||
* **Deterministic Total-Order Score Ranking**: `OmniSearchHandler` uses `f64::total_cmp` for Reciprocal Rank Fusion (RRF) score sorting, guaranteeing deterministic NaN-safe search result ordering.
|
* **Deterministic Total-Order Score Ranking**: `OmniSearchHandler` uses `f64::total_cmp` for Reciprocal Rank Fusion (RRF) score sorting, guaranteeing deterministic NaN-safe search result ordering.
|
||||||
* **RPC Timeout Memory Hygiene**: `nvim-core` maintains request hygiene by removing pending request entries from static RPC maps upon timeout or channel drop, eliminating orphan memory leaks.
|
* **RPC Timeout Memory Hygiene**: `nvim-core` maintains request hygiene by removing pending request entries from static RPC maps upon timeout or channel drop, eliminating orphan memory leaks.
|
||||||
* **Embedding Input Safeguard**: `generate_embedding_async` returns explicit errors for empty/0-length text inputs instead of returning empty vectors, preventing downstream vector dimension mismatches during cosine similarity calculations.
|
|
||||||
* **Path Traversal Security Guards**: `validate_safe_path` enforces path canonicalization and rejects relative parent traversal components (`..`) across file and process log handlers (`process_logs` / `ProcessLogsTool`).
|
* **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.
|
* **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.
|
* **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.
|
* **Two-Phase Graph Condensation**: `condense_graph_worker` uses a 2-phase commit (non-destructive `read_with` -> graph insert -> prune by timestamp/content) to prevent data loss if summarization or graph insertion fails.
|
||||||
* **Store Write Lock Minimization**: `Store::modify` and `Store::modify_async` unblock concurrent readers during JSON serialization by releasing the write lock immediately after mutating memory state.
|
|
||||||
* **Redb Database Lock Retry Backoff**: `init_db` retries transient Redb lock contention with exponential backoff (3 attempts, 150ms delay) before falling back.
|
* **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.
|
* **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.
|
* **Broadcast Watch-Based Shutdown Channels**: Background workers utilize `tokio::sync::watch` for broadcast shutdown notifications without consuming cancellation signals.
|
||||||
* **Consolidated Neovim Tool Suite (v2)**: The Neovim server exposes 7 consolidated domain tools (`nvim_buffer`, `nvim_window`, `nvim_view`, `nvim_diagnostics`, `nvim_visual`, `nvim_execute_lua`, `nvim_system`) with comprehensive action dispatching.
|
|
||||||
* **Fallback Vector Search Parity**: In-memory vector search fallback indexes Knowledge Graph entities, observations, and error fixes when external vector databases are unavailable.
|
|
||||||
|
|
||||||
|
|
||||||
+317
-60
@@ -4,6 +4,7 @@
|
|||||||
)]
|
)]
|
||||||
|
|
||||||
pub mod api;
|
pub mod api;
|
||||||
|
pub mod config;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod embedding;
|
pub mod embedding;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
@@ -96,28 +97,117 @@ pub struct AppState {
|
|||||||
|
|
||||||
pub async fn ttl_sweeper_worker(state: Arc<MemoryState>) {
|
pub async fn ttl_sweeper_worker(state: Arc<MemoryState>) {
|
||||||
loop {
|
loop {
|
||||||
state.ttl_notify.notified().await;
|
|
||||||
let now = std::time::SystemTime::now()
|
let now = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_secs();
|
.as_secs();
|
||||||
|
|
||||||
state.project.tasks.modify(|tasks| {
|
let mut next_expiry: Option<u64> = None;
|
||||||
tasks.retain(|t| t.expires_at.is_none_or(|exp| exp > now));
|
|
||||||
|
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| {
|
state.telemetry.handoff_memos.modify(|memos| {
|
||||||
memos.retain(|m| m.expires_at.is_none_or(|exp| exp > now));
|
memos.retain(|m| m.expires_at.is_none_or(|exp| exp > now));
|
||||||
});
|
});
|
||||||
state.telemetry.session_summaries.modify(|summaries| {
|
state.telemetry.session_summaries.modify(|summaries| {
|
||||||
summaries.retain(|s| s.expires_at.is_none_or(|exp| exp > now));
|
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>) {
|
pub async fn index_committer_worker(state: Arc<MemoryState>) {
|
||||||
loop {
|
loop {
|
||||||
state.index_commit_notify.notified().await;
|
tokio::select! {
|
||||||
|
_ = state.shutdown_notify.notified() => break,
|
||||||
|
_ = state.index_commit_notify.notified() => {},
|
||||||
|
}
|
||||||
let idx = state.search_index.read().await.clone();
|
let idx = state.search_index.read().await.clone();
|
||||||
let _ = idx.commit().await;
|
let _ = idx.commit().await;
|
||||||
}
|
}
|
||||||
@@ -125,7 +215,10 @@ pub async fn index_committer_worker(state: Arc<MemoryState>) {
|
|||||||
|
|
||||||
pub async fn condense_graph_worker(state: Arc<MemoryState>) {
|
pub async fn condense_graph_worker(state: Arc<MemoryState>) {
|
||||||
loop {
|
loop {
|
||||||
state.condense_notify.notified().await;
|
tokio::select! {
|
||||||
|
_ = state.shutdown_notify.notified() => break,
|
||||||
|
_ = state.condense_notify.notified() => {},
|
||||||
|
}
|
||||||
|
|
||||||
let threshold: usize = std::env::var("MCP_MEMORY_CONDENSE_THRESHOLD")
|
let threshold: usize = std::env::var("MCP_MEMORY_CONDENSE_THRESHOLD")
|
||||||
.unwrap_or_else(|_| "100".to_string())
|
.unwrap_or_else(|_| "100".to_string())
|
||||||
@@ -158,27 +251,108 @@ pub async fn condense_graph_worker(state: Arc<MemoryState>) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some((content, names)) = snippet_condensation {
|
if let Some((content, names)) = snippet_condensation
|
||||||
if !content.is_empty() {
|
&& !content.is_empty()
|
||||||
let name = format!("Snippet History {}", now);
|
{
|
||||||
state.modify_graph(|graph| {
|
let name = format!("Snippet History {}", now);
|
||||||
graph.entities.insert(
|
state.modify_graph(|graph| {
|
||||||
name.clone(),
|
graph.entities.insert(
|
||||||
crate::models::Entity {
|
name.clone(),
|
||||||
name: name.clone(),
|
crate::models::Entity {
|
||||||
entity_type: "Historical Summary".to_string(),
|
name: name.clone(),
|
||||||
observations: vec![content],
|
entity_type: "Historical Summary".to_string(),
|
||||||
namespace: crate::models::default_namespace(),
|
observations: vec![content],
|
||||||
git_branch: None,
|
namespace: crate::models::default_namespace(),
|
||||||
..Default::default()
|
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| {
|
||||||
let name_set: std::collections::HashSet<String> = names.into_iter().collect();
|
if let Some(mut e2) = g.entities.remove(e2_name) {
|
||||||
state.code.snippets.modify(|snippets| {
|
if let Some(e1) = g.entities.get_mut(e1_name) {
|
||||||
snippets.retain(|s| !name_set.contains(&s.name));
|
e1.observations.append(&mut e2.observations);
|
||||||
});
|
} else {
|
||||||
tracing::info!("Condensed snippets into Historical Summary.");
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,7 +370,9 @@ pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::erro
|
|||||||
tokio::spawn(index_committer_worker(Arc::clone(&state)));
|
tokio::spawn(index_committer_worker(Arc::clone(&state)));
|
||||||
tokio::spawn(ttl_sweeper_worker(Arc::clone(&state)));
|
tokio::spawn(ttl_sweeper_worker(Arc::clone(&state)));
|
||||||
tokio::spawn(condense_graph_worker(Arc::clone(&state)));
|
tokio::spawn(condense_graph_worker(Arc::clone(&state)));
|
||||||
|
tokio::spawn(memory_consolidation_worker(Arc::clone(&state)));
|
||||||
crate::watcher::spawn_watcher(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 (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
|
||||||
let app_state = Arc::new(AppState {
|
let app_state = Arc::new(AppState {
|
||||||
handler: Arc::new(MemoryHandler::new(Arc::clone(&state))),
|
handler: Arc::new(MemoryHandler::new(Arc::clone(&state))),
|
||||||
@@ -243,32 +419,64 @@ pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::erro
|
|||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let port1 = std::env::var("MCP_UDP_PORT1").unwrap_or_else(|_| "3001".to_string());
|
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 {
|
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 buf = vec![0u8; 65536];
|
||||||
|
let mut event_rx = udp_state.handler.state.event_bus_tx.subscribe();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if let Ok((len, _addr)) = socket.recv_from(&mut buf).await
|
tokio::select! {
|
||||||
&& let Ok(payload) =
|
recv_res = socket_rx.recv_from(&mut buf) => {
|
||||||
serde_json::from_slice::<crate::models::TerminalHistory>(&buf[..len])
|
if let Ok((len, addr)) = recv_res {
|
||||||
{
|
if let Ok(payload) = serde_json::from_slice::<crate::models::TerminalHistory>(&buf[..len]) {
|
||||||
udp_state
|
udp_state
|
||||||
.handler
|
.handler
|
||||||
.state
|
.state
|
||||||
.record_terminal_history(payload.clone());
|
.record_terminal_history(payload.clone());
|
||||||
|
|
||||||
let ws_msg = serde_json::json!({
|
let ws_msg = serde_json::json!({
|
||||||
"type": "terminal_telemetry",
|
"type": "terminal_telemetry",
|
||||||
"data": payload
|
"data": payload
|
||||||
});
|
});
|
||||||
let msg_str = ws_msg.to_string();
|
let msg_str = ws_msg.to_string();
|
||||||
|
|
||||||
let senders: Vec<_> = udp_state
|
let senders: Vec<_> = udp_state
|
||||||
.clients
|
.clients
|
||||||
.read()
|
.read()
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
.values()
|
.values()
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
for tx in senders {
|
for tx in senders {
|
||||||
let _ = tx.try_send(msg_str.clone());
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -304,9 +512,20 @@ pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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!({
|
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();
|
||||||
|
|
||||||
@@ -334,14 +553,46 @@ pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::erro
|
|||||||
let _ = nvim_udp_state.handler.state.event_bus_tx.send(event);
|
let _ = nvim_udp_state.handler.state.event_bus_tx.send(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.event.starts_with("agent_") {
|
if payload.event.starts_with("agent_") || payload.event.starts_with("diff_") {
|
||||||
let topic = format!("nvim:ui:{}", payload.event);
|
let payload_val = serde_json::json!(&payload);
|
||||||
let event = crate::state::GenericEvent {
|
// 1. General event topic (e.g. nvim:ui:agent_prompt_response, nvim:ui:agent_diff_accepted)
|
||||||
topic,
|
let _ = nvim_udp_state.handler.state.event_bus_tx.send(
|
||||||
session_id: Some(payload.session_id.clone()),
|
crate::state::GenericEvent {
|
||||||
payload: serde_json::json!(&payload),
|
topic: format!("nvim:ui:{}", payload.event),
|
||||||
};
|
session_id: Some(payload.session_id.clone()),
|
||||||
let _ = nvim_udp_state.handler.state.event_bus_tx.send(event);
|
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(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -413,6 +664,7 @@ pub fn init_logging(app_name: &str) -> Option<tracing_appender::non_blocking::Wo
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_cli() -> Result<(), Box<dyn std::error::Error>> {
|
pub fn run_cli() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
crate::config::load_mcp_config_env();
|
||||||
let _guard = init_logging("mcp-memory-server");
|
let _guard = init_logging("mcp-memory-server");
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
@@ -556,7 +808,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cli_parsing_default() {
|
fn test_cli_parsing_default() {
|
||||||
let cli = Cli::try_parse_from(&["mcp-memory-server"]).unwrap();
|
let cli = Cli::try_parse_from(["mcp-memory-server"]).unwrap();
|
||||||
assert!(cli.command.is_none());
|
assert!(cli.command.is_none());
|
||||||
assert!(!cli.exit);
|
assert!(!cli.exit);
|
||||||
assert!(!cli.restart);
|
assert!(!cli.restart);
|
||||||
@@ -564,7 +816,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cli_parsing_exit_and_target() {
|
fn test_cli_parsing_exit_and_target() {
|
||||||
let cli = Cli::try_parse_from(&[
|
let cli = Cli::try_parse_from([
|
||||||
"mcp-memory-server",
|
"mcp-memory-server",
|
||||||
"--exit",
|
"--exit",
|
||||||
"--target",
|
"--target",
|
||||||
@@ -577,7 +829,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cli_parsing_gate_set() {
|
fn test_cli_parsing_gate_set() {
|
||||||
let cli = Cli::try_parse_from(&[
|
let cli = Cli::try_parse_from([
|
||||||
"mcp-memory-server",
|
"mcp-memory-server",
|
||||||
"gate",
|
"gate",
|
||||||
"set",
|
"set",
|
||||||
@@ -613,7 +865,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cli_parsing_gate_verify() {
|
fn test_cli_parsing_gate_verify() {
|
||||||
let cli = Cli::try_parse_from(&[
|
let cli = Cli::try_parse_from([
|
||||||
"mcp-memory-server",
|
"mcp-memory-server",
|
||||||
"gate",
|
"gate",
|
||||||
"verify",
|
"verify",
|
||||||
@@ -643,8 +895,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_init_logging_helper() {
|
fn test_init_logging_helper() {
|
||||||
|
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let temp_dir = tempfile::tempdir().unwrap();
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::set_var("MCP_MEMORY_STORE_DIR", temp_dir.path().to_str().unwrap());
|
std::env::set_var("MCP_MEMORY_STORE_DIR", temp_dir.path().to_str().unwrap());
|
||||||
@@ -675,7 +930,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[allow(clippy::await_holding_lock)]
|
||||||
async fn test_run_server_graceful_shutdown() {
|
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 temp_dir = tempfile::tempdir().unwrap();
|
||||||
let state = Arc::new(MemoryState::new(temp_dir.path().to_str().unwrap()));
|
let state = Arc::new(MemoryState::new(temp_dir.path().to_str().unwrap()));
|
||||||
|
|
||||||
|
|||||||
+16
-24
@@ -4,19 +4,15 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
|
#[derive(Default)]
|
||||||
pub enum ChangeKind {
|
pub enum ChangeKind {
|
||||||
Added,
|
Added,
|
||||||
|
#[default]
|
||||||
Modified,
|
Modified,
|
||||||
Deleted,
|
Deleted,
|
||||||
Renamed,
|
Renamed,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ChangeKind {
|
|
||||||
fn default() -> Self {
|
|
||||||
ChangeKind::Modified
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct CodeChange {
|
pub struct CodeChange {
|
||||||
pub timestamp: u64,
|
pub timestamp: u64,
|
||||||
@@ -57,18 +53,18 @@ impl CodeChange {
|
|||||||
if let Some(vcs) = &self.vcs_type {
|
if let Some(vcs) = &self.vcs_type {
|
||||||
return vcs.as_str();
|
return vcs.as_str();
|
||||||
}
|
}
|
||||||
if let Some(rev) = self.revision.as_deref().or(self.git_commit.as_deref()) {
|
if let Some(rev) = self.revision.as_deref().or(self.git_commit.as_deref())
|
||||||
if rev.starts_with('r') && rev[1..].chars().all(|c| c.is_ascii_digit()) {
|
&& rev.starts_with('r')
|
||||||
return "svn";
|
&& rev[1..].chars().all(|c| c.is_ascii_digit())
|
||||||
}
|
{
|
||||||
|
return "svn";
|
||||||
}
|
}
|
||||||
if let Some(br) = self.branch.as_deref().or(self.git_branch.as_deref()) {
|
if let Some(br) = self.branch.as_deref().or(self.git_branch.as_deref())
|
||||||
if br.eq_ignore_ascii_case("trunk")
|
&& (br.eq_ignore_ascii_case("trunk")
|
||||||
|| br.starts_with("branches/")
|
|| br.starts_with("branches/")
|
||||||
|| br.starts_with("tags/")
|
|| br.starts_with("tags/"))
|
||||||
{
|
{
|
||||||
return "svn";
|
return "svn";
|
||||||
}
|
|
||||||
}
|
}
|
||||||
"git"
|
"git"
|
||||||
}
|
}
|
||||||
@@ -266,10 +262,10 @@ impl KnowledgeGraph {
|
|||||||
let mut adj: 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);
|
HashMap::with_capacity(self.relations.len() * 2);
|
||||||
for rel in &self.relations {
|
for rel in &self.relations {
|
||||||
if let Some(rf) = relation_filter {
|
if let Some(rf) = relation_filter
|
||||||
if rel.relation_type != rf {
|
&& rel.relation_type != rf
|
||||||
continue;
|
{
|
||||||
}
|
continue;
|
||||||
}
|
}
|
||||||
adj.entry(rel.from.as_str()).or_default().push((
|
adj.entry(rel.from.as_str()).or_default().push((
|
||||||
rel.to.as_str(),
|
rel.to.as_str(),
|
||||||
@@ -398,7 +394,6 @@ fn default_adr_status() -> String {
|
|||||||
"accepted".to_string()
|
"accepted".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct ErrorFix {
|
pub struct ErrorFix {
|
||||||
pub signature: String,
|
pub signature: String,
|
||||||
@@ -420,7 +415,6 @@ pub struct ErrorFix {
|
|||||||
pub toolchain: Option<String>,
|
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,
|
||||||
@@ -527,7 +521,6 @@ pub struct EnvironmentDetail {
|
|||||||
pub ssh_host: Option<String>,
|
pub ssh_host: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct TechDebt {
|
pub struct TechDebt {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -554,7 +547,6 @@ pub struct TechDebt {
|
|||||||
pub effort_estimate: Option<String>,
|
pub effort_estimate: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct GateRecord {
|
pub struct GateRecord {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|||||||
+29
-3
@@ -23,6 +23,12 @@ struct GenerateRequest<'a> {
|
|||||||
stream: bool,
|
stream: bool,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
images: Option<Vec<&'a str>>,
|
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)]
|
#[derive(Deserialize)]
|
||||||
@@ -34,6 +40,8 @@ struct GenerateResponse {
|
|||||||
struct EmbeddingRequest<'a> {
|
struct EmbeddingRequest<'a> {
|
||||||
model: &'a str,
|
model: &'a str,
|
||||||
prompt: &'a str,
|
prompt: &'a str,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
keep_alive: Option<&'a str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -50,12 +58,17 @@ impl OllamaClient {
|
|||||||
let reasoning_model =
|
let reasoning_model =
|
||||||
env::var("OLLAMA_REASONING_MODEL").unwrap_or_else(|_| "deepseek-r1:1.5b".to_string());
|
env::var("OLLAMA_REASONING_MODEL").unwrap_or_else(|_| "deepseek-r1:1.5b".to_string());
|
||||||
let vision_model =
|
let vision_model =
|
||||||
env::var("OLLAMA_VISION_MODEL").unwrap_or_else(|_| "qwen3-vl:2b".to_string());
|
env::var("OLLAMA_VISION_MODEL").unwrap_or_else(|_| "moondream".to_string());
|
||||||
let embed_model = env::var("OLLAMA_EMBED_MODEL")
|
let embed_model = env::var("OLLAMA_EMBED_MODEL")
|
||||||
.unwrap_or_else(|_| "nomic-embed-text:latest".to_string());
|
.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()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(60))
|
.timeout(Duration::from_secs(timeout_sec))
|
||||||
.build()
|
.build()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
@@ -102,6 +115,7 @@ impl OllamaClient {
|
|||||||
prompt: &str,
|
prompt: &str,
|
||||||
model_override: Option<&str>,
|
model_override: Option<&str>,
|
||||||
system: Option<&str>,
|
system: Option<&str>,
|
||||||
|
format: Option<&str>,
|
||||||
) -> Result<String, AppError> {
|
) -> Result<String, AppError> {
|
||||||
let model = model_override.unwrap_or(&self.coder_model);
|
let model = model_override.unwrap_or(&self.coder_model);
|
||||||
let url = format!("{}/api/generate", self.base_url.trim_end_matches('/'));
|
let url = format!("{}/api/generate", self.base_url.trim_end_matches('/'));
|
||||||
@@ -112,6 +126,12 @@ impl OllamaClient {
|
|||||||
system,
|
system,
|
||||||
stream: false,
|
stream: false,
|
||||||
images: None,
|
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
|
let res = self
|
||||||
@@ -151,6 +171,12 @@ impl OllamaClient {
|
|||||||
),
|
),
|
||||||
stream: false,
|
stream: false,
|
||||||
images: Some(vec![image_base64]),
|
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
|
let res = self
|
||||||
@@ -181,6 +207,7 @@ impl OllamaClient {
|
|||||||
let body = EmbeddingRequest {
|
let body = EmbeddingRequest {
|
||||||
model: &self.embed_model,
|
model: &self.embed_model,
|
||||||
prompt: text,
|
prompt: text,
|
||||||
|
keep_alive: Some("1h"),
|
||||||
};
|
};
|
||||||
|
|
||||||
let res = self
|
let res = self
|
||||||
@@ -241,4 +268,3 @@ async fn test_ollama_client_invalid_api_key() {
|
|||||||
client.base_url = "http://invalid-api-key:11434".to_string();
|
client.base_url = "http://invalid-api-key:11434".to_string();
|
||||||
assert!(!client.is_available().await);
|
assert!(!client.is_available().await);
|
||||||
}
|
}
|
||||||
|
|
||||||
+237
-16
@@ -639,12 +639,19 @@ impl MemoryHandler {
|
|||||||
if let Some(resource) = self.resources.get(uri) {
|
if let Some(resource) = self.resources.get(uri) {
|
||||||
match resource.read(Arc::clone(&self.state)).await {
|
match resource.read(Arc::clone(&self.state)).await {
|
||||||
Ok(text) => {
|
Ok(text) => {
|
||||||
|
let mut content_obj = serde_json::json!({
|
||||||
|
"uri": uri,
|
||||||
|
"mimeType": resource.mime_type().unwrap_or("application/json"),
|
||||||
|
"text": &text
|
||||||
|
});
|
||||||
|
|
||||||
|
if text.len() > 4000 {
|
||||||
|
content_obj["cacheControl"] =
|
||||||
|
serde_json::json!({"type": "ephemeral"});
|
||||||
|
}
|
||||||
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"contents": [{
|
"contents": [content_obj]
|
||||||
"uri": uri,
|
|
||||||
"mimeType": resource.mime_type().unwrap_or("application/json"),
|
|
||||||
"text": text
|
|
||||||
}]
|
|
||||||
});
|
});
|
||||||
Some(crate::mcp::success(id, payload))
|
Some(crate::mcp::success(id, payload))
|
||||||
}
|
}
|
||||||
@@ -756,8 +763,15 @@ impl MemoryHandler {
|
|||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(text) => {
|
Ok(text) => {
|
||||||
|
let mut content_obj = serde_json::json!({"type": "text", "text": &text});
|
||||||
|
|
||||||
|
// Heuristic: If response is large (e.g. > 4000 chars), inject cache_control for Prompt Caching
|
||||||
|
if text.len() > 4000 {
|
||||||
|
content_obj["cacheControl"] = serde_json::json!({"type": "ephemeral"});
|
||||||
|
}
|
||||||
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"content": [{"type": "text", "text": text}],
|
"content": [content_obj],
|
||||||
"isError": false
|
"isError": false
|
||||||
});
|
});
|
||||||
Some(crate::mcp::success(id_clone, payload))
|
Some(crate::mcp::success(id_clone, payload))
|
||||||
@@ -772,6 +786,195 @@ impl MemoryHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"tasks/complete" => {
|
||||||
|
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
|
||||||
|
let task_id = params.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
if task_id.is_empty() {
|
||||||
|
return Some(crate::mcp::error(id, -32602, "Missing task id parameter"));
|
||||||
|
}
|
||||||
|
self.state.project.tasks.modify(|tasks| {
|
||||||
|
for t in tasks.iter_mut() {
|
||||||
|
if t.id == task_id {
|
||||||
|
t.status = "completed".to_string();
|
||||||
|
t.updated_at = crate::handlers::utils::now_secs();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let unblocked = crate::handlers::reconciliation::cascade_unblock_tasks(&self.state);
|
||||||
|
let milestones =
|
||||||
|
crate::handlers::reconciliation::recalculate_milestones(&self.state);
|
||||||
|
self.state.record_activity(
|
||||||
|
"task",
|
||||||
|
&format!("Completed task via WebSocket RPC: {}", task_id),
|
||||||
|
Some("completed"),
|
||||||
|
);
|
||||||
|
let _ = self.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||||
|
topic: "task:event".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({
|
||||||
|
"task_id": task_id,
|
||||||
|
"status": "completed",
|
||||||
|
"unblocked_tasks": unblocked,
|
||||||
|
"updated_milestones": milestones,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
Some(crate::mcp::success(
|
||||||
|
id,
|
||||||
|
serde_json::json!({
|
||||||
|
"status": "success",
|
||||||
|
"unblocked_tasks": unblocked,
|
||||||
|
"updated_milestones": milestones
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
"tech_debts/resolve" => {
|
||||||
|
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
|
||||||
|
let debt_id = params.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
if debt_id.is_empty() {
|
||||||
|
return Some(crate::mcp::error(id, -32602, "Missing debt id parameter"));
|
||||||
|
}
|
||||||
|
let mut found = false;
|
||||||
|
self.state.code.tech_debts.modify(|debts| {
|
||||||
|
for d in debts.iter_mut() {
|
||||||
|
if d.id == debt_id {
|
||||||
|
d.is_resolved = true;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if found {
|
||||||
|
self.state.record_activity(
|
||||||
|
"tech_debt",
|
||||||
|
&format!("Resolved tech debt via WebSocket RPC: {}", debt_id),
|
||||||
|
Some("resolved"),
|
||||||
|
);
|
||||||
|
let _ = self.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||||
|
topic: "tech_debt:event".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({
|
||||||
|
"id": debt_id,
|
||||||
|
"status": "resolved"
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
Some(crate::mcp::success(
|
||||||
|
id,
|
||||||
|
serde_json::json!({ "status": "success", "message": format!("Tech debt {} resolved", debt_id) }),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Some(crate::mcp::error(id, -32602, "Tech debt not found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"gates/authorize" => {
|
||||||
|
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
|
||||||
|
let gate_id = params.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let mut found = false;
|
||||||
|
self.state.env.gates.modify(|gates| {
|
||||||
|
for g in gates.iter_mut() {
|
||||||
|
if g.id == gate_id {
|
||||||
|
g.status = "authorized".to_string();
|
||||||
|
g.reason = Some("Manually authorized via WebSocket RPC".to_string());
|
||||||
|
g.timestamp = crate::handlers::utils::now_secs();
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if found {
|
||||||
|
self.state.record_activity(
|
||||||
|
"gate",
|
||||||
|
&format!("Authorized push gate via WebSocket: {}", gate_id),
|
||||||
|
Some("authorized"),
|
||||||
|
);
|
||||||
|
let _ = self.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||||
|
topic: "gate:event".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({ "id": gate_id, "status": "authorized", "active_gate": "authorized" }),
|
||||||
|
});
|
||||||
|
Some(crate::mcp::success(
|
||||||
|
id,
|
||||||
|
serde_json::json!({ "status": "success" }),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Some(crate::mcp::error(id, -32602, "Gate not found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"gates/block" => {
|
||||||
|
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
|
||||||
|
let gate_id = params.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let mut found = false;
|
||||||
|
self.state.env.gates.modify(|gates| {
|
||||||
|
for g in gates.iter_mut() {
|
||||||
|
if g.id == gate_id {
|
||||||
|
g.status = "blocked".to_string();
|
||||||
|
g.reason = Some("Manually blocked via WebSocket RPC".to_string());
|
||||||
|
g.timestamp = crate::handlers::utils::now_secs();
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if found {
|
||||||
|
self.state.record_activity(
|
||||||
|
"gate",
|
||||||
|
&format!("Blocked push gate via WebSocket: {}", gate_id),
|
||||||
|
Some("blocked"),
|
||||||
|
);
|
||||||
|
let _ = self.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||||
|
topic: "gate:event".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({ "id": gate_id, "status": "blocked", "active_gate": "blocked" }),
|
||||||
|
});
|
||||||
|
Some(crate::mcp::success(
|
||||||
|
id,
|
||||||
|
serde_json::json!({ "status": "success" }),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Some(crate::mcp::error(id, -32602, "Gate not found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"gates/delete" => {
|
||||||
|
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
|
||||||
|
let gate_id = params.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let mut removed = false;
|
||||||
|
self.state.env.gates.modify(|gates| {
|
||||||
|
if let Some(pos) = gates.iter().position(|g| g.id == gate_id) {
|
||||||
|
gates.remove(pos);
|
||||||
|
removed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if removed {
|
||||||
|
self.state.record_activity(
|
||||||
|
"gate",
|
||||||
|
&format!("Deleted push gate via WebSocket: {}", gate_id),
|
||||||
|
Some("deleted"),
|
||||||
|
);
|
||||||
|
let _ = self.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||||
|
topic: "gate:event".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({ "id": gate_id, "status": "deleted" }),
|
||||||
|
});
|
||||||
|
Some(crate::mcp::success(
|
||||||
|
id,
|
||||||
|
serde_json::json!({ "status": "success" }),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Some(crate::mcp::error(id, -32602, "Gate not found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"resources/subscribe" => {
|
||||||
|
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
|
||||||
|
let uri = params.get("uri").and_then(|u| u.as_str()).unwrap_or("");
|
||||||
|
tracing::info!("Client subscribed to MCP resource: {}", uri);
|
||||||
|
Some(crate::mcp::success(id, serde_json::json!({})))
|
||||||
|
}
|
||||||
|
"resources/unsubscribe" => {
|
||||||
|
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
|
||||||
|
let uri = params.get("uri").and_then(|u| u.as_str()).unwrap_or("");
|
||||||
|
tracing::info!("Client unsubscribed from MCP resource: {}", uri);
|
||||||
|
Some(crate::mcp::success(id, serde_json::json!({})))
|
||||||
|
}
|
||||||
m if m.starts_with("notifications/") => None,
|
m if m.starts_with("notifications/") => None,
|
||||||
"ping" => Some(crate::mcp::success(id, serde_json::json!({}))),
|
"ping" => Some(crate::mcp::success(id, serde_json::json!({}))),
|
||||||
_ => {
|
_ => {
|
||||||
@@ -850,7 +1053,10 @@ pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) ->
|
|||||||
("Create Entities", names)
|
("Create Entities", names)
|
||||||
}
|
}
|
||||||
"hypotheses" => {
|
"hypotheses" => {
|
||||||
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("manage");
|
let act = args
|
||||||
|
.get("action")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("manage");
|
||||||
let hyp = args
|
let hyp = args
|
||||||
.get("hypothesis")
|
.get("hypothesis")
|
||||||
.or_else(|| args.get("query"))
|
.or_else(|| args.get("query"))
|
||||||
@@ -858,24 +1064,39 @@ pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) ->
|
|||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
(
|
(
|
||||||
"Hypotheses",
|
"Hypotheses",
|
||||||
format!("{}: {}", act, hyp).trim_end_matches(": ").to_string(),
|
format!("{}: {}", act, hyp)
|
||||||
|
.trim_end_matches(": ")
|
||||||
|
.to_string(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
"agent_signals" => {
|
"agent_signals" => {
|
||||||
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("signal");
|
let act = args
|
||||||
|
.get("action")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("signal");
|
||||||
let sender = args.get("sender").and_then(|v| v.as_str()).unwrap_or("");
|
let sender = args.get("sender").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
let st = args.get("signal_type").and_then(|v| v.as_str()).unwrap_or("");
|
let st = args
|
||||||
|
.get("signal_type")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
(
|
(
|
||||||
"Agent Signals",
|
"Agent Signals",
|
||||||
format!("{}: {} [{}]", act, sender, st).trim_end_matches(": ").to_string(),
|
format!("{}: {} [{}]", act, sender, st)
|
||||||
|
.trim_end_matches(": ")
|
||||||
|
.to_string(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
"process_logs" => {
|
"process_logs" => {
|
||||||
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("logs");
|
let act = args
|
||||||
|
.get("action")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("logs");
|
||||||
let file = args.get("file_path").and_then(|v| v.as_str()).unwrap_or("");
|
let file = args.get("file_path").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
(
|
(
|
||||||
"Process Logs",
|
"Process Logs",
|
||||||
format!("{}: {}", act, file).trim_end_matches(": ").to_string(),
|
format!("{}: {}", act, file)
|
||||||
|
.trim_end_matches(": ")
|
||||||
|
.to_string(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_ => return name.to_string(),
|
_ => return name.to_string(),
|
||||||
@@ -900,8 +1121,8 @@ async fn execute_tool_span(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use tempfile::tempdir;
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_format_tool_activity_description() {
|
fn test_format_tool_activity_description() {
|
||||||
@@ -1177,7 +1398,7 @@ mod tests {
|
|||||||
let handler = MemoryHandler::new(state);
|
let handler = MemoryHandler::new(state);
|
||||||
|
|
||||||
// 1. Verify invalid LLM requests fail gracefully with isError: true or structured response
|
// 1. Verify invalid LLM requests fail gracefully with isError: true or structured response
|
||||||
for (tool_name, _tool) in &handler.tools {
|
for tool_name in handler.tools.keys() {
|
||||||
let invalid_req = json!({
|
let invalid_req = json!({
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"id": 200,
|
"id": 200,
|
||||||
@@ -1207,7 +1428,7 @@ mod tests {
|
|||||||
("decisions", json!({"action": "query"})),
|
("decisions", json!({"action": "query"})),
|
||||||
("tech_debt", json!({"action": "list"})),
|
("tech_debt", json!({"action": "list"})),
|
||||||
("snippets", json!({"action": "search", "query": "test"})),
|
("snippets", json!({"action": "search", "query": "test"})),
|
||||||
("clipboard", json!({"action": "read"})),
|
("clipboard", json!({"action": "history"})),
|
||||||
("environment", json!({"action": "read_fingerprint"})),
|
("environment", json!({"action": "read_fingerprint"})),
|
||||||
("omni_search", json!({"query": "test"})),
|
("omni_search", json!({"query": "test"})),
|
||||||
("get_project_health", json!({})),
|
("get_project_health", json!({})),
|
||||||
|
|||||||
+242
-19
@@ -1,4 +1,6 @@
|
|||||||
|
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};
|
||||||
@@ -14,6 +16,26 @@ pub struct SearchResult {
|
|||||||
|
|
||||||
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,
|
||||||
@@ -53,7 +75,8 @@ impl MemoryIndex {
|
|||||||
.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]);
|
let query_parser =
|
||||||
|
tantivy::query::QueryParser::for_index(&index, vec![title_field, body_field]);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
index,
|
index,
|
||||||
@@ -85,7 +108,8 @@ impl MemoryIndex {
|
|||||||
.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]);
|
let query_parser =
|
||||||
|
tantivy::query::QueryParser::for_index(&index, vec![title_field, body_field]);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
index,
|
index,
|
||||||
@@ -115,7 +139,10 @@ impl MemoryIndex {
|
|||||||
self.index_entities_batch(std::slice::from_ref(e))
|
self.index_entities_batch(std::slice::from_ref(e))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn index_entities_batch(&self, entities: &[Entity]) -> tokio::task::JoinHandle<tantivy::Result<()>> {
|
pub fn index_entities_batch(
|
||||||
|
&self,
|
||||||
|
entities: &[Entity],
|
||||||
|
) -> tokio::task::JoinHandle<tantivy::Result<()>> {
|
||||||
let writer = Arc::clone(&self.writer);
|
let writer = Arc::clone(&self.writer);
|
||||||
let id_field = self.id_field;
|
let id_field = self.id_field;
|
||||||
let needs_commit = Arc::clone(&self.needs_commit);
|
let needs_commit = Arc::clone(&self.needs_commit);
|
||||||
@@ -152,13 +179,16 @@ 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| {
|
let criteria_body = t
|
||||||
if !acc.is_empty() {
|
.acceptance_criteria
|
||||||
acc.push('\n');
|
.iter()
|
||||||
}
|
.fold(String::new(), |mut acc, c| {
|
||||||
acc.push_str(&c.description);
|
if !acc.is_empty() {
|
||||||
acc
|
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(),
|
||||||
@@ -177,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;
|
||||||
@@ -201,7 +259,10 @@ impl MemoryIndex {
|
|||||||
if let Err(first_err) = writer.commit() {
|
if let Err(first_err) = writer.commit() {
|
||||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
writer.commit().map_err(|second_err| {
|
writer.commit().map_err(|second_err| {
|
||||||
tantivy::TantivyError::SystemError(format!("First commit error: {}; Second commit error: {}", first_err, second_err))
|
tantivy::TantivyError::SystemError(format!(
|
||||||
|
"First commit error: {}; Second commit error: {}",
|
||||||
|
first_err, second_err
|
||||||
|
))
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
let _ = reader.reload();
|
let _ = reader.reload();
|
||||||
@@ -216,9 +277,11 @@ impl MemoryIndex {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub fn commit_if_needed(&self) -> tantivy::Result<()> {
|
pub fn commit_if_needed(&self) -> tantivy::Result<()> {
|
||||||
if self.needs_commit.swap(false, std::sync::atomic::Ordering::SeqCst) {
|
if self
|
||||||
|
.needs_commit
|
||||||
|
.swap(false, std::sync::atomic::Ordering::SeqCst)
|
||||||
|
{
|
||||||
let mut writer = self.writer.lock().unwrap_or_else(|e| e.into_inner());
|
let mut writer = self.writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
writer.commit()?;
|
writer.commit()?;
|
||||||
let _ = self.reader.reload();
|
let _ = self.reader.reload();
|
||||||
@@ -238,8 +301,8 @@ impl MemoryIndex {
|
|||||||
let sanitized: String = query
|
let sanitized: String = query
|
||||||
.chars()
|
.chars()
|
||||||
.map(|c| match c {
|
.map(|c| match c {
|
||||||
'+' | '-' | '&' | '|' | '!' | '(' | ')' | '{' | '}' | '[' | ']' | '^'
|
'+' | '-' | '&' | '|' | '!' | '(' | ')' | '{' | '}' | '[' | ']' | '^' | '"'
|
||||||
| '"' | '~' | '*' | '?' | ':' | '\\' | '/' => ' ',
|
| '~' | '*' | '?' | ':' | '\\' | '/' => ' ',
|
||||||
_ => c,
|
_ => c,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -254,10 +317,11 @@ impl MemoryIndex {
|
|||||||
|
|
||||||
let target_limit = if is_wildcard_fallback { 10 } else { 50 };
|
let target_limit = if is_wildcard_fallback { 10 } else { 50 };
|
||||||
let final_query: Box<dyn tantivy::query::Query> = if let Some(ns) = namespace {
|
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(
|
let ns_term_query: Box<dyn tantivy::query::Query> =
|
||||||
tantivy::Term::from_field_text(self.namespace_field, ns),
|
Box::new(tantivy::query::TermQuery::new(
|
||||||
tantivy::schema::IndexRecordOption::Basic,
|
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 ns_filter: Box<dyn tantivy::query::Query> = if ns != "global" {
|
||||||
let global_term_query = Box::new(tantivy::query::TermQuery::new(
|
let global_term_query = Box::new(tantivy::query::TermQuery::new(
|
||||||
tantivy::Term::from_field_text(self.namespace_field, "global"),
|
tantivy::Term::from_field_text(self.namespace_field, "global"),
|
||||||
@@ -432,6 +496,165 @@ impl MemoryIndex {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+113
-146
@@ -1,5 +1,6 @@
|
|||||||
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;
|
||||||
@@ -42,6 +43,78 @@ pub struct TelemetryStores {
|
|||||||
pub agent_signals: Store<Vec<AgentSignal>>,
|
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 struct MemoryState {
|
||||||
pub base_dir: PathBuf,
|
pub base_dir: PathBuf,
|
||||||
pub index_commit_notify: Arc<tokio::sync::Notify>,
|
pub index_commit_notify: Arc<tokio::sync::Notify>,
|
||||||
@@ -59,6 +132,7 @@ pub struct MemoryState {
|
|||||||
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 ollama: Arc<crate::ollama::OllamaClient>,
|
||||||
|
pub clipboard_cache: Arc<tokio::sync::RwLock<ClipboardCacheState>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryState {
|
impl MemoryState {
|
||||||
@@ -69,10 +143,8 @@ impl MemoryState {
|
|||||||
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 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);
|
||||||
if !is_in_memory {
|
if !is_in_memory && let Err(e) = std::fs::create_dir_all(&base) {
|
||||||
if let Err(e) = std::fs::create_dir_all(&base) {
|
tracing::error!("Failed to create store directory at {:?}: {}", base, e);
|
||||||
tracing::error!("Failed to create store directory at {:?}: {}", base, e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let db = crate::db::init_redb(&base);
|
let db = crate::db::init_redb(&base);
|
||||||
@@ -133,6 +205,7 @@ impl MemoryState {
|
|||||||
|
|
||||||
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())),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Normalize pre-existing graph entity and relation types
|
// Normalize pre-existing graph entity and relation types
|
||||||
@@ -162,6 +235,19 @@ 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 async fn get_search_index(&self) -> MemoryIndex {
|
pub async fn get_search_index(&self) -> MemoryIndex {
|
||||||
@@ -283,7 +369,24 @@ impl MemoryState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn broadcast_task_event(&self, event: TaskEvent) {
|
pub fn broadcast_task_event(&self, event: TaskEvent) {
|
||||||
let payload_val = serde_json::to_value(&event).unwrap_or_default();
|
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 summary_str = format!("Task {} -> {}", event.task_id, event.status);
|
||||||
let details_str = payload_val.to_string();
|
let details_str = payload_val.to_string();
|
||||||
@@ -315,6 +418,11 @@ impl MemoryState {
|
|||||||
payload: payload_val,
|
payload: payload_val,
|
||||||
};
|
};
|
||||||
let _ = self.event_bus_tx.send(generic_ev);
|
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!({
|
let ws_notification = serde_json::json!({
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
@@ -468,144 +576,3 @@ mod tests {
|
|||||||
assert!(activities_updated[0].timestamp >= activities_updated[1].timestamp);
|
assert!(activities_updated[0].timestamp >= activities_updated[1].timestamp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::embedding::{cosine_similarity, generate_embedding_async, generate_embeddings_async};
|
|
||||||
|
|
||||||
pub struct UnifiedSearchResult {
|
|
||||||
pub id: String,
|
|
||||||
pub doc_type: String,
|
|
||||||
pub title: String,
|
|
||||||
pub body: String,
|
|
||||||
pub score: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
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<UnifiedSearchResult>> {
|
|
||||||
let query_emb = generate_embedding_async(query.to_string())
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
let mut results = Vec::new();
|
|
||||||
let mut cached_items = 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() {
|
|
||||||
let title = snippet.name.clone();
|
|
||||||
let desc = snippet.description.clone();
|
|
||||||
if let Some(ref emb) = snippet.embedding {
|
|
||||||
cached_items.push((title, "snippet".to_string(), desc, emb.clone()));
|
|
||||||
} else if uncached_texts.len() < 50 {
|
|
||||||
uncached_texts.push(format!(
|
|
||||||
"{} {} {}",
|
|
||||||
snippet.name, snippet.description, snippet.code
|
|
||||||
));
|
|
||||||
uncached_meta.push((title, "snippet".to_string(), desc));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
self.state.read_graph(|graph| {
|
|
||||||
for entity in graph.entities.values() {
|
|
||||||
if let Some(ns) = filter_namespace {
|
|
||||||
if entity.namespace != ns {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let title = entity.name.clone();
|
|
||||||
let obs = entity.observations.join("; ");
|
|
||||||
let desc = format!("{}: {}", entity.entity_type, obs);
|
|
||||||
if let Some(ref emb) = entity.embedding {
|
|
||||||
cached_items.push((title, "entity".to_string(), desc, emb.clone()));
|
|
||||||
} else if uncached_texts.len() < 50 {
|
|
||||||
uncached_texts.push(format!("{} {} {}", entity.name, entity.entity_type, obs));
|
|
||||||
uncached_meta.push((title, "entity".to_string(), desc));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
self.state.code.error_fixes.read_with(|fixes| {
|
|
||||||
for fix in fixes.iter() {
|
|
||||||
let title = fix.signature.clone();
|
|
||||||
let desc = fix.solution.clone();
|
|
||||||
if let Some(ref emb) = fix.embedding {
|
|
||||||
cached_items.push((title, "error_fix".to_string(), desc, emb.clone()));
|
|
||||||
} else if uncached_texts.len() < 50 {
|
|
||||||
uncached_texts.push(format!("{} {}", fix.signature, fix.solution));
|
|
||||||
uncached_meta.push((title, "error_fix".to_string(), desc));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for (title, doc_type, body, emb) in cached_items {
|
|
||||||
let sim = cosine_similarity(&query_emb, &emb);
|
|
||||||
results.push(UnifiedSearchResult {
|
|
||||||
id: title.clone(),
|
|
||||||
doc_type,
|
|
||||||
title,
|
|
||||||
body,
|
|
||||||
score: sim,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if !uncached_texts.is_empty()
|
|
||||||
&& let Ok(embeddings) = generate_embeddings_async(uncached_texts).await
|
|
||||||
{
|
|
||||||
for (emb, meta) in embeddings.into_iter().zip(uncached_meta) {
|
|
||||||
let sim = cosine_similarity(&query_emb, &emb);
|
|
||||||
results.push(UnifiedSearchResult {
|
|
||||||
id: meta.0.clone(),
|
|
||||||
doc_type: meta.1.clone(),
|
|
||||||
title: meta.0,
|
|
||||||
body: meta.2,
|
|
||||||
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<UnifiedSearchResult>> {
|
|
||||||
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(UnifiedSearchResult {
|
|
||||||
id,
|
|
||||||
doc_type,
|
|
||||||
title,
|
|
||||||
body,
|
|
||||||
score,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(results)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+52
-98
@@ -33,10 +33,11 @@ static QUEUE_REGISTRY: std::sync::Mutex<Option<(Arc<Database>, DbWriteQueue)>> =
|
|||||||
|
|
||||||
fn get_or_create_queue(db: Arc<Database>) -> DbWriteQueue {
|
fn get_or_create_queue(db: Arc<Database>) -> DbWriteQueue {
|
||||||
let mut reg = QUEUE_REGISTRY.lock().unwrap_or_else(|e| e.into_inner());
|
let mut reg = QUEUE_REGISTRY.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
if let Some((ref existing_db, ref queue)) = *reg {
|
if let Some((ref existing_db, ref queue)) = *reg
|
||||||
if Arc::ptr_eq(existing_db, &db) && !queue.tx.is_closed() {
|
&& Arc::ptr_eq(existing_db, &db)
|
||||||
return queue.clone();
|
&& !queue.tx.is_closed()
|
||||||
}
|
{
|
||||||
|
return queue.clone();
|
||||||
}
|
}
|
||||||
let new_queue = DbWriteQueue::new(db.clone());
|
let new_queue = DbWriteQueue::new(db.clone());
|
||||||
*reg = Some((db, new_queue.clone()));
|
*reg = Some((db, new_queue.clone()));
|
||||||
@@ -334,19 +335,17 @@ impl<T: DeserializeOwned + Default + Serialize + Send + Sync + 'static> Store<T>
|
|||||||
let mut known = std::collections::HashSet::new();
|
let mut known = std::collections::HashSet::new();
|
||||||
|
|
||||||
if let Ok(range) = table.range(prefix.as_str()..) {
|
if let Ok(range) = table.range(prefix.as_str()..) {
|
||||||
for entry in range {
|
for (k, v) in range.flatten() {
|
||||||
if let Ok((k, v)) = entry {
|
let k_str = k.value();
|
||||||
let k_str = k.value();
|
if !k_str.starts_with(&prefix) {
|
||||||
if !k_str.starts_with(&prefix) {
|
break;
|
||||||
break;
|
}
|
||||||
}
|
found_granular = true;
|
||||||
found_granular = true;
|
known.insert(k_str.to_string());
|
||||||
known.insert(k_str.to_string());
|
if let Ok(val) = serde_json::from_slice::<serde_json::Value>(v.value()) {
|
||||||
if let Ok(val) = serde_json::from_slice::<serde_json::Value>(v.value()) {
|
let sub_key = &k_str[prefix.len()..];
|
||||||
let sub_key = &k_str[prefix.len()..];
|
items_array.push(val.clone());
|
||||||
items_array.push(val.clone());
|
items_map.insert(sub_key.to_string(), val);
|
||||||
items_map.insert(sub_key.to_string(), val);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -444,14 +443,36 @@ impl<T: DeserializeOwned + Default + Serialize + Send + Sync + 'static> Store<T>
|
|||||||
(*lock).clone()
|
(*lock).clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Expensive serialization and granular extraction run completely unblocked outside the lock
|
match self.prepare_batch(&new_snapshot) {
|
||||||
let full_bytes_res = serde_json::to_vec(&new_snapshot);
|
Ok((batch_inserts, removed_keys)) => {
|
||||||
let granular_entries = full_bytes_res
|
self.queue.push_batch(
|
||||||
.as_ref()
|
self.key.clone(),
|
||||||
.ok()
|
batch_inserts,
|
||||||
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(bytes).ok())
|
removed_keys,
|
||||||
.map(|val| Self::extract_granular_entries(&self.key, &val))
|
self.flushed.clone(),
|
||||||
.unwrap_or_default();
|
);
|
||||||
|
}
|
||||||
|
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> =
|
let new_keys: std::collections::HashSet<String> =
|
||||||
granular_entries.iter().map(|(k, _)| k.clone()).collect();
|
granular_entries.iter().map(|(k, _)| k.clone()).collect();
|
||||||
@@ -469,48 +490,11 @@ impl<T: DeserializeOwned + Default + Serialize + Send + Sync + 'static> Store<T>
|
|||||||
*known = new_keys;
|
*known = new_keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
match full_bytes_res {
|
let mut batch_inserts = Vec::with_capacity(granular_entries.len() + 1);
|
||||||
Ok(data) => {
|
batch_inserts.extend(granular_entries);
|
||||||
let mut batch_inserts = Vec::with_capacity(granular_entries.len() + 1);
|
batch_inserts.push((self.key.clone(), full_bytes));
|
||||||
for (g_key, g_bytes) in granular_entries {
|
|
||||||
batch_inserts.push((g_key, g_bytes));
|
|
||||||
}
|
|
||||||
batch_inserts.push((self.key.clone(), data.clone()));
|
|
||||||
|
|
||||||
if self
|
Ok((batch_inserts, removed_keys))
|
||||||
.queue
|
|
||||||
.push_batch(
|
|
||||||
self.key.clone(),
|
|
||||||
batch_inserts.clone(),
|
|
||||||
removed_keys.clone(),
|
|
||||||
self.flushed.clone(),
|
|
||||||
)
|
|
||||||
.is_none()
|
|
||||||
{
|
|
||||||
tracing::warn!(
|
|
||||||
"DbWriteQueue channel full for key '{}'. Applying backpressure fallback.",
|
|
||||||
self.key
|
|
||||||
);
|
|
||||||
let queue = self.queue.clone();
|
|
||||||
let key = self.key.clone();
|
|
||||||
let flushed = self.flushed.clone();
|
|
||||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
|
||||||
handle.spawn(async move {
|
|
||||||
let _ = tokio::time::timeout(
|
|
||||||
std::time::Duration::from_secs(10),
|
|
||||||
queue.push_batch_async(key, batch_inserts, removed_keys, flushed),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => tracing::error!(
|
|
||||||
"Failed to serialize memory store for key '{}': {}",
|
|
||||||
self.key,
|
|
||||||
e
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn modify_async<F: FnOnce(&mut T)>(&self, f: F)
|
pub async fn modify_async<F: FnOnce(&mut T)>(&self, f: F)
|
||||||
@@ -531,38 +515,8 @@ impl<T: DeserializeOwned + Default + Serialize + Send + Sync + 'static> Store<T>
|
|||||||
(*lock).clone()
|
(*lock).clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
let full_bytes_res = serde_json::to_vec(&new_snapshot);
|
match self.prepare_batch(&new_snapshot) {
|
||||||
let granular_entries = full_bytes_res
|
Ok((batch_inserts, removed_keys)) => {
|
||||||
.as_ref()
|
|
||||||
.ok()
|
|
||||||
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(bytes).ok())
|
|
||||||
.map(|val| Self::extract_granular_entries(&self.key, &val))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
match full_bytes_res {
|
|
||||||
Ok(data) => {
|
|
||||||
let mut batch_inserts = Vec::with_capacity(granular_entries.len() + 1);
|
|
||||||
for (g_key, g_bytes) in granular_entries {
|
|
||||||
batch_inserts.push((g_key, g_bytes));
|
|
||||||
}
|
|
||||||
batch_inserts.push((self.key.clone(), data));
|
|
||||||
|
|
||||||
if let Some(rx) = self
|
if let Some(rx) = self
|
||||||
.queue
|
.queue
|
||||||
.push_batch_async(
|
.push_batch_async(
|
||||||
|
|||||||
+42
-11
@@ -151,7 +151,6 @@ pub struct VisualizeGraphTool {
|
|||||||
pub namespace: Option<String>,
|
pub namespace: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Condense or summarize an entity's observations to reduce size.
|
/// Condense or summarize an entity's observations to reduce size.
|
||||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||||
pub struct CondenseEntityTool {
|
pub struct CondenseEntityTool {
|
||||||
@@ -173,8 +172,6 @@ pub enum SubgraphFormat {
|
|||||||
pub struct GetSubgraphTool {
|
pub struct GetSubgraphTool {
|
||||||
/// The root entity name to start the subgraph search from.
|
/// The root entity name to start the subgraph search from.
|
||||||
pub root_entity: Option<String>,
|
pub root_entity: Option<String>,
|
||||||
/// Legacy alias for root_entity.
|
|
||||||
pub root_node: Option<String>,
|
|
||||||
/// Maximum search depth (hops). Defaults to 2.
|
/// Maximum search depth (hops). Defaults to 2.
|
||||||
pub depth: Option<u32>,
|
pub depth: Option<u32>,
|
||||||
/// Output format: 'json' (raw entities and relations) or 'markdown_tree' (compact topology tree). Defaults to 'json'.
|
/// Output format: 'json' (raw entities and relations) or 'markdown_tree' (compact topology tree). Defaults to 'json'.
|
||||||
@@ -327,13 +324,15 @@ pub struct VerifyAcceptanceCriteriaTool {
|
|||||||
pub proof: String,
|
pub proof: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Audit the knowledge graph to detect orphaned entities, compute name similarity for potential duplicate merges, and optionally auto-prune orphans.
|
/// Audit the knowledge graph to detect orphaned entities, compute name similarity for potential duplicate merges, and optionally auto-prune orphans and stale file/symbol tombstones (ADR-0111).
|
||||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||||
pub struct SweepGraphHealthTool {
|
pub struct SweepGraphHealthTool {
|
||||||
/// Optional flag to automatically prune orphaned nodes with 0 relations. Defaults to false.
|
/// Optional flag to automatically prune orphaned nodes with 0 relations. Defaults to false.
|
||||||
pub auto_prune_orphans: Option<bool>,
|
pub auto_prune_orphans: Option<bool>,
|
||||||
/// Minimum string similarity threshold (0.0 to 1.0) to report duplicate entity pairs. Defaults to 0.8.
|
/// Minimum string similarity threshold (0.0 to 1.0) to report duplicate entity pairs. Defaults to 0.8.
|
||||||
pub similarity_threshold: Option<f32>,
|
pub similarity_threshold: Option<f32>,
|
||||||
|
/// Optional flag to prune stale entities whose file paths or symbols no longer exist on disk (ADR-0111). Defaults to false.
|
||||||
|
pub auto_prune_stale_files: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trace the causal provenance and historical lineage linking a task, ADR, git commit, code change, or error fix.
|
/// Trace the causal provenance and historical lineage linking a task, ADR, git commit, code change, or error fix.
|
||||||
@@ -611,6 +610,7 @@ pub enum HandoffMemoAction {
|
|||||||
/// - 'leave': Post a handoff memo. Required: content. Optional: namespace.
|
/// - 'leave': Post a handoff memo. Required: content. Optional: namespace.
|
||||||
/// - 'read': Inspect active memos. Optional: namespace.
|
/// - 'read': Inspect active memos. Optional: namespace.
|
||||||
/// - 'clear': Clear memos. Optional: namespace, ids.
|
/// - 'clear': Clear memos. Optional: namespace, ids.
|
||||||
|
///
|
||||||
/// Next steps on error: Check parameter requirements for 'leave' or 'clear' actions.
|
/// Next steps on error: Check parameter requirements for 'leave' or 'clear' actions.
|
||||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||||
pub struct HandoffMemosTool {
|
pub struct HandoffMemosTool {
|
||||||
@@ -654,6 +654,7 @@ pub enum TaskAction {
|
|||||||
/// - 'list': List active tasks. Optional: git_branch, summary_level ('compact'|'detailed'|'full'), max_tokens.
|
/// - 'list': List active tasks. Optional: git_branch, summary_level ('compact'|'detailed'|'full'), max_tokens.
|
||||||
/// - 'set_criteria': Set acceptance criteria. Required: id, criteria (array of strings).
|
/// - 'set_criteria': Set acceptance criteria. Required: id, criteria (array of strings).
|
||||||
/// - 'verify': Verify criteria met. Required: id.
|
/// - 'verify': Verify criteria met. Required: id.
|
||||||
|
///
|
||||||
/// Next steps on error: Check required parameters or call list to verify task IDs.
|
/// Next steps on error: Check required parameters or call list to verify task IDs.
|
||||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||||
pub struct TasksTool {
|
pub struct TasksTool {
|
||||||
@@ -718,6 +719,7 @@ pub enum MilestoneAction {
|
|||||||
/// - 'add': Create milestone. Required: title. Optional: namespace.
|
/// - 'add': Create milestone. Required: title. Optional: namespace.
|
||||||
/// - 'update': Update milestone status. Required: id, status.
|
/// - 'update': Update milestone status. Required: id, status.
|
||||||
/// - 'list': List milestones. Optional: namespace.
|
/// - 'list': List milestones. Optional: namespace.
|
||||||
|
///
|
||||||
/// Next steps on error: Provide required title for 'add' or id/status for 'update'.
|
/// Next steps on error: Provide required title for 'add' or id/status for 'update'.
|
||||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||||
pub struct MilestonesTool {
|
pub struct MilestonesTool {
|
||||||
@@ -739,6 +741,9 @@ pub struct MilestonesTool {
|
|||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
/// Optional deliverables or task checklist.
|
/// Optional deliverables or task checklist.
|
||||||
pub deliverables: Option<Vec<String>>,
|
pub deliverables: Option<Vec<String>>,
|
||||||
|
/// Optional list of task IDs associated with this milestone.
|
||||||
|
#[serde(default)]
|
||||||
|
pub task_ids: Option<Vec<String>>,
|
||||||
/// Optional repository name.
|
/// Optional repository name.
|
||||||
pub repo_name: Option<String>,
|
pub repo_name: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -761,6 +766,7 @@ pub enum SnippetAction {
|
|||||||
/// - 'search': Search snippets. Optional: query, tags, hybrid (boolean for BM25+vector search).
|
/// - 'search': Search snippets. Optional: query, tags, hybrid (boolean for BM25+vector search).
|
||||||
/// - 'delete': Delete snippet. Required: id (or query) as snippet name.
|
/// - 'delete': Delete snippet. Required: id (or query) as snippet name.
|
||||||
/// - 'tag': Tag snippet. Required: id (or query), tags (array of strings).
|
/// - 'tag': Tag snippet. Required: id (or query), tags (array of strings).
|
||||||
|
///
|
||||||
/// Next steps on error: Ensure snippet name/query or id is provided.
|
/// Next steps on error: Ensure snippet name/query or id is provided.
|
||||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||||
pub struct SnippetsTool {
|
pub struct SnippetsTool {
|
||||||
@@ -811,6 +817,7 @@ pub enum DecisionAction {
|
|||||||
/// - 'query': Search ADRs. Optional: query, namespace.
|
/// - 'query': Search ADRs. Optional: query, namespace.
|
||||||
/// - 'update': Update ADR status or implementation info. Required: id. Optional: status, git_commit, git_branch, task_id, context, decision, consequences, title.
|
/// - 'update': Update ADR status or implementation info. Required: id. Optional: status, git_commit, git_branch, task_id, context, decision, consequences, title.
|
||||||
/// - 'delete': Remove ADR. Required: id.
|
/// - 'delete': Remove ADR. Required: id.
|
||||||
|
///
|
||||||
/// Next steps on error: Provide title for 'log' or id for 'update'/'delete'.
|
/// Next steps on error: Provide title for 'log' or id for 'update'/'delete'.
|
||||||
#[derive(Deserialize, Serialize, JsonSchema, Debug, Clone)]
|
#[derive(Deserialize, Serialize, JsonSchema, Debug, Clone)]
|
||||||
pub struct DecisionsTool {
|
pub struct DecisionsTool {
|
||||||
@@ -871,6 +878,7 @@ pub enum TechDebtAction {
|
|||||||
/// - 'log': Record technical debt. Required: description. Optional: ideal_solution, git_commit, git_branch, symbol_references, line_range, namespace.
|
/// - 'log': Record technical debt. Required: description. Optional: ideal_solution, git_commit, git_branch, symbol_references, line_range, namespace.
|
||||||
/// - 'resolve': Mark tech debt resolved. Required: id.
|
/// - 'resolve': Mark tech debt resolved. Required: id.
|
||||||
/// - 'list': List tech debt items. Optional: include_resolved, namespace.
|
/// - 'list': List tech debt items. Optional: include_resolved, namespace.
|
||||||
|
///
|
||||||
/// Next steps on error: Provide description for 'log' or valid ID for 'resolve'.
|
/// Next steps on error: Provide description for 'log' or valid ID for 'resolve'.
|
||||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||||
pub struct TechDebtTool {
|
pub struct TechDebtTool {
|
||||||
@@ -959,6 +967,7 @@ pub enum EnvAction {
|
|||||||
/// - 'log_requirement': Log environment variable requirement. Required: key. Optional: description, is_secret, namespace.
|
/// - 'log_requirement': Log environment variable requirement. Required: key. Optional: description, is_secret, namespace.
|
||||||
/// - 'register': Register remote target environment. Required: name. Optional: url, description, requires_vpn, namespace.
|
/// - 'register': Register remote target environment. Required: name. Optional: url, description, requires_vpn, namespace.
|
||||||
/// - 'get_details': Get full environment details. Optional: namespace.
|
/// - 'get_details': Get full environment details. Optional: namespace.
|
||||||
|
///
|
||||||
/// Next steps on error: Provide key for 'log_requirement' or name for 'register'.
|
/// Next steps on error: Provide key for 'log_requirement' or name for 'register'.
|
||||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||||
pub struct EnvironmentTool {
|
pub struct EnvironmentTool {
|
||||||
@@ -980,24 +989,46 @@ pub struct EnvironmentTool {
|
|||||||
pub requires_vpn: Option<bool>,
|
pub requires_vpn: Option<bool>,
|
||||||
/// Optional namespace.
|
/// Optional namespace.
|
||||||
pub namespace: Option<String>,
|
pub namespace: Option<String>,
|
||||||
|
/// Optional operating system name (e.g. 'windows', 'linux', 'darwin').
|
||||||
|
pub os: Option<String>,
|
||||||
|
/// Optional shell name (e.g. 'pwsh', 'bash', 'zsh', 'cmd').
|
||||||
|
pub shell: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)]
|
#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ClipboardAction {
|
pub enum ClipboardAction {
|
||||||
#[serde(alias = "read", alias = "READ", alias = "Read")]
|
#[serde(
|
||||||
Read,
|
alias = "image",
|
||||||
|
alias = "IMAGE",
|
||||||
|
alias = "Image",
|
||||||
|
alias = "screenshot",
|
||||||
|
alias = "SCREENSHOT"
|
||||||
|
)]
|
||||||
|
Image,
|
||||||
|
#[serde(alias = "text", alias = "TEXT", alias = "Text")]
|
||||||
|
Text,
|
||||||
|
#[serde(alias = "history", alias = "HISTORY", alias = "History")]
|
||||||
|
History,
|
||||||
|
#[serde(alias = "clear", alias = "CLEAR", alias = "Clear")]
|
||||||
|
Clear,
|
||||||
#[serde(alias = "write", alias = "WRITE", alias = "Write")]
|
#[serde(alias = "write", alias = "WRITE", alias = "Write")]
|
||||||
Write,
|
Write,
|
||||||
}
|
}
|
||||||
/// OS Clipboard management (read, write).
|
/// Smart OS Clipboard management with overwrite-immune screenshot caching and OCR.
|
||||||
/// Actions:
|
/// Actions:
|
||||||
/// - 'read': Read current clipboard contents.
|
/// - 'image': Get latest screenshot image path (Windows + WSL) and extracted verbatim OCR text.
|
||||||
|
/// Returns cached screenshot even if text was copied afterwards!
|
||||||
|
/// - 'text': Get latest clipboard text (or normalized Markdown if HTML was copied).
|
||||||
|
/// - 'history': View recent clipboard history ring buffer (images and text with timestamps).
|
||||||
|
/// - 'clear': Clear OS clipboard and memory cache.
|
||||||
/// - 'write': Write content to OS clipboard. Optional: text, html, files, image_path.
|
/// - 'write': Write content to OS clipboard. Optional: text, html, files, image_path.
|
||||||
/// Next steps on error: Provide text/html/files/image_path when calling 'write'.
|
///
|
||||||
|
/// Triggers: Call 'image' immediately when user says "look at image in clipboard", "see screenshot",
|
||||||
|
/// "look at clipboard", "what I copied", or shares terminal error captures.
|
||||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||||
pub struct ClipboardTool {
|
pub struct ClipboardTool {
|
||||||
/// Action to perform: 'read' or 'write'.
|
/// Action to perform: 'image', 'text', 'history', 'clear', 'read', or 'write'.
|
||||||
pub action: ClipboardAction,
|
pub action: ClipboardAction,
|
||||||
/// Plain text content (for 'write').
|
/// Plain text content (for 'write').
|
||||||
pub text: Option<String>,
|
pub text: Option<String>,
|
||||||
|
|||||||
+129
-17
@@ -56,10 +56,10 @@ pub fn spawn_watcher(state: Arc<MemoryState>) {
|
|||||||
for path in event.paths {
|
for path in event.paths {
|
||||||
if should_review(&path) {
|
if should_review(&path) {
|
||||||
// 250ms debouncing window per file path
|
// 250ms debouncing window per file path
|
||||||
if let Some(last) = last_processed.get(&path) {
|
if let Some(last) = last_processed.get(&path)
|
||||||
if now.duration_since(*last) < std::time::Duration::from_millis(250) {
|
&& now.duration_since(*last) < std::time::Duration::from_millis(250)
|
||||||
continue;
|
{
|
||||||
}
|
continue;
|
||||||
}
|
}
|
||||||
last_processed.insert(path.clone(), now);
|
last_processed.insert(path.clone(), now);
|
||||||
|
|
||||||
@@ -79,25 +79,138 @@ pub fn spawn_watcher(state: Arc<MemoryState>) {
|
|||||||
|
|
||||||
fn should_review(path: &Path) -> bool {
|
fn should_review(path: &Path) -> bool {
|
||||||
let path_str = path.to_string_lossy();
|
let path_str = path.to_string_lossy();
|
||||||
if path_str.contains(".git") || path_str.contains("target") || path_str.contains(".gemini") || path_str.contains("node_modules") {
|
if path_str.contains(".git")
|
||||||
|
|| path_str.contains("target")
|
||||||
|
|| path_str.contains(".gemini")
|
||||||
|
|| path_str.contains("node_modules")
|
||||||
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ext) = path.extension() {
|
path.extension()
|
||||||
let ext_str = ext.to_string_lossy();
|
.and_then(|ext| ext.to_str())
|
||||||
if ext_str == "rs" || ext_str == "md" || ext_str == "toml" || ext_str == "lua" {
|
.is_some_and(|ext| matches!(ext, "rs" | "md" | "toml" | "lua"))
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn trigger_autonomous_review(path: &Path, state: Arc<MemoryState>) {
|
async fn trigger_autonomous_review(path: &Path, state: Arc<MemoryState>) {
|
||||||
info!("Triggering autonomous review for {:?}", path);
|
info!(
|
||||||
state.broadcast_activity("AUTONOMOUS", &format!("Modified: {:?}", path.file_name().unwrap_or_default()));
|
"Triggering autonomous review & incremental AST index for {:?}",
|
||||||
info!("Autonomous review complete for {:?}", path);
|
path
|
||||||
}
|
);
|
||||||
|
state.broadcast_activity(
|
||||||
|
"AUTONOMOUS",
|
||||||
|
&format!("Modified: {:?}", path.file_name().unwrap_or_default()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ADR-0109: Incremental Background AST Indexing & Differential Graph Updates
|
||||||
|
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||||
|
if matches!(ext, "rs" | "ts" | "js" | "py" | "go" | "java" | "c" | "cpp")
|
||||||
|
&& let Ok(content) = std::fs::read_to_string(path)
|
||||||
|
{
|
||||||
|
let language = match ext {
|
||||||
|
"rs" => Some(tree_sitter_rust::LANGUAGE),
|
||||||
|
"ts" | "js" => Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT),
|
||||||
|
"py" => Some(tree_sitter_python::LANGUAGE),
|
||||||
|
"java" => Some(tree_sitter_java::LANGUAGE),
|
||||||
|
"c" => Some(tree_sitter_c::LANGUAGE),
|
||||||
|
"cpp" => Some(tree_sitter_cpp::LANGUAGE),
|
||||||
|
"go" => Some(tree_sitter_go::LANGUAGE),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(lang) = language {
|
||||||
|
let mut parser = tree_sitter::Parser::new();
|
||||||
|
if parser.set_language(&lang.into()).is_ok()
|
||||||
|
&& let Some(tree) = parser.parse(&content, None)
|
||||||
|
{
|
||||||
|
let mut chunks = Vec::new();
|
||||||
|
crate::indexer::extract_chunks(tree.root_node(), &content, &mut chunks, ext);
|
||||||
|
let file_str = path.to_string_lossy().to_string();
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
let chunks_count = chunks.len();
|
||||||
|
let file_str_clone = file_str.clone();
|
||||||
|
state.modify_graph(|g| {
|
||||||
|
// Ensure File entity exists
|
||||||
|
g.entities
|
||||||
|
.entry(file_str.clone())
|
||||||
|
.or_insert_with(|| crate::models::Entity {
|
||||||
|
name: file_str.clone(),
|
||||||
|
entity_type: "File".to_string(),
|
||||||
|
namespace: "global".to_string(),
|
||||||
|
file_path: Some(file_str.clone()),
|
||||||
|
created_at: Some(now),
|
||||||
|
updated_at: Some(now),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
for (chunk_name, chunk_code, chunk_desc) in chunks {
|
||||||
|
let symbol_name = format!("{}::{}", file_str, chunk_name);
|
||||||
|
let symbol_type = if chunk_desc.contains("struct") {
|
||||||
|
"DataStructure".to_string()
|
||||||
|
} else {
|
||||||
|
"McpTool".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
g.entities.insert(
|
||||||
|
symbol_name.clone(),
|
||||||
|
crate::models::Entity {
|
||||||
|
name: symbol_name.clone(),
|
||||||
|
entity_type: symbol_type,
|
||||||
|
observations: vec![format!(
|
||||||
|
"AST definition: {} chars",
|
||||||
|
chunk_code.len()
|
||||||
|
)],
|
||||||
|
namespace: "global".to_string(),
|
||||||
|
file_path: Some(file_str.clone()),
|
||||||
|
created_at: Some(now),
|
||||||
|
updated_at: Some(now),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let rel = crate::models::Relation {
|
||||||
|
from: file_str.clone(),
|
||||||
|
to: symbol_name,
|
||||||
|
relation_type: "declares".to_string(),
|
||||||
|
namespace: "global".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
if !g.relations.contains(&rel) {
|
||||||
|
g.relations.push(rel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = state.event_bus_tx.send(crate::state::GenericEvent {
|
||||||
|
topic: "ast:symbol_updated".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({
|
||||||
|
"file": file_str_clone,
|
||||||
|
"extension": ext,
|
||||||
|
"symbols_count": chunks_count,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = state.event_bus_tx.send(crate::state::GenericEvent {
|
||||||
|
topic: "resource:updated".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
payload: serde_json::json!({
|
||||||
|
"uri": "memory://graph"
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Autonomous review & incremental AST index complete for {:?}",
|
||||||
|
path
|
||||||
|
);
|
||||||
|
}
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -128,4 +241,3 @@ mod tests {
|
|||||||
spawn_watcher(state);
|
spawn_watcher(state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#[test]
|
||||||
|
fn test_img() {
|
||||||
|
let mut img = image::DynamicImage::new_rgb8(10, 10);
|
||||||
|
img.invert();
|
||||||
|
let _img2 = img.adjust_contrast(20.0);
|
||||||
|
}
|
||||||
+25
-12
@@ -93,18 +93,18 @@ fn test_dashboard_route_parity() {
|
|||||||
"server/src/api/setup.rs"
|
"server/src/api/setup.rs"
|
||||||
};
|
};
|
||||||
|
|
||||||
let dashboard_content = std::fs::read_to_string(dashboard_path)
|
let dashboard_content =
|
||||||
.expect("Failed to read dashboard.ts");
|
std::fs::read_to_string(dashboard_path).expect("Failed to read dashboard.ts");
|
||||||
let setup_content = std::fs::read_to_string(setup_path)
|
let setup_content = std::fs::read_to_string(setup_path).expect("Failed to read setup.rs");
|
||||||
.expect("Failed to read setup.rs");
|
|
||||||
|
|
||||||
// 1. Extract routes from setup.rs
|
// 1. Extract routes from setup.rs
|
||||||
let mut registered_routes = HashSet::new();
|
let mut registered_routes = HashSet::new();
|
||||||
for part in setup_content.split(".route(") {
|
for part in setup_content.split(".route(") {
|
||||||
let trimmed = part.trim_start();
|
let trimmed = part.trim_start();
|
||||||
if trimmed.starts_with('"') {
|
#[allow(clippy::collapsible_if)]
|
||||||
if let Some(end_quote) = trimmed[1..].find('"') {
|
if let Some(stripped) = trimmed.strip_prefix('"') {
|
||||||
let route = &trimmed[1..=end_quote];
|
if let Some(end_quote) = stripped.find('"') {
|
||||||
|
let route = &stripped[..end_quote];
|
||||||
registered_routes.insert(route.to_string());
|
registered_routes.insert(route.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,8 +120,10 @@ fn test_dashboard_route_parity() {
|
|||||||
let rest = &dashboard_content[start..];
|
let rest = &dashboard_content[start..];
|
||||||
let quote_char = dashboard_content[..start].chars().last();
|
let quote_char = dashboard_content[..start].chars().last();
|
||||||
let mut endpoint = String::new();
|
let mut endpoint = String::new();
|
||||||
|
#[allow(clippy::collapsible_if)]
|
||||||
if let Some(q) = quote_char {
|
if let Some(q) = quote_char {
|
||||||
if q == '`' || q == '"' || q == '\'' {
|
let is_quote = q == '`' || q == '"' || q == '\'';
|
||||||
|
if is_quote {
|
||||||
if let Some(close_idx) = rest.find(q) {
|
if let Some(close_idx) = rest.find(q) {
|
||||||
let full_call = &rest[..close_idx];
|
let full_call = &rest[..close_idx];
|
||||||
let clean = if let Some(q_idx) = full_call.find('?') {
|
let clean = if let Some(q_idx) = full_call.find('?') {
|
||||||
@@ -129,13 +131,21 @@ fn test_dashboard_route_parity() {
|
|||||||
} else {
|
} else {
|
||||||
full_call
|
full_call
|
||||||
};
|
};
|
||||||
let normalized = clean.replace("${id}", "{id}");
|
let mut normalized = clean.to_string();
|
||||||
endpoint = normalized.to_string();
|
while let Some(start_var) = normalized.find("${") {
|
||||||
|
if let Some(end_var) = normalized[start_var..].find('}') {
|
||||||
|
normalized.replace_range(start_var..=start_var + end_var, "{id}");
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
endpoint = normalized;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if endpoint.is_empty() {
|
if endpoint.is_empty() {
|
||||||
let end_idx = rest.find(['"', '`', '\'', ' ', '\n', '?'])
|
let end_idx = rest
|
||||||
|
.find(['"', '`', '\'', ' ', '\n', '?'])
|
||||||
.unwrap_or(rest.len());
|
.unwrap_or(rest.len());
|
||||||
endpoint = rest[..end_idx].to_string();
|
endpoint = rest[..end_idx].to_string();
|
||||||
}
|
}
|
||||||
@@ -146,7 +156,10 @@ fn test_dashboard_route_parity() {
|
|||||||
cursor = start + 5;
|
cursor = start + 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
assert!(!frontend_endpoints.is_empty(), "No frontend endpoints found in dashboard.ts");
|
assert!(
|
||||||
|
!frontend_endpoints.is_empty(),
|
||||||
|
"No frontend endpoints found in dashboard.ts"
|
||||||
|
);
|
||||||
|
|
||||||
for endpoint in &frontend_endpoints {
|
for endpoint in &frontend_endpoints {
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -17,3 +17,6 @@ serde_json = "1.0.151"
|
|||||||
mcp-stdio = { version = "1.0.0", path = "../mcp-stdio" }
|
mcp-stdio = { version = "1.0.0", path = "../mcp-stdio" }
|
||||||
regex = "1.13.1"
|
regex = "1.13.1"
|
||||||
async-channel = "2.5.0"
|
async-channel = "2.5.0"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
chrono = "0.4.45"
|
||||||
+20
-3
@@ -1,22 +1,39 @@
|
|||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let git_hash = Command::new("git")
|
let mut git_hash = Command::new("git")
|
||||||
.args(["rev-parse", "--short", "HEAD"])
|
.args(["rev-parse", "--short", "HEAD"])
|
||||||
.output()
|
.output()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|out| String::from_utf8(out.stdout).ok())
|
.and_then(|out| String::from_utf8(out.stdout).ok())
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
.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")
|
let git_date = Command::new("git")
|
||||||
.args(["log", "-1", "--format=%cd", "--date=format:%y.%m.%d.%H%M%S"])
|
.args(["log", "-1", "--format=%cd", "--date=format:%y.%m.%d.%H%M%S"])
|
||||||
.output()
|
.output()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|out| String::from_utf8(out.stdout).ok())
|
.and_then(|out| String::from_utf8(out.stdout).ok())
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
.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!("{} ({})", git_date.trim(), git_hash.trim());
|
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");
|
||||||
}
|
}
|
||||||
+24
-8
@@ -48,28 +48,45 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_extract_log_prefix_response() {
|
fn test_extract_log_prefix_response() {
|
||||||
let json_str = r#"{"jsonrpc":"2.0","id":123,"result":{}}"#;
|
let json_str = r#"{"jsonrpc":"2.0","id":123,"result":{}}"#;
|
||||||
assert_eq!(extract_log_prefix(json_str, true), "Response id=123 [Error: false]");
|
assert_eq!(
|
||||||
|
extract_log_prefix(json_str, true),
|
||||||
|
"Response id=123 [Error: false]"
|
||||||
|
);
|
||||||
|
|
||||||
let err_json = r#"{"jsonrpc":"2.0","id":456,"error":{"code":-32600,"message":"Invalid Request"}}"#;
|
let err_json =
|
||||||
assert_eq!(extract_log_prefix(err_json, true), "Response id=456 [Error: true]");
|
r#"{"jsonrpc":"2.0","id":456,"error":{"code":-32600,"message":"Invalid Request"}}"#;
|
||||||
|
assert_eq!(
|
||||||
|
extract_log_prefix(err_json, true),
|
||||||
|
"Response id=456 [Error: true]"
|
||||||
|
);
|
||||||
|
|
||||||
let is_err_json = r#"{"jsonrpc":"2.0","id":789,"result":{"isError":true}}"#;
|
let is_err_json = r#"{"jsonrpc":"2.0","id":789,"result":{"isError":true}}"#;
|
||||||
assert_eq!(extract_log_prefix(is_err_json, true), "Response id=789 [Error: true]");
|
assert_eq!(
|
||||||
|
extract_log_prefix(is_err_json, true),
|
||||||
|
"Response id=789 [Error: true]"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_extract_log_prefix_tool_call() {
|
fn test_extract_log_prefix_tool_call() {
|
||||||
let json_str = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search"}}"#;
|
let json_str =
|
||||||
|
r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search"}}"#;
|
||||||
assert_eq!(extract_log_prefix(json_str, false), "ToolCall[search] id=1");
|
assert_eq!(extract_log_prefix(json_str, false), "ToolCall[search] id=1");
|
||||||
|
|
||||||
let unknown_tool = r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{}}"#;
|
let unknown_tool = r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{}}"#;
|
||||||
assert_eq!(extract_log_prefix(unknown_tool, false), "ToolCall[unknown] id=2");
|
assert_eq!(
|
||||||
|
extract_log_prefix(unknown_tool, false),
|
||||||
|
"ToolCall[unknown] id=2"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_extract_log_prefix_request() {
|
fn test_extract_log_prefix_request() {
|
||||||
let json_str = r#"{"jsonrpc":"2.0","id":10,"method":"initialize"}"#;
|
let json_str = r#"{"jsonrpc":"2.0","id":10,"method":"initialize"}"#;
|
||||||
assert_eq!(extract_log_prefix(json_str, false), "Request[initialize] id=10");
|
assert_eq!(
|
||||||
|
extract_log_prefix(json_str, false),
|
||||||
|
"Request[initialize] id=10"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -84,4 +101,3 @@ mod tests {
|
|||||||
assert_eq!(extract_log_prefix(json_str, false), "Message");
|
assert_eq!(extract_log_prefix(json_str, false), "Message");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+2
-2
@@ -170,11 +170,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if retry_count >= 20 {
|
if retry_count >= 20 {
|
||||||
return Err("Failed to connect to the MCP memory server after multiple attempts. Is it running?".into());
|
return Err("Failed to connect to the MCP memory server after multiple attempts. Is it running?".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@ async fn test_stub_connection_refused() {
|
|||||||
.spawn()
|
.spawn()
|
||||||
.expect("Failed to execute stub");
|
.expect("Failed to execute stub");
|
||||||
|
|
||||||
let res = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
|
let res = tokio::time::timeout(Duration::from_secs(15), child.wait()).await;
|
||||||
let elapsed = start.elapsed();
|
let elapsed = start.elapsed();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
@@ -71,7 +71,7 @@ async fn test_stub_handles_eof_cleanly() {
|
|||||||
} // stdin dropped here
|
} // stdin dropped here
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let res = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
|
let res = tokio::time::timeout(Duration::from_secs(15), child.wait()).await;
|
||||||
let elapsed = start.elapsed();
|
let elapsed = start.elapsed();
|
||||||
|
|
||||||
assert!(res.is_ok(), "Stub hung after EOF! Took {elapsed:?}");
|
assert!(res.is_ok(), "Stub hung after EOF! Took {elapsed:?}");
|
||||||
@@ -102,7 +102,7 @@ async fn test_stub_sse_fallback_failure() {
|
|||||||
.spawn()
|
.spawn()
|
||||||
.expect("Failed to execute stub");
|
.expect("Failed to execute stub");
|
||||||
|
|
||||||
let res = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
|
let res = tokio::time::timeout(Duration::from_secs(15), child.wait()).await;
|
||||||
let elapsed = start.elapsed();
|
let elapsed = start.elapsed();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
Reference in new issue
Block a user