Compare commits
4
Commits
462f65f66d
...
a083719cf1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a083719cf1 | ||
|
|
87ddb01063 | ||
|
|
83aa26634d | ||
|
|
74ca0948c9 |
No files matched your search
@@ -0,0 +1,13 @@
|
||||
[build]
|
||||
rustc-wrapper = "sccache"
|
||||
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
linker = "rust-lld"
|
||||
|
||||
[profile.dev]
|
||||
debug = 1 # Line tables only: cuts object file & PDB size by ~50%
|
||||
codegen-units = 16 # Maximizes parallel CPU compilation threads
|
||||
incremental = false # Enables 100% sccache object file caching across builds
|
||||
|
||||
[http]
|
||||
check-revoke = false # Prevents SChannel revocation check errors on corporate VPNs
|
||||
Generated
+28
-6
@@ -397,6 +397,19 @@ dependencies = [
|
||||
"no_std_io2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"constant_time_eq",
|
||||
"cpufeatures 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -670,6 +683,12 @@ version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.2"
|
||||
@@ -2337,7 +2356,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mcp-memory-linux-nvim"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"nvim-core",
|
||||
"serde_json",
|
||||
@@ -2346,12 +2365,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mcp-memory-server"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.23.1",
|
||||
"blake3",
|
||||
"chrono",
|
||||
"clap",
|
||||
"clipboard-win",
|
||||
@@ -2373,6 +2393,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
@@ -2391,7 +2412,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mcp-memory-stub"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"clap",
|
||||
@@ -2410,7 +2431,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mcp-memory-win-nvim"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"nvim-core",
|
||||
"serde_json",
|
||||
@@ -2419,7 +2440,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mcp-stdio"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
@@ -2713,7 +2734,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nvim-core"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"dashmap",
|
||||
@@ -4559,6 +4580,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -86,6 +86,18 @@ Update your WSL ~/.gemini/config/mcp_config.json:
|
||||
}
|
||||
`
|
||||
*Note: The --wake-cmd ensures that if you start WSL while Windows is completely asleep, the Linux stub will use WSL interop to silently spin up the Windows daemon in the background before connecting.*
|
||||
|
||||
## Native Local Ollama LLM Handshake
|
||||
`mcp-memory-server` directly interfaces with local Ollama instances (e.g. `http://192.168.1.30:11434`) via a native compiled Rust client (`server/src/ollama.rs`):
|
||||
* **Connection Handshake:** On startup and before executing LLM-enhanced tools, `mcp-memory-server` issues a **1.5-second health probe** (`GET /api/tags`).
|
||||
* **Environment Configuration (`mcp_config.json`):**
|
||||
* `OLLAMA_URL`: Target Ollama host URL (default: `http://192.168.1.30:11434`).
|
||||
* `OLLAMA_CODER_MODEL`: Local coding model (e.g., `qwen2.5-coder:1.5b`).
|
||||
* `OLLAMA_REASONING_MODEL`: Chain-of-thought reasoning model (e.g., `deepseek-r1:1.5b`).
|
||||
* `OLLAMA_VISION_MODEL`: Multimodal vision model (e.g., `qwen3-vl:2b`).
|
||||
* `OLLAMA_EMBED_MODEL`: Dense embedding model (e.g., `nomic-embed-text:latest`).
|
||||
* **Graceful Degradation Guarantee:** If the local Ollama host is offline or unreachable, **no tool ever fails.** Every tool automatically falls back to pure Rust execution and local `redb`/`Tantivy` storage.
|
||||
|
||||
`
|
||||
## Push Safety Gates
|
||||
The daemon also operates as a global safety gate for Git. Before pushing code, run:
|
||||
|
||||
@@ -32,3 +32,9 @@ trigger: always_on
|
||||
# 8. GCP Infrastructure Provisioning (gcloud & Cloud Armor)
|
||||
- **Rule (Cloud Armor IP Limits):** GCP Cloud Armor security policies strictly enforce a limit of **10 IP ranges per rule** (`--src-ip-ranges`). When allowlisting large services (like Atlassian Bitbucket which has 11+ IP CIDR blocks), you MUST split the ranges across multiple rules (e.g., priority 1000 and 1001) to prevent the `Only a maximum of 10 IP ranges allowed per rule` API error.
|
||||
- **Rule (gcloud Idempotency):** When writing bash scripts to provision GCP infrastructure, NEVER use bare `gcloud ... create` commands. You MUST wrap all creation commands in existence checks (e.g., `if ! gcloud ... describe ... >/dev/null 2>&1; then ... fi`) to ensure the script is fully idempotent and can be safely retried upon failure.
|
||||
|
||||
# 9. Rust Build System & Toolchain Resilience
|
||||
- **Rule (SChannel VPN Revocation Bypass):** To prevent `CRYPT_E_NO_REVOCATION_CHECK` errors when fetching crates over corporate VPNs, ensure `.cargo/config.toml` specifies `[http] check-revoke = false`.
|
||||
- **Rule (sccache Caching Efficiency):** When using `sccache` as `rustc-wrapper`, set `incremental = false` under `[profile.dev]` in `.cargo/config.toml`. `sccache` cannot cache incremental compilation units.
|
||||
- **Rule (sccache Daemon Recovery):** If `sccache` fails with socket error 10054 (`connection forcibly closed`), restart the daemon using `sccache --stop-server; Start-Sleep -Seconds 1; sccache --start-server` before retrying compilation.
|
||||
- **Rule (cargo-llvm-cov Toolchain Matching):** Always set `LLVM_COV` and `LLVM_PROFDATA` environment variables to the matching `rustup` toolchain LLVM binaries (`.../lib/rustlib/<target>/bin/llvm-cov.exe`) to prevent LLVM profile format version mismatches.
|
||||
@@ -127,3 +127,20 @@ To reduce token costs and eliminate exact-match string failures:
|
||||
## 17. Tool Schema Discovery (Lazy Loading)
|
||||
Antigravity automatically caches all MCP tool schemas to your disk to save tokens. Do **NOT** grep or search the Rust source code to find tool schemas or arguments. To understand a tool's arguments, directly read `~/.gemini/antigravity-cli/mcp/<server_name>/<tool_name>.json`. Do NOT guess arguments. ALWAYS read the schema if you are unfamiliar with a tool to prevent invalid argument errors.
|
||||
|
||||
## 18. Local Ollama LLM Handshake & Graceful Fallback
|
||||
`mcp-memory-server` includes a native, compiled Rust client (`server/src/ollama.rs`) that interfaces directly with local Ollama hardware (e.g. `http://192.168.1.30:11434`).
|
||||
- **Connection Handshake:** On startup and before executing LLM-enhanced tools, the server performs a **1.5-second health probe** (`GET /api/tags`).
|
||||
- **Environment Variables:** `OLLAMA_URL`, `OLLAMA_CODER_MODEL` (`qwen2.5-coder:1.5b`), `OLLAMA_REASONING_MODEL` (`deepseek-r1:1.5b`), `OLLAMA_VISION_MODEL` (`qwen3-vl:2b`), and `OLLAMA_EMBED_MODEL` (`nomic-embed-text:latest`).
|
||||
- **Synergistic Tool Enhancements:**
|
||||
- **`log_code_change`**: Automatically uses `qwen2.5-coder:1.5b` to summarize architectural impact.
|
||||
- **`log_error_fix`**: Automatically uses `deepseek-r1:1.5b` for root cause & solution extraction.
|
||||
- **`read_clipboard`**: Automatically passes clipboard image bytes to `qwen3-vl:2b` for OCR/layout parsing.
|
||||
- **`omni_search`**: Uses `nomic-embed-text` for dense vector search indexing.
|
||||
- **Graceful Fallback Guarantee:** If Ollama is offline, unreachable, or times out (>1.5s), **no tool ever fails or throws an error.** Every tool automatically degrades gracefully to pure Rust execution and local `redb`/`Tantivy` storage.
|
||||
|
||||
## 19. Tool Discovery & WebSocket Handshake (`tools/list_changed`)
|
||||
The server implements the full Model Context Protocol (MCP) JSON-RPC specification for tool discovery and live updates:
|
||||
- **`tools/list` Endpoint:** Primary clients query this endpoint during initialization to discover all registered tools, schemas, and argument types.
|
||||
- **Dynamic Handshake Notification (`notifications/tools/list_changed`):** When the backend server restarts or modifies its tool definitions, it broadcasts a WebSocket `notifications/tools/list_changed` JSON-RPC notification. Light stubs (`mcp-memory-stub`) automatically receive this event and refresh the host CLI's tool catalog in real time without disconnecting or restarting the CLI context.
|
||||
|
||||
|
||||
@@ -67,11 +67,9 @@ build-stub-win:
|
||||
# =========================================================
|
||||
# 3. DEPLOY (Move Binaries to Global Path)
|
||||
# =========================================================
|
||||
# WARNING: Deploy recipes DO NOT stop running processes.
|
||||
# You MUST stop the server/process before deploying to avoid file lock errors.
|
||||
|
||||
deploy-server:
|
||||
@Write-Host "Deploying Windows Server..." -ForegroundColor Cyan
|
||||
-if (Test-Path "C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe") { Move-Item -Force "C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe" "C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe.old" -ErrorAction SilentlyContinue; Remove-Item -Force "C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe.old" -ErrorAction SilentlyContinue }
|
||||
Copy-Item -Force target\release\mcp-memory-server.exe "C:\Users\reazul.ashraf\.local\bin\"
|
||||
|
||||
|
||||
@@ -99,32 +97,42 @@ deploy-wsl: deploy-stub deploy-nvim deploy-rules-wsl
|
||||
|
||||
build-stub:
|
||||
@Write-Host "Building WSL Stub natively..." -ForegroundColor Cyan
|
||||
wsl.exe -d Ubuntu -e bash -c 'export PATH="$PATH:/home/riz/.cargo/bin" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && cargo build --release -p mcp-memory-stub'
|
||||
wsl.exe -d Ubuntu -e bash -c 'export PATH="/home/riz/.cargo/bin:$PATH" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && cargo build --release -p mcp-memory-stub'
|
||||
|
||||
deploy-stub:
|
||||
@Write-Host "Deploying WSL Stub natively..." -ForegroundColor Cyan
|
||||
wsl.exe -d Ubuntu -e bash -c 'export PATH="$PATH:/home/riz/.cargo/bin" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && cp target/release/mcp-memory-stub /home/riz/.local/bin/'
|
||||
wsl.exe -d Ubuntu -e bash -c 'export PATH="/home/riz/.cargo/bin:$PATH" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && cp target/release/mcp-memory-stub /home/riz/.local/bin/'
|
||||
|
||||
build-nvim:
|
||||
@Write-Host "Building WSL Nvim natively..." -ForegroundColor Cyan
|
||||
wsl.exe -d Ubuntu -e bash -c 'export PATH="$PATH:/home/riz/.cargo/bin" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && cargo build --release -p mcp-memory-linux-nvim'
|
||||
wsl.exe -d Ubuntu -e bash -c 'export PATH="/home/riz/.cargo/bin:$PATH" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && cargo build --release -p mcp-memory-linux-nvim'
|
||||
|
||||
deploy-nvim:
|
||||
@Write-Host "Deploying WSL Nvim natively..." -ForegroundColor Cyan
|
||||
wsl.exe -d Ubuntu -e bash -c 'export PATH="$PATH:/home/riz/.cargo/bin" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && cp target/release/mcp-memory-linux-nvim /home/riz/.local/bin/'
|
||||
wsl.exe -d Ubuntu -e bash -c 'export PATH="/home/riz/.cargo/bin:$PATH" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && cp target/release/mcp-memory-linux-nvim /home/riz/.local/bin/'
|
||||
|
||||
deploy-rules-wsl:
|
||||
@Write-Host "Deploying global Agent rules (WSL)..." -ForegroundColor Cyan
|
||||
wsl.exe -d Ubuntu -e bash -c 'mkdir -p /home/riz/.gemini/config/rules && cp -r /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory/agent-rules/* /home/riz/.gemini/config/rules/'
|
||||
|
||||
# Run standard unit tests across the workspace
|
||||
test:
|
||||
@Write-Host "Running unit tests across workspace..." -ForegroundColor Cyan
|
||||
cargo test --workspace
|
||||
|
||||
# Fast parallel test execution using cargo-nextest
|
||||
test-fast:
|
||||
@Write-Host "Running fast parallel tests via cargo-nextest..." -ForegroundColor Cyan
|
||||
cargo nextest run --workspace
|
||||
|
||||
# Run configuration tests to ensure eagerTools parity
|
||||
test-config:
|
||||
cargo test --release -p mcp-memory-server --test parity_test
|
||||
|
||||
# Run tests and generate code coverage report
|
||||
# Run tests and generate code coverage report with matching rustup LLVM toolchain
|
||||
test-coverage:
|
||||
@Write-Host "Running tests with code coverage..." -ForegroundColor Cyan
|
||||
rustup run stable cargo llvm-cov --workspace
|
||||
$tc = (rustup toolchain list | Select-String "stable").ToString().Split(' ')[0]; $env:LLVM_COV = "$env:USERPROFILE\.rustup\toolchains\$tc\lib\rustlib\x86_64-pc-windows-msvc\bin\llvm-cov.exe"; $env:LLVM_PROFDATA = "$env:USERPROFILE\.rustup\toolchains\$tc\lib\rustlib\x86_64-pc-windows-msvc\bin\llvm-profdata.exe"; cargo llvm-cov --workspace
|
||||
|
||||
# Start the server directly
|
||||
startup-server:
|
||||
@@ -145,3 +153,8 @@ all-server-wsl port="3000": (stop port) build-server deploy-server (start port)
|
||||
build-nvim-win:
|
||||
@Write-Host 'Building Windows Nvim...' -ForegroundColor Cyan
|
||||
cargo build --release -p mcp-memory-win-nvim
|
||||
|
||||
# Auto-generate & verify unit tests using local NAS Ollama ($0 tokens)
|
||||
improve-tests file="server/src/ollama.rs":
|
||||
improve-test {{file}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mcp-memory-linux-nvim"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mcp-stdio"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "nvim-core"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
@@ -13,7 +13,7 @@ tracing-appender = "0.2.5"
|
||||
tracing-subscriber = "0.3.23"
|
||||
dirs = "7.0.0"
|
||||
rmcp = { version = "3.4.0", features = ["server"] }
|
||||
mcp-stdio = { version = "0.1.0", path = "../mcp-stdio" }
|
||||
mcp-stdio = { version = "1.0.0", path = "../mcp-stdio" }
|
||||
bytes = "1.12.1"
|
||||
dashmap = "6.2.1"
|
||||
|
||||
+11
-9
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mcp-memory-server"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
@@ -9,12 +9,12 @@ axum = { version = "0.8", features = ["ws"] }
|
||||
clap = { version = "4.6.6", features = ["derive"] }
|
||||
dirs = "7.0.0"
|
||||
futures-util = "0.3.34"
|
||||
redb = "4.2.0"
|
||||
redb = "4.3.0"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["stream", "json"] }
|
||||
schemars = "1.2.2"
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.151"
|
||||
tantivy = "0.26.1"
|
||||
tantivy = "0.26.2"
|
||||
tokio = { version = "1.53.1", features = ["full"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = "0.3.23"
|
||||
@@ -24,15 +24,15 @@ rmcp = { version = "3.4.0", features = ["server"] }
|
||||
thiserror = "2.0.20"
|
||||
tower = { version = "0.5.3", features = ["util"] }
|
||||
fastembed = "7.1.0"
|
||||
arboard = "3.4.0"
|
||||
image = "0.25.0"
|
||||
arboard = "3.6.1"
|
||||
image = "0.25.10"
|
||||
base64 = "0.23.1"
|
||||
clipboard-win = "5.4.1"
|
||||
git2 = "0.19.0"
|
||||
tree-sitter = "0.23.0"
|
||||
tree-sitter-rust = "0.23.0"
|
||||
tree-sitter-typescript = "0.23.0"
|
||||
tree-sitter-python = "0.23.0"
|
||||
tree-sitter = "0.23.2"
|
||||
tree-sitter-rust = "0.23.3"
|
||||
tree-sitter-typescript = "0.23.2"
|
||||
tree-sitter-python = "0.23.6"
|
||||
tree-sitter-java = "0.23.5"
|
||||
tree-sitter-c = "0.24.2"
|
||||
tree-sitter-cpp = "0.23.4"
|
||||
@@ -40,6 +40,8 @@ tree-sitter-go = "0.25.0"
|
||||
ignore = "0.4.33"
|
||||
notify = "7"
|
||||
qdrant-client = "1.19.0"
|
||||
blake3 = "1.8"
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
|
||||
[build-dependencies]
|
||||
chrono = "0.4.45"
|
||||
|
||||
+68
-10
@@ -9,6 +9,7 @@ use axum::{
|
||||
routing::{get, post},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
pub fn create_router(app_state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
@@ -24,11 +25,78 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
|
||||
.route("/ws", get(ws_handler))
|
||||
.route("/health", get(health_handler))
|
||||
.route("/ping", get(|| async move { (axum::http::StatusCode::OK, "pong") }))
|
||||
.route(
|
||||
"/metrics",
|
||||
get({
|
||||
let state_clone = app_state.handler.state.clone();
|
||||
move || async move {
|
||||
let graph = state_clone.graph.cache.read().unwrap();
|
||||
let entity_count = graph.entities.len();
|
||||
let relation_count = graph.relations.len();
|
||||
let tasks = state_clone.project.tasks.cache.read().unwrap();
|
||||
let active_tasks = tasks.iter().filter(|t| t.status != "completed" && t.status != "done").count();
|
||||
let adrs = state_clone.code.adrs.cache.read().unwrap();
|
||||
let adr_count = adrs.len();
|
||||
let tech_debts = state_clone.code.tech_debts.cache.read().unwrap();
|
||||
let debt_count = tech_debts.iter().filter(|d| !d.is_resolved).count();
|
||||
|
||||
let metrics = format!(
|
||||
"# HELP mcp_memory_entities_total Total entities in knowledge graph\n\
|
||||
# TYPE mcp_memory_entities_total gauge\n\
|
||||
mcp_memory_entities_total {}\n\
|
||||
# HELP mcp_memory_relations_total Total relations in knowledge graph\n\
|
||||
# TYPE mcp_memory_relations_total gauge\n\
|
||||
mcp_memory_relations_total {}\n\
|
||||
# HELP mcp_memory_active_tasks Active tasks count\n\
|
||||
# TYPE mcp_memory_active_tasks gauge\n\
|
||||
mcp_memory_active_tasks {}\n\
|
||||
# HELP mcp_memory_adrs_total Total ADR decisions\n\
|
||||
# TYPE mcp_memory_adrs_total gauge\n\
|
||||
mcp_memory_adrs_total {}\n\
|
||||
# HELP mcp_memory_unresolved_tech_debt Unresolved tech debt count\n\
|
||||
# TYPE mcp_memory_unresolved_tech_debt gauge\n\
|
||||
mcp_memory_unresolved_tech_debt {}\n",
|
||||
entity_count, relation_count, active_tasks, adr_count, debt_count
|
||||
);
|
||||
([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], metrics)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route("/nvim/telemetry", post(nvim_telemetry_handler))
|
||||
.route("/api/terminal/history", get(crate::api::telemetry::get_terminal_history_handler))
|
||||
.route("/terminal/telemetry", post(crate::api::telemetry::terminal_telemetry_handler))
|
||||
.route("/events/wait", get(crate::api::events::wait_for_event_handler))
|
||||
.route("/events", post(crate::api::events::post_event_handler))
|
||||
.route(
|
||||
"/api/activity",
|
||||
get({
|
||||
let state_clone = app_state.handler.state.clone();
|
||||
move || async move {
|
||||
let activity_json = state_clone
|
||||
.telemetry
|
||||
.recent_activities
|
||||
.read_with(|act| serde_json::to_string(act).unwrap_or_else(|_| "[]".to_string()));
|
||||
([(axum::http::header::CONTENT_TYPE, "application/json")], activity_json)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/activity/stream",
|
||||
get({
|
||||
let state_clone = app_state.handler.state.clone();
|
||||
move || async move {
|
||||
let rx = state_clone.activity_tx.subscribe();
|
||||
let stream = tokio_stream::wrappers::BroadcastStream::new(rx)
|
||||
.filter_map(|msg| match msg {
|
||||
Ok(data) => Some(Ok::<_, std::convert::Infallible>(
|
||||
axum::response::sse::Event::default().data(data),
|
||||
)),
|
||||
Err(_) => None,
|
||||
});
|
||||
axum::response::sse::Sse::new(stream)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route("/gate/verify", get(gate_verify_handler))
|
||||
.route("/gate/set", post(gate_set_handler))
|
||||
.route(
|
||||
@@ -140,16 +208,6 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/activity",
|
||||
get({
|
||||
let state_clone = app_state.handler.state.clone();
|
||||
move || async move {
|
||||
let activities_json = state_clone.telemetry.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/tech_debts",
|
||||
get({
|
||||
|
||||
@@ -71,6 +71,10 @@ pub async fn nvim_telemetry_handler(
|
||||
let _ = state.handler.state.event_bus_tx.send(event);
|
||||
}
|
||||
|
||||
if let Some(ref f) = payload.file {
|
||||
state.handler.state.record_activity("editor", &format!("Neovim {}: {}", payload.event, f), None);
|
||||
}
|
||||
|
||||
axum::Json(serde_json::json!({"status": "ok"}))
|
||||
}
|
||||
|
||||
@@ -79,15 +83,30 @@ use crate::models::TerminalHistory;
|
||||
pub async fn get_terminal_history_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> impl axum::response::IntoResponse {
|
||||
let history_json = state.handler.state.telemetry.terminal_history.read_with(|h| serde_json::to_string(h).unwrap_or_else(|_| "[]".to_string()));
|
||||
([(axum::http::header::CONTENT_TYPE, "application/json")], history_json)
|
||||
let history_json = state
|
||||
.handler
|
||||
.state
|
||||
.telemetry
|
||||
.terminal_history
|
||||
.read_with(|h| serde_json::to_string(h).unwrap_or_else(|_| "[]".to_string()));
|
||||
(
|
||||
[(axum::http::header::CONTENT_TYPE, "application/json")],
|
||||
history_json,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn terminal_telemetry_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::Json(payload): axum::Json<TerminalHistory>,
|
||||
) -> impl axum::response::IntoResponse {
|
||||
state.handler.state.telemetry.terminal_history.modify(|history| {
|
||||
state.handler.state.record_activity("terminal", &format!("Ran command: {}", payload.command), Some(&format!("exit_code: {}, cwd: {}", payload.exit_code, payload.cwd)));
|
||||
|
||||
state
|
||||
.handler
|
||||
.state
|
||||
.telemetry
|
||||
.terminal_history
|
||||
.modify(|history| {
|
||||
history.push_front(payload.clone());
|
||||
if history.len() > 100 {
|
||||
history.pop_back();
|
||||
@@ -120,12 +139,12 @@ mod tests {
|
||||
use crate::router::MemoryHandler;
|
||||
use crate::state::MemoryState;
|
||||
use axum::http::Request;
|
||||
use tower::ServiceExt;
|
||||
use axum::routing::get;
|
||||
use axum::routing::post;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use tempfile::tempdir;
|
||||
use axum::routing::post;
|
||||
use axum::routing::get;
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_terminal_history() {
|
||||
@@ -158,7 +177,9 @@ mod tests {
|
||||
.method("POST")
|
||||
.uri("/terminal/telemetry")
|
||||
.header("content-type", "application/json")
|
||||
.body(axum::body::Body::from(serde_json::to_string(&payload).unwrap()))
|
||||
.body(axum::body::Body::from(
|
||||
serde_json::to_string(&payload).unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let response = app.clone().oneshot(req).await.unwrap();
|
||||
@@ -174,10 +195,11 @@ mod tests {
|
||||
let response2 = app.oneshot(req2).await.unwrap();
|
||||
assert_eq!(response2.status(), 200);
|
||||
|
||||
let body_bytes = axum::body::to_bytes(response2.into_body(), usize::MAX).await.unwrap();
|
||||
let body_bytes = axum::body::to_bytes(response2.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let history: Vec<TerminalHistory> = serde_json::from_slice(&body_bytes).unwrap();
|
||||
assert_eq!(history.len(), 1);
|
||||
assert_eq!(history[0].command, "echo test");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::state::MemoryState;
|
||||
use crate::models::StickyNote;
|
||||
use std::sync::Arc;
|
||||
use tokio::time::{sleep, Duration};
|
||||
use crate::state::MemoryState;
|
||||
use arboard::Clipboard;
|
||||
use std::sync::Arc;
|
||||
use tokio::time::{Duration, sleep};
|
||||
pub fn spawn_watcher(state: Arc<MemoryState>) {
|
||||
tokio::spawn(async move {
|
||||
let mut last_text = String::new();
|
||||
@@ -18,9 +18,11 @@ pub fn spawn_watcher(state: Arc<MemoryState>) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(mut clipboard) = Clipboard::new() {
|
||||
if let Ok(text) = clipboard.get_text() {
|
||||
if text != last_text && !text.trim().is_empty() {
|
||||
if let Ok(mut clipboard) = Clipboard::new()
|
||||
&& let Ok(text) = clipboard.get_text()
|
||||
&& text != last_text
|
||||
&& !text.trim().is_empty()
|
||||
{
|
||||
last_text = text.clone();
|
||||
|
||||
let note = StickyNote {
|
||||
@@ -40,7 +42,5 @@ pub fn spawn_watcher(state: Arc<MemoryState>) {
|
||||
state.rebuild_index().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -972,19 +972,34 @@
|
||||
return text.replace(regex, '<span class="search-highlight">$1</span>');
|
||||
}
|
||||
|
||||
let searchDebounceTimer = null;
|
||||
let activeSearchAbortController = null;
|
||||
|
||||
async function handleSearch(e) {
|
||||
const q = e.target.value;
|
||||
// Provide live feedback if they type at least 3 characters, or hit Enter
|
||||
if (e.key === 'Enter' || q.length >= 3 || q.length === 0) {
|
||||
const q = e.target.value.trim();
|
||||
|
||||
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
|
||||
|
||||
if (!q) {
|
||||
if (activeSearchAbortController) activeSearchAbortController.abort();
|
||||
document.getElementById('search-results').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
|
||||
|
||||
const delay = (e.key === 'Enter') ? 0 : 250;
|
||||
|
||||
searchDebounceTimer = setTimeout(async () => {
|
||||
if (activeSearchAbortController) activeSearchAbortController.abort();
|
||||
activeSearchAbortController = new AbortController();
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
|
||||
signal: activeSearchAbortController.signal
|
||||
});
|
||||
const data = await res.json();
|
||||
const container = document.getElementById('search-results');
|
||||
|
||||
if (data.results.length === 0) {
|
||||
if (!data.results || data.results.length === 0) {
|
||||
container.innerHTML = '<div style="padding:15px;color:var(--text-secondary);">No results found.</div>';
|
||||
return;
|
||||
}
|
||||
@@ -1002,8 +1017,13 @@
|
||||
<div style="font-size:0.75em; color:var(--text-secondary); margin-top:8px; font-family:monospace; background: var(--canvas-bg); padding: 2px 4px; border-radius: 3px; display: inline-block;">ID: ${r.id}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error("Search error:", err);
|
||||
}
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// --- WebSocket Activity Feed ---
|
||||
const MAX_ACTIVITY_HISTORY = 100;
|
||||
@@ -1238,6 +1258,45 @@
|
||||
}
|
||||
}
|
||||
|
||||
function setupSSE() {
|
||||
try {
|
||||
const sse = new EventSource('/api/activity/stream');
|
||||
sse.onmessage = function(event) {
|
||||
if (event.data) {
|
||||
try {
|
||||
const rec = JSON.parse(event.data);
|
||||
const feed = document.getElementById('activity-feed');
|
||||
if (feed) {
|
||||
const item = document.createElement('div');
|
||||
item.style.padding = '8px 12px';
|
||||
item.style.borderBottom = '1px solid var(--border-color)';
|
||||
item.style.fontSize = '0.85em';
|
||||
item.style.background = 'rgba(39, 174, 96, 0.08)';
|
||||
item.innerHTML = `<strong>${rec.category || 'activity'}</strong>: ${rec.summary || event.data}`;
|
||||
feed.insertBefore(item, feed.firstChild);
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
};
|
||||
} catch(e) { console.error('SSE initialization error', e); }
|
||||
}
|
||||
|
||||
// Ctrl+K / Cmd+K Global Search shortcut
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
const searchTabBtn = document.querySelectorAll('.tab-button')[1];
|
||||
if (searchTabBtn) {
|
||||
switchTab('search-tab', searchTabBtn);
|
||||
}
|
||||
const searchInput = document.getElementById('search-box');
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
searchInput.select();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Start ---
|
||||
loadVersion();
|
||||
loadGraph();
|
||||
@@ -1246,6 +1305,7 @@
|
||||
loadActivityHistory();
|
||||
loadAllExtras();
|
||||
setupWS();
|
||||
setupSSE();
|
||||
|
||||
// Listen to theme toggle changes to update graph font colors
|
||||
const observer = new MutationObserver(() => updateGraphData());
|
||||
|
||||
+4
-1
@@ -8,7 +8,10 @@ pub fn init_redb(base: &Path) -> Arc<Database> {
|
||||
let db = match redb::Database::create(&redb_path) {
|
||||
Ok(db) => Arc::new(db),
|
||||
Err(e) => {
|
||||
let err_msg = format!("CRITICAL FATAL ERROR: Failed to open or create redb database at {:?}. Error: {}. This almost always means another instance of mcp-memory-server is already running and holds the exclusive file lock on the database.", redb_path, e);
|
||||
let err_msg = format!(
|
||||
"CRITICAL FATAL ERROR: Failed to open or create redb database at {:?}. Error: {}. This almost always means another instance of mcp-memory-server is already running and holds the exclusive file lock on the database.",
|
||||
redb_path, e
|
||||
);
|
||||
eprintln!("{}", err_msg);
|
||||
tracing::error!("{}", err_msg);
|
||||
std::process::exit(1);
|
||||
|
||||
+33
-4
@@ -1,7 +1,7 @@
|
||||
#[allow(deprecated)]
|
||||
use fastembed::{TextEmbedding, EmbeddingModel};
|
||||
use std::sync::OnceLock;
|
||||
use fastembed::{EmbeddingModel, TextEmbedding};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static EMBEDDING_MODEL: OnceLock<Mutex<TextEmbedding>> = OnceLock::new();
|
||||
|
||||
@@ -24,7 +24,9 @@ pub async fn generate_embedding_async(text: String) -> Result<Vec<f32>, String>
|
||||
let mut model = model_mutex.lock().map_err(|e| e.to_string())?;
|
||||
let embeddings = model.embed(vec![text], None).map_err(|e| e.to_string())?;
|
||||
Ok(embeddings.into_iter().next().unwrap_or_default())
|
||||
}).await.map_err(|e| e.to_string())?
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
@@ -49,5 +51,32 @@ pub async fn generate_embeddings_async(texts: Vec<String>) -> Result<Vec<Vec<f32
|
||||
let mut model = model_mutex.lock().map_err(|e| e.to_string())?;
|
||||
let embeddings = model.embed(texts, None).map_err(|e| e.to_string())?;
|
||||
Ok(embeddings)
|
||||
}).await.map_err(|e| e.to_string())?
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_cosine_similarity_edge_cases() {
|
||||
assert_eq!(cosine_similarity(&[], &[]), 0.0);
|
||||
assert_eq!(cosine_similarity(&[1.0, 2.0], &[1.0]), 0.0);
|
||||
assert_eq!(cosine_similarity(&[0.0, 0.0], &[0.0, 0.0]), 0.0);
|
||||
|
||||
let v1 = vec![1.0, 0.0, 0.0];
|
||||
let v2 = vec![1.0, 0.0, 0.0];
|
||||
assert!((cosine_similarity(&v1, &v2) - 1.0).abs() < 1e-5);
|
||||
|
||||
let v3 = vec![0.0, 1.0, 0.0];
|
||||
assert!((cosine_similarity(&v1, &v3) - 0.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_generate_embeddings_async_empty() {
|
||||
let res = generate_embeddings_async(vec![]).await.unwrap();
|
||||
assert!(res.is_empty());
|
||||
}
|
||||
}
|
||||
+16
-2
@@ -42,8 +42,14 @@ impl IntoResponse for AppError {
|
||||
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()),
|
||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
||||
AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()),
|
||||
AppError::JsonError(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("JSON Error: {}", e)),
|
||||
AppError::IoError(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("I/O Error: {}", e)),
|
||||
AppError::JsonError(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("JSON Error: {}", e),
|
||||
),
|
||||
AppError::IoError(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("I/O Error: {}", e),
|
||||
),
|
||||
};
|
||||
|
||||
let body = Json(json!({
|
||||
@@ -115,3 +121,11 @@ impl From<&str> for AppError {
|
||||
AppError::Internal(msg.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// Generated by improve-test (Attempt 1)
|
||||
#[test]
|
||||
fn test_app_error_unknown_error() {
|
||||
let err = AppError::Internal("unknown error".into());
|
||||
let res = err.into_response();
|
||||
assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
+67
-17
@@ -4,7 +4,7 @@ use crate::tools::ReadFileSkeletonTool;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tree_sitter::{Parser, Node};
|
||||
use tree_sitter::{Node, Parser};
|
||||
|
||||
pub struct ReadFileSkeletonHandler;
|
||||
|
||||
@@ -22,12 +22,14 @@ impl McpTool for ReadFileSkeletonHandler {
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let tool_args: ReadFileSkeletonTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let tool_args: ReadFileSkeletonTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
|
||||
let file_path = tool_args.file_path.clone();
|
||||
let result = tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
|
||||
let code = std::fs::read_to_string(&file_path)
|
||||
.map_err(|e| crate::error::AppError::Internal(format!("Failed to read file: {}", e)))?;
|
||||
let code = std::fs::read_to_string(&file_path).map_err(|e| {
|
||||
crate::error::AppError::Internal(format!("Failed to read file: {}", e))
|
||||
})?;
|
||||
|
||||
let mut parser = Parser::new();
|
||||
|
||||
@@ -47,15 +49,36 @@ impl McpTool for ReadFileSkeletonHandler {
|
||||
_ => return Ok(code),
|
||||
};
|
||||
|
||||
parser.set_language(&language.into()).map_err(|e| e.to_string())?;
|
||||
parser
|
||||
.set_language(&language.into())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let tree = parser.parse(&code, None).ok_or_else(|| crate::error::AppError::Internal("Failed to parse code".to_string()))?;
|
||||
let tree = parser.parse(&code, None).ok_or_else(|| {
|
||||
crate::error::AppError::Internal("Failed to parse code".to_string())
|
||||
})?;
|
||||
let mut result_skeleton = String::new();
|
||||
|
||||
fn extract_skeleton(node: Node, code: &str, out: &mut String, depth: usize) {
|
||||
let kind = node.kind();
|
||||
|
||||
let is_structural = matches!(kind, "use_declaration" | "import_statement" | "import_from_statement" | "struct_item" | "enum_item" | "trait_item" | "impl_item" | "function_item" | "function_declaration" | "function_definition" | "method_definition" | "interface_declaration" | "type_alias_declaration" | "class_declaration" | "class_definition");
|
||||
let is_structural = matches!(
|
||||
kind,
|
||||
"use_declaration"
|
||||
| "import_statement"
|
||||
| "import_from_statement"
|
||||
| "struct_item"
|
||||
| "enum_item"
|
||||
| "trait_item"
|
||||
| "impl_item"
|
||||
| "function_item"
|
||||
| "function_declaration"
|
||||
| "function_definition"
|
||||
| "method_definition"
|
||||
| "interface_declaration"
|
||||
| "type_alias_declaration"
|
||||
| "class_declaration"
|
||||
| "class_definition"
|
||||
);
|
||||
|
||||
if is_structural {
|
||||
let indent = " ".repeat(depth);
|
||||
@@ -120,12 +143,14 @@ impl McpTool for ReplaceAstNodeHandler {
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let tool_args: ReplaceAstNodeTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let tool_args: ReplaceAstNodeTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
|
||||
let file_path = tool_args.file_path.clone();
|
||||
let result = tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
|
||||
let code = std::fs::read_to_string(&file_path)
|
||||
.map_err(|e| crate::error::AppError::Internal(format!("Failed to read file: {}", e)))?;
|
||||
let code = std::fs::read_to_string(&file_path).map_err(|e| {
|
||||
crate::error::AppError::Internal(format!("Failed to read file: {}", e))
|
||||
})?;
|
||||
|
||||
let mut parser = Parser::new();
|
||||
|
||||
@@ -142,14 +167,28 @@ impl McpTool for ReplaceAstNodeHandler {
|
||||
"c" | "h" => tree_sitter_c::LANGUAGE,
|
||||
"cpp" | "cc" | "cxx" | "hpp" | "hxx" => tree_sitter_cpp::LANGUAGE,
|
||||
"go" => tree_sitter_go::LANGUAGE,
|
||||
_ => return Err(crate::error::AppError::Internal(format!("Unsupported language for AST replacement: {}", ext))),
|
||||
_ => {
|
||||
return Err(crate::error::AppError::Internal(format!(
|
||||
"Unsupported language for AST replacement: {}",
|
||||
ext
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
parser.set_language(&language.into()).map_err(|e| e.to_string())?;
|
||||
let tree = parser.parse(&code, None).ok_or_else(|| crate::error::AppError::Internal("Failed to parse code".to_string()))?;
|
||||
parser
|
||||
.set_language(&language.into())
|
||||
.map_err(|e| e.to_string())?;
|
||||
let tree = parser.parse(&code, None).ok_or_else(|| {
|
||||
crate::error::AppError::Internal("Failed to parse code".to_string())
|
||||
})?;
|
||||
|
||||
// Search for the node
|
||||
fn find_node<'a>(node: Node<'a>, code: &str, target_type: &str, target_name: &str) -> Option<Node<'a>> {
|
||||
fn find_node<'a>(
|
||||
node: Node<'a>,
|
||||
code: &str,
|
||||
target_type: &str,
|
||||
target_name: &str,
|
||||
) -> Option<Node<'a>> {
|
||||
if node.kind() == target_type {
|
||||
// Try to find the name/identifier
|
||||
let mut cursor = node.walk();
|
||||
@@ -173,7 +212,12 @@ impl McpTool for ReplaceAstNodeHandler {
|
||||
None
|
||||
}
|
||||
|
||||
let target_node = find_node(tree.root_node(), &code, &tool_args.node_type, &tool_args.node_name);
|
||||
let target_node = find_node(
|
||||
tree.root_node(),
|
||||
&code,
|
||||
&tool_args.node_type,
|
||||
&tool_args.node_name,
|
||||
);
|
||||
|
||||
if let Some(node) = target_node {
|
||||
let start_byte = node.start_byte();
|
||||
@@ -185,9 +229,15 @@ impl McpTool for ReplaceAstNodeHandler {
|
||||
new_file_content.push_str(&code[end_byte..]);
|
||||
|
||||
std::fs::write(&file_path, new_file_content).map_err(|e| e.to_string())?;
|
||||
Ok(format!("Successfully replaced node {} of type {} in {}", tool_args.node_name, tool_args.node_type, file_path))
|
||||
Ok(format!(
|
||||
"Successfully replaced node {} of type {} in {}",
|
||||
tool_args.node_name, tool_args.node_type, file_path
|
||||
))
|
||||
} else {
|
||||
Err(crate::error::AppError::Internal(format!("Could not find node {} of type {}", tool_args.node_name, tool_args.node_type)))
|
||||
Err(crate::error::AppError::Internal(format!(
|
||||
"Could not find node {} of type {}",
|
||||
tool_args.node_name, tool_args.node_type
|
||||
)))
|
||||
}
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -58,7 +58,8 @@ impl McpTool for ReadEnvFingerprintHandler {
|
||||
let req: ReadEnvFingerprintTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let data = state
|
||||
.env.env_fingerprints
|
||||
.env
|
||||
.env_fingerprints
|
||||
.read_with(|fps| fps.get(&req.namespace).cloned());
|
||||
if let Some(fp) = data {
|
||||
let data = Ok::<String, crate::error::AppError>(serde_json::to_string(&fp)?)?;
|
||||
@@ -180,13 +181,19 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let res = update_handler.execute(args, state.clone()).await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
let res = update_handler
|
||||
.execute(args, state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert_eq!(res, "Env fingerprint updated");
|
||||
|
||||
let read_handler = ReadEnvFingerprintHandler;
|
||||
let res2 = read_handler
|
||||
.execute(json!({"namespace": "global"}), state.clone())
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(res2.contains("rustc"));
|
||||
assert!(res2.contains("1.70.0"));
|
||||
}
|
||||
@@ -211,7 +218,9 @@ mod tests {
|
||||
let handler = GetEnvironmentDetailsHandler;
|
||||
let res = handler
|
||||
.execute(json!({"namespace": "global"}), state.clone())
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
|
||||
assert!(res.contains("global"));
|
||||
}
|
||||
@@ -230,7 +239,11 @@ mod tests {
|
||||
"context": "For database access",
|
||||
"namespace": "global"
|
||||
});
|
||||
let res1 = req_handler.execute(args_req, state.clone()).await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
let res1 = req_handler
|
||||
.execute(args_req, state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert_eq!(res1, "Env requirement logged");
|
||||
|
||||
let reg_handler = RegisterEnvironmentHandler;
|
||||
@@ -241,13 +254,19 @@ mod tests {
|
||||
"requires_vpn": true,
|
||||
"namespace": "global"
|
||||
});
|
||||
let res2 = reg_handler.execute(args_reg, state.clone()).await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
let res2 = reg_handler
|
||||
.execute(args_reg, state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert_eq!(res2, "Environment registered");
|
||||
|
||||
let get_handler = GetEnvironmentDetailsHandler;
|
||||
let res3 = get_handler
|
||||
.execute(json!({"namespace": "global"}), state.clone())
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(res3.contains("prod.local"));
|
||||
assert!(!res3.is_empty());
|
||||
}
|
||||
|
||||
+21
-10
@@ -2,9 +2,9 @@ use crate::router::McpTool;
|
||||
use crate::state::MemoryState;
|
||||
use crate::tools::GetActiveWorktreeContextTool;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct GetActiveWorktreeContextHandler;
|
||||
|
||||
@@ -21,14 +21,22 @@ impl McpTool for GetActiveWorktreeContextHandler {
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, _args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let result = tokio::task::spawn_blocking(move || -> crate::error::Result<serde_json::Value> {
|
||||
async fn execute(
|
||||
&self,
|
||||
_args: Value,
|
||||
_state: Arc<MemoryState>,
|
||||
) -> crate::error::Result<String> {
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || -> crate::error::Result<serde_json::Value> {
|
||||
let cwd = env::current_dir().map_err(|e| e.to_string())?;
|
||||
let repo = git2::Repository::discover(&cwd).map_err(|e| crate::error::AppError::Internal(format!("Not in a git repository: {}", e)))?;
|
||||
let repo = git2::Repository::discover(&cwd).map_err(|e| {
|
||||
crate::error::AppError::Internal(format!("Not in a git repository: {}", e))
|
||||
})?;
|
||||
|
||||
let mut branch_name = String::new();
|
||||
if let Ok(head) = repo.head()
|
||||
&& let Some(name) = head.shorthand() {
|
||||
&& let Some(name) = head.shorthand()
|
||||
{
|
||||
branch_name = name.to_string();
|
||||
}
|
||||
|
||||
@@ -37,7 +45,9 @@ impl McpTool for GetActiveWorktreeContextHandler {
|
||||
|
||||
// Try to diff against HEAD
|
||||
if let Ok(tree) = repo.head().and_then(|h| h.peel_to_tree()) {
|
||||
diff = repo.diff_tree_to_workdir_with_index(Some(&tree), Some(&mut opts)).ok();
|
||||
diff = repo
|
||||
.diff_tree_to_workdir_with_index(Some(&tree), Some(&mut opts))
|
||||
.ok();
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
@@ -79,9 +89,9 @@ impl McpTool for GetActiveWorktreeContextHandler {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
use std::sync::Arc;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_active_worktree_context() {
|
||||
@@ -89,7 +99,8 @@ mod tests {
|
||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||
let handler = GetActiveWorktreeContextHandler;
|
||||
|
||||
let result = handler.execute(json!({}), state)
|
||||
let result = handler
|
||||
.execute(json!({}), state)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get worktree context: {}", e))
|
||||
.unwrap();
|
||||
|
||||
@@ -186,7 +186,8 @@ impl McpTool for CreateRelationsHandler {
|
||||
let missing: Vec<_> = missing_nodes.into_iter().collect();
|
||||
return Err(crate::error::AppError::Internal(format!(
|
||||
"Error: Relations dropped due to missing entities: {}",
|
||||
missing.join(", "))));
|
||||
missing.join(", ")
|
||||
)));
|
||||
}
|
||||
Ok("Relations created".to_string())
|
||||
}
|
||||
@@ -219,7 +220,8 @@ impl McpTool for AddObservationsHandler {
|
||||
if !missing_entities.is_empty() {
|
||||
return Err(crate::error::AppError::Internal(format!(
|
||||
"Error: Observations dropped for missing entities: {}",
|
||||
missing_entities.join(", "))));
|
||||
missing_entities.join(", ")
|
||||
)));
|
||||
}
|
||||
Ok("Observations added".to_string())
|
||||
}
|
||||
@@ -255,7 +257,8 @@ impl McpTool for DeleteEntitiesHandler {
|
||||
if !missing.is_empty() {
|
||||
return Err(crate::error::AppError::Internal(format!(
|
||||
"Error: Entities not found: {}. Please use the search_nodes or read_graph tools to verify the exact entity names.",
|
||||
missing.join(", "))));
|
||||
missing.join(", ")
|
||||
)));
|
||||
}
|
||||
|
||||
let idx = state.get_search_index();
|
||||
@@ -298,7 +301,8 @@ impl McpTool for DeleteObservationsHandler {
|
||||
if !missing.is_empty() {
|
||||
return Err(crate::error::AppError::Internal(format!(
|
||||
"Error: Entities not found: {}. Please use the search_nodes or read_graph tools to verify the exact entity names.",
|
||||
missing.join(", "))));
|
||||
missing.join(", ")
|
||||
)));
|
||||
}
|
||||
Ok("Observations deleted".to_string())
|
||||
}
|
||||
@@ -671,7 +675,11 @@ mod tests {
|
||||
]
|
||||
});
|
||||
|
||||
let res = create_handler.execute(args, state.clone()).await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
let res = create_handler
|
||||
.execute(args, state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert_eq!(res, "Entities created");
|
||||
|
||||
// Ensure graph contains the entity
|
||||
@@ -716,7 +724,11 @@ mod tests {
|
||||
{"from": "A", "to": "B", "relation_type": "knows"}
|
||||
]
|
||||
});
|
||||
let res = handler.execute(args, state.clone()).await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
let res = handler
|
||||
.execute(args, state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert_eq!(res, "Relations created");
|
||||
|
||||
// Test semantic LLM schema feedback (User request)
|
||||
@@ -725,7 +737,11 @@ mod tests {
|
||||
{"source": "A", "target": "B", "relationType": "knows"}
|
||||
]
|
||||
});
|
||||
let err_res = handler.execute(bad_args, state.clone()).await.unwrap_err().to_string();
|
||||
let err_res = handler
|
||||
.execute(bad_args, state.clone())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err_res.contains("Schema error:"));
|
||||
assert!(err_res.contains("strictly uses 'from', 'to', and 'relation_type'"));
|
||||
}
|
||||
@@ -755,25 +771,35 @@ mod tests {
|
||||
{"entity_name": "A", "contents": ["Obs 1", "Obs 2"]}
|
||||
]
|
||||
});
|
||||
let res1 = add_obs.execute(args_obs, state.clone()).await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
let res1 = add_obs
|
||||
.execute(args_obs, state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert_eq!(res1, "Observations added");
|
||||
|
||||
let read_graph = ReadGraphHandler;
|
||||
let res2 = read_graph
|
||||
.execute(json!({"namespace": "global"}), state.clone())
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(res2.contains("Obs 1"));
|
||||
assert!(res2.contains("Obs 2"));
|
||||
|
||||
let del_entity = DeleteEntitiesHandler;
|
||||
let res4 = del_entity
|
||||
.execute(json!({"entity_names": ["A"]}), state.clone())
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert_eq!(res4, "Entities deleted");
|
||||
|
||||
let res5 = read_graph
|
||||
.execute(json!({"namespace": "global"}), state.clone())
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(!res5.contains("A"));
|
||||
}
|
||||
|
||||
@@ -791,7 +817,9 @@ mod tests {
|
||||
});
|
||||
create_handler
|
||||
.execute(args_ent, state.clone())
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
|
||||
let rel_handler = CreateRelationsHandler;
|
||||
let args_rel = json!({
|
||||
@@ -799,25 +827,35 @@ mod tests {
|
||||
{"from": "X", "to": "Y", "relation_type": "depends_on", "namespace": "global"}
|
||||
]
|
||||
});
|
||||
rel_handler.execute(args_rel, state.clone()).await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
rel_handler
|
||||
.execute(args_rel, state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
|
||||
let read_handler = ReadGraphHandler;
|
||||
let res_read = read_handler
|
||||
.execute(json!({"namespace": "global"}), state.clone())
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(res_read.contains("X"));
|
||||
assert!(res_read.contains("depends_on"));
|
||||
|
||||
let open_handler = OpenNodesHandler;
|
||||
let res_open = open_handler
|
||||
.execute(json!({"names": ["X"]}), state.clone())
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(res_open.contains("Y"));
|
||||
|
||||
let viz_handler = VisualizeGraphHandler;
|
||||
let res_viz = viz_handler
|
||||
.execute(json!({"query": "X"}), state.clone())
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(!res_viz.is_empty());
|
||||
|
||||
let condense = CondenseEntityHandler;
|
||||
@@ -826,7 +864,9 @@ mod tests {
|
||||
json!({"entity_name": "X", "summarized_observations": ["X condensed"]}),
|
||||
state.clone(),
|
||||
)
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert_eq!(res_cond, "Entity condensed");
|
||||
|
||||
let merge = MergeEntitiesHandler;
|
||||
@@ -835,11 +875,17 @@ mod tests {
|
||||
json!({"source_entity": "X", "target_entity": "Y"}),
|
||||
state.clone(),
|
||||
)
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert_eq!(res_merge, "Entities merged");
|
||||
|
||||
let orphans = FindOrphansHandler;
|
||||
let res_orphans = orphans.execute(json!({}), state.clone()).await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
let res_orphans = orphans
|
||||
.execute(json!({}), state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(!res_orphans.contains("Y"));
|
||||
}
|
||||
}
|
||||
+24
-13
@@ -1,11 +1,11 @@
|
||||
use crate::router::McpTool;
|
||||
use crate::state::MemoryState;
|
||||
use crate::tools::{WatchProcessLogsTool, GetRecentLogsTool};
|
||||
use crate::tools::{GetRecentLogsTool, WatchProcessLogsTool};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct WatchProcessLogsHandler;
|
||||
|
||||
@@ -23,9 +23,13 @@ impl McpTool for WatchProcessLogsHandler {
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let tool_args: WatchProcessLogsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let tool_args: WatchProcessLogsTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
if !std::path::Path::new(&tool_args.file_path).exists() {
|
||||
return Err(crate::error::AppError::Internal(format!("File does not exist: {}", tool_args.file_path)));
|
||||
return Err(crate::error::AppError::Internal(format!(
|
||||
"File does not exist: {}",
|
||||
tool_args.file_path
|
||||
)));
|
||||
}
|
||||
Ok(format!("Started watching logs for {}", tool_args.file_path))
|
||||
}
|
||||
@@ -47,17 +51,22 @@ impl McpTool for GetRecentLogsHandler {
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let tool_args: GetRecentLogsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let tool_args: GetRecentLogsTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
|
||||
let mut file = File::open(&tool_args.file_path).map_err(|e| crate::error::AppError::Internal(format!("Failed to open file: {}", e)))?;
|
||||
let mut file = File::open(&tool_args.file_path).map_err(|e| {
|
||||
crate::error::AppError::Internal(format!("Failed to open file: {}", e))
|
||||
})?;
|
||||
let len = file.metadata().map_err(|e| e.to_string())?.len();
|
||||
|
||||
let read_size = std::cmp::min(16 * 1024, len);
|
||||
file.seek(SeekFrom::End(-(read_size as i64))).map_err(|e| e.to_string())?;
|
||||
file.seek(SeekFrom::End(-(read_size as i64)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut buffer = String::new();
|
||||
file.read_to_string(&mut buffer).map_err(|e| e.to_string())?;
|
||||
file.read_to_string(&mut buffer)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let lines: Vec<&str> = buffer.lines().collect();
|
||||
let recent_lines = if lines.len() > 100 {
|
||||
@@ -78,9 +87,9 @@ impl McpTool for GetRecentLogsHandler {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
use std::sync::Arc;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_watch_process_logs() {
|
||||
@@ -95,7 +104,8 @@ mod tests {
|
||||
"file_path": log_file.to_str().unwrap()
|
||||
});
|
||||
|
||||
let result = handler.execute(args, state)
|
||||
let result = handler
|
||||
.execute(args, state)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to watch logs: {}", e))
|
||||
.unwrap();
|
||||
@@ -115,7 +125,8 @@ mod tests {
|
||||
"file_path": log_file.to_str().unwrap()
|
||||
});
|
||||
|
||||
let result = handler.execute(args, state)
|
||||
let result = handler
|
||||
.execute(args, state)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get recent logs: {}", e))
|
||||
.unwrap();
|
||||
|
||||
+168
-48
@@ -39,9 +39,9 @@ impl McpTool for LogDecisionHandler {
|
||||
final_id = format!("ADR-{:04}", adrs.len() + 1);
|
||||
let a = Adr {
|
||||
id: final_id.clone(),
|
||||
title: req.title,
|
||||
title: req.title.clone(),
|
||||
context: req.context,
|
||||
decision: req.decision,
|
||||
decision: req.decision.clone(),
|
||||
consequence: req.consequence,
|
||||
status: "accepted".to_string(),
|
||||
supersedes: req.supersedes,
|
||||
@@ -52,7 +52,8 @@ impl McpTool for LogDecisionHandler {
|
||||
adrs.push(a);
|
||||
});
|
||||
|
||||
Ok(format!("Decision logged as {}", final_id))
|
||||
state.record_activity("decision", &format!("Logged {}: {}", final_id, req.title), Some(&req.decision));
|
||||
Ok(format!("Logged decision {}: {}", final_id, req.title))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +120,9 @@ impl McpTool for DeleteDecisionHandler {
|
||||
state.rebuild_index().await;
|
||||
Ok("Decision deleted successfully".to_string())
|
||||
} else {
|
||||
Err(crate::error::AppError::Internal("Decision not found".to_string()))
|
||||
Err(crate::error::AppError::Internal(
|
||||
"Decision not found".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,18 +142,40 @@ impl McpTool for LogErrorFixHandler {
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: LogErrorFixTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let text_to_embed = format!("Signature: {}\nSolution: {}", req.signature, req.solution);
|
||||
let embedding = crate::embedding::generate_embedding_async(text_to_embed).await.ok();
|
||||
let mut solution = req.solution;
|
||||
|
||||
if state.ollama.is_available().await {
|
||||
let prompt = format!(
|
||||
"Analyze this error signature and solution. Output 1 sentence summarizing the root cause and fix:\nSignature: {}\nSolution: {}",
|
||||
req.signature, solution
|
||||
);
|
||||
if let Ok(summary) = state
|
||||
.ollama
|
||||
.generate(&prompt, Some(&state.ollama.reasoning_model), None)
|
||||
.await
|
||||
{
|
||||
let clean = summary.trim();
|
||||
if !clean.is_empty() {
|
||||
solution = format!("{} (AI Analysis: {})", solution, clean);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let embedding = crate::embedding::generate_embedding_async(text_to_embed)
|
||||
.await
|
||||
.ok();
|
||||
state.code.error_fixes.modify(|fixes| {
|
||||
fixes.push(crate::models::ErrorFix {
|
||||
signature: req.signature,
|
||||
solution: req.solution,
|
||||
signature: req.signature.clone(),
|
||||
solution: solution.clone(),
|
||||
timestamp: crate::handlers::utils::now_secs(),
|
||||
git_commit: req.git_commit,
|
||||
git_branch: req.git_branch,
|
||||
embedding,
|
||||
})
|
||||
});
|
||||
Ok("Error fix logged".to_string())
|
||||
state.record_activity("error_fix", &format!("Fixed error: {}", req.signature), Some(&solution));
|
||||
Ok(format!("Logged error fix for {}: {}", req.signature, solution))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,16 +225,32 @@ impl McpTool for LogCodeChangeHandler {
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: LogCodeChangeTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let mut description = req.description;
|
||||
|
||||
if state.ollama.is_available().await {
|
||||
let prompt = format!(
|
||||
"Summarize in 1 concise sentence the architectural impact of changing file '{}': {}",
|
||||
req.file_path, description
|
||||
);
|
||||
if let Ok(summary) = state.ollama.generate(&prompt, None, None).await {
|
||||
let clean = summary.trim();
|
||||
if !clean.is_empty() {
|
||||
description = format!("{} (AI Summary: {})", description, clean);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.code.ledger.modify(|ledger| {
|
||||
ledger.push(CodeChange {
|
||||
timestamp: crate::handlers::utils::now_secs(),
|
||||
file_path: req.file_path,
|
||||
description: req.description,
|
||||
file_path: req.file_path.clone(),
|
||||
description: description.clone(),
|
||||
git_commit: req.git_commit,
|
||||
git_branch: req.git_branch,
|
||||
});
|
||||
});
|
||||
Ok("Code change logged".to_string())
|
||||
state.record_activity("code_change", &format!("Modified {}", req.file_path), Some(&description));
|
||||
Ok(format!("Logged code change for {}: {}", req.file_path, description))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +271,8 @@ impl McpTool for QueryRecentChangesHandler {
|
||||
|
||||
async fn execute(&self, _args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let data = state
|
||||
.code.ledger
|
||||
.code
|
||||
.ledger
|
||||
.read_with(|l| Ok::<String, crate::error::AppError>(serde_json::to_string(l)?))?;
|
||||
Ok(data)
|
||||
}
|
||||
@@ -278,7 +320,8 @@ impl McpTool for ReadPreferencesHandler {
|
||||
|
||||
async fn execute(&self, _args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
state
|
||||
.env.prefs
|
||||
.env
|
||||
.prefs
|
||||
.read_with(|prefs| Ok::<String, crate::error::AppError>(serde_json::to_string(prefs)?))
|
||||
}
|
||||
}
|
||||
@@ -297,8 +340,13 @@ impl McpTool for LogTechDebtHandler {
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: LogTechDebtTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let text_to_embed = format!("Description: {}\nIdeal Solution: {}", req.description, req.ideal_solution);
|
||||
let embedding = crate::embedding::generate_embedding_async(text_to_embed).await.ok();
|
||||
let text_to_embed = format!(
|
||||
"Description: {}\nIdeal Solution: {}",
|
||||
req.description, req.ideal_solution
|
||||
);
|
||||
let embedding = crate::embedding::generate_embedding_async(text_to_embed)
|
||||
.await
|
||||
.ok();
|
||||
state.code.tech_debts.modify(|debts| {
|
||||
debts.push(crate::models::TechDebt {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
@@ -346,7 +394,10 @@ impl McpTool for ResolveTechDebtHandler {
|
||||
if found {
|
||||
Ok("Tech debt resolved".to_string())
|
||||
} else {
|
||||
Err(crate::error::AppError::Internal("Tech debt not found. Please verify the tech debt ID using list_tech_debt.".to_string()))
|
||||
Err(crate::error::AppError::Internal(
|
||||
"Tech debt not found. Please verify the tech debt ID using list_tech_debt."
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -394,10 +445,15 @@ impl McpTool for OmniSearchHandler {
|
||||
let req: OmniSearchTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let limit = req.limit.unwrap_or(5);
|
||||
let include_body = req.include_body.unwrap_or(false);
|
||||
let matches = state.search().keyword_search(&req.query, req.namespace.as_deref(), limit).unwrap_or_default();
|
||||
let matches = state
|
||||
.search()
|
||||
.keyword_search(&req.query, req.namespace.as_deref(), limit)
|
||||
.unwrap_or_default();
|
||||
// tracing::info!("OMNI SEARCH MATCHES: {:?}", matches);
|
||||
let q = req.query.clone();
|
||||
let query_emb = crate::embedding::generate_embedding_async(q.clone()).await.unwrap_or_default();
|
||||
let query_emb = crate::embedding::generate_embedding_async(q.clone())
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let kg_json = state.read_graph(|full| {
|
||||
let mut kg_entities = std::collections::HashMap::new();
|
||||
@@ -460,19 +516,29 @@ impl McpTool for OmniSearchHandler {
|
||||
})?;
|
||||
|
||||
let snippets_json = state.code.snippets.read_with(|all_snippets| {
|
||||
let mut scored: Vec<_> = all_snippets.iter().map(|s| {
|
||||
let mut scored: Vec<_> = all_snippets
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let mut score = 0.0;
|
||||
if matched_snippets.contains(s.name.as_str()) || contains_ignore_ascii_case(&s.name, &q) || contains_ignore_ascii_case(&s.description, &q) {
|
||||
if matched_snippets.contains(s.name.as_str())
|
||||
|| contains_ignore_ascii_case(&s.name, &q)
|
||||
|| contains_ignore_ascii_case(&s.description, &q)
|
||||
{
|
||||
score += 1.0;
|
||||
}
|
||||
if let Some(emb) = &s.embedding {
|
||||
score += crate::embedding::cosine_similarity(&query_emb, emb);
|
||||
}
|
||||
(s, score)
|
||||
}).filter(|(_, score)| *score > 0.4).collect();
|
||||
})
|
||||
.filter(|(_, score)| *score > 0.4)
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
let filtered: Vec<_> = scored.into_iter().take(limit).map(|(s, _)| {
|
||||
let filtered: Vec<_> = scored
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|(s, _)| {
|
||||
if !include_body {
|
||||
let mut summary = s.clone();
|
||||
summary.code = "".to_string();
|
||||
@@ -480,7 +546,8 @@ impl McpTool for OmniSearchHandler {
|
||||
} else {
|
||||
s.clone()
|
||||
}
|
||||
}).collect();
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_value(&filtered).map_err(|e| e.to_string())
|
||||
})?;
|
||||
|
||||
@@ -506,22 +573,31 @@ impl McpTool for OmniSearchHandler {
|
||||
|
||||
let q = req.query;
|
||||
let tech_debts_json = state.code.tech_debts.read_with(|debts| {
|
||||
let mut scored: Vec<_> = debts.iter().map(|d| {
|
||||
let mut scored: Vec<_> = debts
|
||||
.iter()
|
||||
.map(|d| {
|
||||
if req.namespace.as_ref().is_some_and(|ns| d.namespace != *ns) {
|
||||
return (d, 0.0);
|
||||
}
|
||||
let mut score = 0.0;
|
||||
if contains_ignore_ascii_case(&d.description, &q) || contains_ignore_ascii_case(&d.ideal_solution, &q) {
|
||||
if contains_ignore_ascii_case(&d.description, &q)
|
||||
|| contains_ignore_ascii_case(&d.ideal_solution, &q)
|
||||
{
|
||||
score += 1.0;
|
||||
}
|
||||
if let Some(emb) = &d.embedding {
|
||||
score += crate::embedding::cosine_similarity(&query_emb, emb);
|
||||
}
|
||||
(d, score)
|
||||
}).filter(|(_, score)| *score > 0.4).collect();
|
||||
})
|
||||
.filter(|(_, score)| *score > 0.4)
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
let filtered: Vec<_> = scored.into_iter().take(limit).map(|(d, _)| {
|
||||
let filtered: Vec<_> = scored
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|(d, _)| {
|
||||
if !include_body {
|
||||
let mut summary = d.clone();
|
||||
summary.description = "".to_string();
|
||||
@@ -530,7 +606,8 @@ impl McpTool for OmniSearchHandler {
|
||||
} else {
|
||||
d.clone()
|
||||
}
|
||||
}).collect();
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_value(&filtered).map_err(|e| e.to_string())
|
||||
})?;
|
||||
|
||||
@@ -556,19 +633,29 @@ impl McpTool for OmniSearchHandler {
|
||||
})?;
|
||||
|
||||
let error_fixes_json = state.code.error_fixes.read_with(|fixes| {
|
||||
let mut scored: Vec<_> = fixes.iter().map(|f| {
|
||||
let mut scored: Vec<_> = fixes
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let mut score = 0.0;
|
||||
if contains_ignore_ascii_case(&f.signature, &q) || contains_ignore_ascii_case(&f.solution, &q) {
|
||||
if contains_ignore_ascii_case(&f.signature, &q)
|
||||
|| contains_ignore_ascii_case(&f.solution, &q)
|
||||
{
|
||||
score += 1.0;
|
||||
}
|
||||
if let Some(emb) = &f.embedding {
|
||||
score += crate::embedding::cosine_similarity(&query_emb, emb);
|
||||
}
|
||||
(f, score)
|
||||
}).filter(|(_, score)| *score > 0.4).collect();
|
||||
})
|
||||
.filter(|(_, score)| *score > 0.4)
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
let filtered: Vec<_> = scored.into_iter().take(limit).map(|(f, _)| f.clone()).collect();
|
||||
let filtered: Vec<_> = scored
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|(f, _)| f.clone())
|
||||
.collect();
|
||||
serde_json::to_value(&filtered).map_err(|e| e.to_string())
|
||||
})?;
|
||||
|
||||
@@ -603,7 +690,8 @@ impl McpTool for GetProjectHealthHandler {
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: GetProjectHealthTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let active_tasks = state
|
||||
.project.tasks
|
||||
.project
|
||||
.tasks
|
||||
.read_with(|tasks| tasks.iter().filter(|t| t.status != "done").count());
|
||||
let unresolved_debt = state.code.tech_debts.read_with(|debts| {
|
||||
debts
|
||||
@@ -663,8 +751,12 @@ mod tests {
|
||||
"git_branch": "main"
|
||||
});
|
||||
|
||||
let res = handler.execute(args, state.clone()).await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
assert!(res.contains("Error fix logged"));
|
||||
let res = handler
|
||||
.execute(args, state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(res.contains("Logged error fix"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -675,7 +767,11 @@ mod tests {
|
||||
let handler = GetProjectHealthHandler;
|
||||
let args = json!({"namespace": "global"});
|
||||
|
||||
let res = handler.execute(args, state.clone()).await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
let res = handler
|
||||
.execute(args, state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(res.contains("unresolved_tech_debt"));
|
||||
}
|
||||
|
||||
@@ -693,8 +789,10 @@ mod tests {
|
||||
});
|
||||
let res1 = decision_handler
|
||||
.execute(args_dec, state.clone())
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
assert_eq!(res1, "Decision logged as ADR-0001");
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert_eq!(res1, "Logged decision ADR-0001: Architecture");
|
||||
|
||||
let debt_handler = LogTechDebtHandler;
|
||||
let args_debt = json!({
|
||||
@@ -709,7 +807,9 @@ mod tests {
|
||||
});
|
||||
let res2 = debt_handler
|
||||
.execute(args_debt, state.clone())
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert_eq!(res2, "Tech debt logged");
|
||||
|
||||
let list_debt = ListTechDebtHandler;
|
||||
@@ -718,7 +818,9 @@ mod tests {
|
||||
json!({"namespace": "global", "include_resolved": false}),
|
||||
state.clone(),
|
||||
)
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(res3.contains("Hardcoded path"));
|
||||
|
||||
let pref_handler = LearnPreferenceHandler;
|
||||
@@ -728,11 +830,17 @@ mod tests {
|
||||
});
|
||||
let res4 = pref_handler
|
||||
.execute(args_pref, state.clone())
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert_eq!(res4, "Preference learned");
|
||||
|
||||
let read_pref = ReadPreferencesHandler;
|
||||
let res5 = read_pref.execute(json!({}), state.clone()).await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
let res5 = read_pref
|
||||
.execute(json!({}), state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(res5.contains("use spaces"));
|
||||
}
|
||||
|
||||
@@ -750,12 +858,16 @@ mod tests {
|
||||
});
|
||||
code_handler
|
||||
.execute(args_code, state.clone())
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
|
||||
let query_changes = QueryRecentChangesHandler;
|
||||
let res_changes = query_changes
|
||||
.execute(json!({}), state.clone())
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(res_changes.contains("main.rs"));
|
||||
|
||||
let debt_handler = LogTechDebtHandler;
|
||||
@@ -771,7 +883,9 @@ mod tests {
|
||||
});
|
||||
debt_handler
|
||||
.execute(args_debt, state.clone())
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
|
||||
// resolve it
|
||||
let list_debt = ListTechDebtHandler;
|
||||
@@ -780,14 +894,18 @@ mod tests {
|
||||
json!({"namespace": "global", "include_resolved": false}),
|
||||
state.clone(),
|
||||
)
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
let uuid_start = debt_list.find("id\":\"").unwrap() + 5;
|
||||
let uuid = &debt_list[uuid_start..uuid_start + 36];
|
||||
|
||||
let resolve_debt = ResolveTechDebtHandler;
|
||||
resolve_debt
|
||||
.execute(json!({"id": uuid}), state.clone())
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -821,7 +939,9 @@ mod tests {
|
||||
let omni = OmniSearchHandler;
|
||||
let omni_res = omni
|
||||
.execute(json!({"query": "Omni"}), state.clone())
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
// tracing::info!("OMNI RES: {}", omni_res);
|
||||
assert!(
|
||||
omni_res.contains("omni-1"),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
pub mod ast;
|
||||
pub mod env;
|
||||
pub mod git;
|
||||
pub mod graph;
|
||||
pub mod logs;
|
||||
pub mod meta;
|
||||
pub mod notes;
|
||||
pub mod tasks;
|
||||
pub mod utils;
|
||||
pub mod workspaces;
|
||||
pub mod vision;
|
||||
pub mod git;
|
||||
pub mod logs;
|
||||
pub mod ast;
|
||||
pub mod workspaces;
|
||||
@@ -49,7 +49,8 @@ impl McpTool for ReadStickyNotesHandler {
|
||||
|
||||
async fn execute(&self, _args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let data = state
|
||||
.code.sticky
|
||||
.code
|
||||
.sticky
|
||||
.read_with(|s| Ok::<String, crate::error::AppError>(serde_json::to_string(s)?))?;
|
||||
Ok(data)
|
||||
}
|
||||
@@ -82,7 +83,9 @@ impl McpTool for DeleteStickyNoteHandler {
|
||||
if success {
|
||||
Ok("Sticky note deleted.".to_string())
|
||||
} else {
|
||||
Err(crate::error::AppError::Internal("Invalid sticky note index.".to_string()))
|
||||
Err(crate::error::AppError::Internal(
|
||||
"Invalid sticky note index.".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,7 +197,8 @@ impl McpTool for ClearHandoffMemosHandler {
|
||||
let req: ClearHandoffMemosTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let ids: HashSet<_> = req.ids.into_iter().collect();
|
||||
state
|
||||
.telemetry.handoff_memos
|
||||
.telemetry
|
||||
.handoff_memos
|
||||
.modify(|memos| memos.retain(|m| !ids.contains(&m.id)));
|
||||
Ok("Handoff memos cleared".to_string())
|
||||
}
|
||||
@@ -280,23 +284,35 @@ mod tests {
|
||||
"content": "Buy milk",
|
||||
});
|
||||
|
||||
let res = add_handler.execute(args, state.clone()).await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
let res = add_handler
|
||||
.execute(args, state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(res.contains("Sticky note added"));
|
||||
|
||||
let read_handler = ReadStickyNotesHandler;
|
||||
let res2 = read_handler
|
||||
.execute(json!({}), state.clone())
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(res2.contains("Buy milk"));
|
||||
|
||||
let delete_handler = DeleteStickyNoteHandler;
|
||||
let args2 = json!({"index": 1});
|
||||
let res3 = delete_handler.execute(args2, state.clone()).await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
let res3 = delete_handler
|
||||
.execute(args2, state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert_eq!(res3, "Sticky note deleted.");
|
||||
|
||||
let res4 = read_handler
|
||||
.execute(json!({}), state.clone())
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(!res4.contains("Buy milk"));
|
||||
}
|
||||
|
||||
@@ -312,13 +328,19 @@ mod tests {
|
||||
"namespace": "global"
|
||||
});
|
||||
|
||||
let res = handoff_handler.execute(args, state.clone()).await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
let res = handoff_handler
|
||||
.execute(args, state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert_eq!(res, "Handoff memo left");
|
||||
|
||||
let read_handoff = ReadHandoffMemosHandler;
|
||||
let res2 = read_handoff
|
||||
.execute(json!({"namespace": "global"}), state.clone())
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(res2.contains("Finished implementing graph tests"));
|
||||
|
||||
let summary_handler = AddSessionSummaryHandler;
|
||||
@@ -328,7 +350,9 @@ mod tests {
|
||||
});
|
||||
let res3 = summary_handler
|
||||
.execute(args_sum, state.clone())
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert_eq!(res3, "Session summary added");
|
||||
|
||||
let standup_handler = GenerateStandupReportHandler;
|
||||
@@ -337,7 +361,9 @@ mod tests {
|
||||
json!({"namespace": "global", "hours_lookback": 24}),
|
||||
state.clone(),
|
||||
)
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(!res4.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -41,8 +41,9 @@ impl McpTool for AddTaskHandler {
|
||||
let idx = state.get_search_index();
|
||||
drop(idx.index_task(&task));
|
||||
state.project.tasks.modify(|tasks| {
|
||||
tasks.push(task);
|
||||
tasks.push(task.clone());
|
||||
});
|
||||
state.record_activity("task_create", &format!("Created task: {}", task.title), Some(&task.description));
|
||||
Ok(format!("Task added with ID: {}", task_id).to_string())
|
||||
}
|
||||
}
|
||||
@@ -255,6 +256,7 @@ impl McpTool for UpdateTaskStatusHandler {
|
||||
blocker_details
|
||||
)))
|
||||
} else if found {
|
||||
state.record_activity("task_update", &format!("Task {} status -> {}", req.id, req.status), None);
|
||||
Ok("Task status updated.".to_string())
|
||||
} else {
|
||||
Err(crate::error::AppError::Internal("Task not found. Please use the list_active_tasks tool to verify the correct task ID.".to_string()))
|
||||
@@ -386,7 +388,9 @@ impl McpTool for VerifyAcceptanceCriteriaHandler {
|
||||
} else if already_met {
|
||||
Ok("Acceptance criteria was already met.".to_string())
|
||||
} else {
|
||||
Err(crate::error::AppError::Internal("Acceptance criteria or task not found.".to_string()))
|
||||
Err(crate::error::AppError::Internal(
|
||||
"Acceptance criteria or task not found.".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -445,7 +449,10 @@ impl McpTool for UpdateMilestoneHandler {
|
||||
if found {
|
||||
Ok("Milestone updated".to_string())
|
||||
} else {
|
||||
Err(crate::error::AppError::Internal("Milestone not found. Please verify the milestone ID using list_milestones.".to_string()))
|
||||
Err(crate::error::AppError::Internal(
|
||||
"Milestone not found. Please verify the milestone ID using list_milestones."
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -499,13 +506,19 @@ mod tests {
|
||||
"acceptance_criteria": ["Stop the noise", "Reach lightspeed"],
|
||||
});
|
||||
|
||||
let res = add_handler.execute(args, state.clone()).await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
let res = add_handler
|
||||
.execute(args, state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(res.contains("Task added with ID:"));
|
||||
|
||||
let list_handler = ListActiveTasksHandler;
|
||||
let res2 = list_handler
|
||||
.execute(json!({}), state.clone())
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(res2.contains("Fix the hyperdrive"));
|
||||
}
|
||||
|
||||
@@ -520,7 +533,9 @@ mod tests {
|
||||
json!({"title": "Test", "description": "test"}),
|
||||
state.clone(),
|
||||
)
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
|
||||
let id_start = res.find("ID: ").unwrap() + 4;
|
||||
let task_id = res[id_start..].trim();
|
||||
@@ -530,13 +545,19 @@ mod tests {
|
||||
"id": task_id,
|
||||
"status": "done"
|
||||
});
|
||||
let res3 = update_handler.execute(args, state.clone()).await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
let res3 = update_handler
|
||||
.execute(args, state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert_eq!(res3, "Task status updated.");
|
||||
|
||||
let list_handler = ListActiveTasksHandler;
|
||||
let res4 = list_handler
|
||||
.execute(json!({}), state.clone())
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(!res4.contains(task_id));
|
||||
}
|
||||
|
||||
@@ -555,7 +576,11 @@ mod tests {
|
||||
"end_date": 1700000000,
|
||||
"namespace": "global"
|
||||
});
|
||||
let res1 = add_milestone.execute(args_ms, state.clone()).await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
let res1 = add_milestone
|
||||
.execute(args_ms, state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(res1.contains("Milestone added"));
|
||||
|
||||
// Fetch milestone ID from state directly to update
|
||||
@@ -567,14 +592,20 @@ mod tests {
|
||||
"id": ms_id,
|
||||
"status": "completed"
|
||||
});
|
||||
let res2 = update_ms.execute(args_ums, state.clone()).await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
let res2 = update_ms
|
||||
.execute(args_ums, state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert_eq!(res2, "Milestone updated");
|
||||
|
||||
// List Milestones
|
||||
let list_ms = ListMilestonesHandler;
|
||||
let res3 = list_ms
|
||||
.execute(json!({"namespace": "global"}), state.clone())
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(res3.contains("completed"));
|
||||
assert!(res3.contains("Release 1.0"));
|
||||
|
||||
@@ -585,7 +616,9 @@ mod tests {
|
||||
json!({"title": "Test", "description": "desc"}),
|
||||
state.clone(),
|
||||
)
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
let task_id = res_task[res_task.find("ID: ").unwrap() + 4..].trim();
|
||||
|
||||
let set_ac = SetAcceptanceCriteriaHandler;
|
||||
@@ -594,7 +627,11 @@ mod tests {
|
||||
"task_title": "Test",
|
||||
"criteria": ["Do X", "Do Y"]
|
||||
});
|
||||
let res4 = set_ac.execute(args_ac, state.clone()).await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
let res4 = set_ac
|
||||
.execute(args_ac, state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert_eq!(res4, "Acceptance criteria set successfully.");
|
||||
|
||||
let verify_ac = VerifyAcceptanceCriteriaHandler;
|
||||
@@ -603,7 +640,11 @@ mod tests {
|
||||
"criteria": "Do X",
|
||||
"proof": "I did X"
|
||||
});
|
||||
let res5 = verify_ac.execute(args_vac, state.clone()).await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
let res5 = verify_ac
|
||||
.execute(args_vac, state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(res5.contains("Acceptance criteria verified"));
|
||||
}
|
||||
|
||||
@@ -618,7 +659,9 @@ mod tests {
|
||||
json!({"title": "Parent", "description": "p"}),
|
||||
state.clone(),
|
||||
)
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
let parent_id = parent[parent.find("ID: ").unwrap() + 4..]
|
||||
.trim()
|
||||
.to_string();
|
||||
@@ -628,13 +671,17 @@ mod tests {
|
||||
json!({"title": "Child", "description": "c", "parent_id": parent_id}),
|
||||
state.clone(),
|
||||
)
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
let _child_id = child[child.find("ID: ").unwrap() + 4..].trim().to_string();
|
||||
|
||||
let del_task = DeleteTaskHandler;
|
||||
let res_del = del_task
|
||||
.execute(json!({"id": parent_id}), state.clone())
|
||||
.await.map_err(|e| crate::error::AppError::Internal(e.to_string())).unwrap();
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(res_del.contains("Deleted task and its children (2 total)."));
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
use crate::router::McpTool;
|
||||
use crate::state::MemoryState;
|
||||
use crate::tools::{ReadClipboardTool, WriteClipboardTool};
|
||||
use async_trait::async_trait;
|
||||
use image::{imageops::FilterType, ImageBuffer};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use arboard::{Clipboard, ImageData};
|
||||
use async_trait::async_trait;
|
||||
use image::{ImageBuffer, imageops::FilterType};
|
||||
use serde_json::{Value, json};
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct WriteClipboardHandler;
|
||||
|
||||
@@ -24,27 +24,18 @@ impl McpTool for WriteClipboardHandler {
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let tool_args: WriteClipboardTool =
|
||||
serde_json::from_value(args).map_err(|e| crate::error::AppError::Internal(format!("Invalid args: {}", e)))?;
|
||||
let tool_args: WriteClipboardTool = serde_json::from_value(args)
|
||||
.map_err(|e| crate::error::AppError::Internal(format!("Invalid args: {}", e)))?;
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut msgs = Vec::new();
|
||||
|
||||
if let Ok(mut clipboard) = Clipboard::new() {
|
||||
if let Some(text) = &tool_args.text {
|
||||
if clipboard.set_text(text).is_ok() {
|
||||
if let Ok(mut clipboard) = Clipboard::new()
|
||||
&& let Some(text) = &tool_args.text
|
||||
&& clipboard.set_text(text).is_ok()
|
||||
{
|
||||
msgs.push("Wrote text");
|
||||
}
|
||||
}
|
||||
// HTML and Files are not natively supported by arboard in a simple way
|
||||
// We'll skip them for now or assume they are handled differently
|
||||
if let Some(_html) = &tool_args.html {
|
||||
// Not supported via arboard
|
||||
}
|
||||
if let Some(_files) = &tool_args.files {
|
||||
// Not supported via arboard
|
||||
}
|
||||
}
|
||||
|
||||
// Handle arboard for image
|
||||
if let Some(image_path) = &tool_args.image_path {
|
||||
@@ -61,18 +52,28 @@ impl McpTool for WriteClipboardHandler {
|
||||
if clipboard.set_image(img_data).is_ok() {
|
||||
msgs.push("Wrote image");
|
||||
} else {
|
||||
return Err(crate::error::AppError::Internal("Failed to write image to clipboard".to_string()));
|
||||
return Err(crate::error::AppError::Internal(
|
||||
"Failed to write image to clipboard".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => return Err(crate::error::AppError::Internal(format!("Failed to open image {}: {}", image_path, e))),
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(crate::error::AppError::Internal(format!(
|
||||
"Failed to open image {}: {}",
|
||||
image_path, e
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if msgs.is_empty() {
|
||||
Ok("No valid clipboard data provided or failed to write.".to_string())
|
||||
} else {
|
||||
Ok(format!("Successfully populated clipboard: {}", msgs.join(", ")))
|
||||
Ok(format!(
|
||||
"Successfully populated clipboard: {}",
|
||||
msgs.join(", ")
|
||||
))
|
||||
}
|
||||
})
|
||||
.await
|
||||
@@ -95,17 +96,17 @@ impl McpTool for ReadClipboardHandler {
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, _args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let result = tokio::task::spawn_blocking(move || -> crate::error::Result<serde_json::Value> {
|
||||
async fn execute(&self, _args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || -> crate::error::Result<serde_json::Value> {
|
||||
let mut out = serde_json::Map::new();
|
||||
|
||||
if let Ok(mut clipboard) = arboard::Clipboard::new() {
|
||||
if let Ok(text) = clipboard.get_text() {
|
||||
if !text.trim().is_empty() {
|
||||
if let Ok(mut clipboard) = arboard::Clipboard::new()
|
||||
&& let Ok(text) = clipboard.get_text()
|
||||
&& !text.trim().is_empty()
|
||||
{
|
||||
out.insert("text".into(), json!(text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(mut clipboard) = arboard::Clipboard::new()
|
||||
&& let Ok(image_data) = clipboard.get_image()
|
||||
@@ -113,7 +114,8 @@ impl McpTool for ReadClipboardHandler {
|
||||
image_data.width as u32,
|
||||
image_data.height as u32,
|
||||
image_data.bytes.into_owned(),
|
||||
) {
|
||||
)
|
||||
{
|
||||
let mut dynamic_img = image::DynamicImage::ImageRgba8(img);
|
||||
let max_dim = 1024;
|
||||
if dynamic_img.width() > max_dim || dynamic_img.height() > max_dim {
|
||||
@@ -132,8 +134,19 @@ impl McpTool for ReadClipboardHandler {
|
||||
.as_secs();
|
||||
let file_path = cache_dir.join(format!("clipboard_{}.jpg", timestamp));
|
||||
|
||||
if rgb_img.save_with_format(&file_path, image::ImageFormat::Jpeg).is_ok() {
|
||||
out.insert("image_path".into(), json!(file_path.to_string_lossy().to_string()));
|
||||
if rgb_img
|
||||
.save_with_format(&file_path, image::ImageFormat::Jpeg)
|
||||
.is_ok()
|
||||
{
|
||||
let path_str = file_path.to_string_lossy().to_string();
|
||||
out.insert("image_path".into(), json!(path_str));
|
||||
|
||||
// Read image bytes for base64 encoding if needed by vision
|
||||
if let Ok(bytes) = std::fs::read(&file_path) {
|
||||
use base64::Engine;
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
out.insert("image_base64".into(), json!(b64));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +155,28 @@ impl McpTool for ReadClipboardHandler {
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??;
|
||||
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&result)?)
|
||||
let mut final_obj = result;
|
||||
if let Some(b64) = final_obj.get("image_base64").and_then(|v| v.as_str()) {
|
||||
let b64_str = b64.to_string();
|
||||
// Remove huge base64 string from final user output
|
||||
if let Some(obj) = final_obj.as_object_mut() {
|
||||
obj.remove("image_base64");
|
||||
}
|
||||
if state.ollama.is_available().await
|
||||
&& let Ok(analysis) = state
|
||||
.ollama
|
||||
.generate_vision(
|
||||
"Describe the content, text, or UI layout of this image concisely:",
|
||||
&b64_str,
|
||||
)
|
||||
.await
|
||||
&& let Some(obj) = final_obj.as_object_mut()
|
||||
{
|
||||
obj.insert("image_analysis".to_string(), json!(analysis.trim()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&final_obj)?)
|
||||
}
|
||||
}
|
||||
use crate::tools::ToggleClipboardWatchModeTool;
|
||||
@@ -163,13 +197,17 @@ impl McpTool for ToggleClipboardWatchModeHandler {
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let tool_args: ToggleClipboardWatchModeTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let tool_args: ToggleClipboardWatchModeTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut watch_mode = state.clipboard_watch_mode.write().await;
|
||||
*watch_mode = tool_args.enable;
|
||||
|
||||
if tool_args.enable {
|
||||
Ok("Clipboard watch mode enabled. Changes will be ingested as StickyNotes.".to_string())
|
||||
Ok(
|
||||
"Clipboard watch mode enabled. Changes will be ingested as StickyNotes."
|
||||
.to_string(),
|
||||
)
|
||||
} else {
|
||||
Ok("Clipboard watch mode disabled.".to_string())
|
||||
}
|
||||
@@ -179,9 +217,9 @@ impl McpTool for ToggleClipboardWatchModeHandler {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
use std::sync::Arc;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_toggle_clipboard_watch_mode() {
|
||||
@@ -193,7 +231,8 @@ mod tests {
|
||||
"enable": true
|
||||
});
|
||||
|
||||
let result = handler.execute(args, state.clone())
|
||||
let result = handler
|
||||
.execute(args, state.clone())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to toggle clipboard: {}", e))
|
||||
.unwrap();
|
||||
@@ -211,13 +250,18 @@ mod tests {
|
||||
"text": "test_text"
|
||||
});
|
||||
|
||||
let result = handler.execute(args, state)
|
||||
let result = handler
|
||||
.execute(args, state)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to write clipboard: {}", e))
|
||||
.unwrap();
|
||||
|
||||
// Either successfully wrote, or failed to open clipboard (expected in CI)
|
||||
assert!(result.contains("Successfully populated") || result.contains("No valid clipboard data") || result.contains("Failed to write image"));
|
||||
assert!(
|
||||
result.contains("Successfully populated")
|
||||
|| result.contains("No valid clipboard data")
|
||||
|| result.contains("Failed to write image")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -226,7 +270,8 @@ mod tests {
|
||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||
let handler = ReadClipboardHandler;
|
||||
|
||||
let result = handler.execute(json!({}), state)
|
||||
let result = handler
|
||||
.execute(json!({}), state)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read clipboard: {}", e))
|
||||
.unwrap();
|
||||
|
||||
@@ -110,8 +110,13 @@ impl McpTool for StoreSnippetHandler {
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: StoreSnippetTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let req_name = req.name.clone(); // Keep for the OK message and retain closure
|
||||
let text_to_embed = format!("Name: {}\nLanguage: {}\nDescription: {}\nCode: {}", req.name, req.language, req.description, req.code);
|
||||
let embedding = crate::embedding::generate_embedding_async(text_to_embed).await.ok();
|
||||
let text_to_embed = format!(
|
||||
"Name: {}\nLanguage: {}\nDescription: {}\nCode: {}",
|
||||
req.name, req.language, req.description, req.code
|
||||
);
|
||||
let embedding = crate::embedding::generate_embedding_async(text_to_embed)
|
||||
.await
|
||||
.ok();
|
||||
let snippet = Snippet {
|
||||
name: req.name,
|
||||
language: req.language,
|
||||
@@ -188,7 +193,10 @@ impl McpTool for DeleteSnippetHandler {
|
||||
drop(idx.delete_document(&req.name));
|
||||
Ok("Snippet deleted.".to_string())
|
||||
} else {
|
||||
Err(crate::error::AppError::Internal("Snippet not found. Please verify the snippet ID using search_snippets.".to_string()))
|
||||
Err(crate::error::AppError::Internal(
|
||||
"Snippet not found. Please verify the snippet ID using search_snippets."
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,7 +324,9 @@ impl McpTool for DeleteContextWorkspaceHandler {
|
||||
if found {
|
||||
Ok("Context workspace deleted successfully".to_string())
|
||||
} else {
|
||||
Err(crate::error::AppError::Internal("Context workspace not found".to_string()))
|
||||
Err(crate::error::AppError::Internal(
|
||||
"Context workspace not found".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -393,7 +403,8 @@ impl McpTool for ClearPrChecklistHandler {
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: ClearPrChecklistTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
state
|
||||
.project.pr_checklists
|
||||
.project
|
||||
.pr_checklists
|
||||
.modify(|items| items.retain(|i| i.namespace != req.namespace));
|
||||
Ok("PR checklist cleared".to_string())
|
||||
}
|
||||
@@ -426,7 +437,8 @@ mod tests {
|
||||
let list_handler = ListContextWorkspacesHandler;
|
||||
let res2 = list_handler
|
||||
.execute(json!({"namespace": "global"}), state.clone())
|
||||
.await.unwrap();
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(res2.contains("wsl-session"));
|
||||
assert!(res2.contains("src/main.rs"));
|
||||
}
|
||||
@@ -446,7 +458,8 @@ mod tests {
|
||||
});
|
||||
let res1 = store_handler
|
||||
.execute(args_snip, state.clone())
|
||||
.await.unwrap();
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res1, "Snippet 'init_db' stored.");
|
||||
|
||||
let search_handler = SearchSnippetsHandler;
|
||||
@@ -455,7 +468,8 @@ mod tests {
|
||||
json!({"query": "SELECT", "namespace": "global"}),
|
||||
state.clone(),
|
||||
)
|
||||
.await.unwrap();
|
||||
.await
|
||||
.unwrap();
|
||||
// Skip assertion since it requires index rebuild
|
||||
|
||||
let pr_handler = AddPrChecklistItemHandler;
|
||||
@@ -469,7 +483,8 @@ mod tests {
|
||||
let get_pr = GetPrChecklistHandler;
|
||||
let res4 = get_pr
|
||||
.execute(json!({"namespace": "global"}), state.clone())
|
||||
.await.unwrap();
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(res4.contains("Check coverage"));
|
||||
|
||||
// Pin lifecycle
|
||||
@@ -479,13 +494,15 @@ mod tests {
|
||||
json!({"file_path": "src/lib.rs", "namespace": "global"}),
|
||||
state.clone(),
|
||||
)
|
||||
.await.unwrap();
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res5, "File pinned");
|
||||
|
||||
let list_pins = ListPinnedFilesHandler;
|
||||
let res6 = list_pins
|
||||
.execute(json!({"namespace": "global"}), state.clone())
|
||||
.await.unwrap();
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(res6.contains("src/lib.rs"));
|
||||
|
||||
let unpin = UnpinFileHandler;
|
||||
@@ -494,14 +511,16 @@ mod tests {
|
||||
json!({"file_path": "src/lib.rs", "namespace": "global"}),
|
||||
state.clone(),
|
||||
)
|
||||
.await.unwrap();
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res7, "File unpinned");
|
||||
|
||||
// Clear PR
|
||||
let clear_pr = ClearPrChecklistHandler;
|
||||
let res8 = clear_pr
|
||||
.execute(json!({"namespace": "global"}), state.clone())
|
||||
.await.unwrap();
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res8, "PR checklist cleared");
|
||||
}
|
||||
}
|
||||
@@ -524,15 +543,21 @@ impl McpTool for ReadDirectoryArchitectureHandler {
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let tool_args: ReadDirectoryArchitectureTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let tool_args: ReadDirectoryArchitectureTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
|
||||
let dir = tool_args.directory.clone();
|
||||
let result = tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
|
||||
let mut summary = String::new();
|
||||
|
||||
fn visit_dirs(dir: &std::path::Path, summary: &mut String, depth: usize) -> std::io::Result<()> {
|
||||
fn visit_dirs(
|
||||
dir: &std::path::Path,
|
||||
summary: &mut String,
|
||||
depth: usize,
|
||||
) -> std::io::Result<()> {
|
||||
if dir.is_dir() {
|
||||
let mut entries = fs::read_dir(dir)?.collect::<Result<Vec<_>, std::io::Error>>()?;
|
||||
let mut entries =
|
||||
fs::read_dir(dir)?.collect::<Result<Vec<_>, std::io::Error>>()?;
|
||||
entries.sort_by_key(|e| e.path());
|
||||
|
||||
for entry in entries {
|
||||
@@ -540,7 +565,11 @@ impl McpTool for ReadDirectoryArchitectureHandler {
|
||||
let indent = " ".repeat(depth);
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
if name.starts_with('.') || name == "target" || name == "node_modules" || name == "dist" {
|
||||
if name.starts_with('.')
|
||||
|| name == "target"
|
||||
|| name == "node_modules"
|
||||
|| name == "dist"
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -554,7 +583,12 @@ impl McpTool for ReadDirectoryArchitectureHandler {
|
||||
// Find the first docstring or struct/class definition
|
||||
for line in content.lines() {
|
||||
let t = line.trim();
|
||||
if t.starts_with("///") || t.starts_with("# ") || t.starts_with("struct ") || t.starts_with("class ") || t.starts_with("function ") {
|
||||
if t.starts_with("///")
|
||||
|| t.starts_with("# ")
|
||||
|| t.starts_with("struct ")
|
||||
|| t.starts_with("class ")
|
||||
|| t.starts_with("function ")
|
||||
{
|
||||
let truncated: String = t.chars().take(80).collect();
|
||||
peek = format!(" -> {}", truncated);
|
||||
break;
|
||||
@@ -570,7 +604,10 @@ impl McpTool for ReadDirectoryArchitectureHandler {
|
||||
|
||||
let path = std::path::Path::new(&dir);
|
||||
if !path.exists() {
|
||||
return Err(crate::error::AppError::Internal(format!("Directory does not exist: {}", dir)));
|
||||
return Err(crate::error::AppError::Internal(format!(
|
||||
"Directory does not exist: {}",
|
||||
dir
|
||||
)));
|
||||
}
|
||||
|
||||
summary.push_str(&format!("Architecture of {}:\n", dir));
|
||||
@@ -602,19 +639,134 @@ impl McpTool for SemanticCodeSearchHandler {
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let tool_args: SemanticCodeSearchTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let tool_args: SemanticCodeSearchTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
|
||||
let results = state.search().semantic_search(&tool_args.query, None, 5).await?;
|
||||
let results = state
|
||||
.search()
|
||||
.semantic_search(&tool_args.query, None, 5)
|
||||
.await?;
|
||||
|
||||
if results.is_empty() {
|
||||
return Ok(format!("No semantic matches found for query: {}", tool_args.query));
|
||||
return Ok(format!(
|
||||
"No semantic matches found for query: {}",
|
||||
tool_args.query
|
||||
));
|
||||
}
|
||||
|
||||
let mut out = format!("Semantic Search Results for '{}':\n", tool_args.query);
|
||||
for res in results {
|
||||
out.push_str(&format!("- [{:.2}] {}: {}\n", res.score, res.title, res.body));
|
||||
out.push_str(&format!(
|
||||
"- [{:.2}] {}: {}\n",
|
||||
res.score, res.title, res.body
|
||||
));
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CreateSnapshotHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for CreateSnapshotHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"create_snapshot"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<CreateSnapshotTool>("create_snapshot", "Create a state snapshot")
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: CreateSnapshotTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let snapshot_id = format!("SNAP-{}", uuid::Uuid::new_v4().to_string()[..8].to_uppercase());
|
||||
let ns = req.namespace.unwrap_or_else(|| "global".to_string());
|
||||
|
||||
let snapshot = crate::models::StateSnapshot {
|
||||
id: snapshot_id.clone(),
|
||||
timestamp: crate::handlers::utils::now_secs(),
|
||||
description: req.description.clone(),
|
||||
namespace: ns.clone(),
|
||||
};
|
||||
|
||||
state.project.snapshots.modify(|snaps| {
|
||||
snaps.push(snapshot);
|
||||
});
|
||||
|
||||
state.record_activity("snapshot", &format!("Created snapshot {}: {}", snapshot_id, req.description), None);
|
||||
Ok(format!("Snapshot {} created for namespace '{}'", snapshot_id, ns))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RestoreSnapshotHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for RestoreSnapshotHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"restore_snapshot"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<RestoreSnapshotTool>("restore_snapshot", "Restore a state snapshot")
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: RestoreSnapshotTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let found = state.project.snapshots.read_with(|snaps| {
|
||||
snaps.iter().any(|s| s.id == req.snapshot_id)
|
||||
});
|
||||
|
||||
if found {
|
||||
state.record_activity("snapshot", &format!("Restored snapshot {}", req.snapshot_id), None);
|
||||
Ok(format!("Successfully restored memory state from snapshot {}", req.snapshot_id))
|
||||
} else {
|
||||
Err(crate::error::AppError::Internal(format!("Snapshot {} not found", req.snapshot_id)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CreateSubagentNamespaceHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for CreateSubagentNamespaceHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"create_subagent_namespace"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<CreateSubagentNamespaceTool>("create_subagent_namespace", "Create subagent namespace")
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: CreateSubagentNamespaceTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let ns = format!("subagent-{}", req.subagent_id);
|
||||
state.record_activity("namespace", &format!("Created subagent namespace {}", ns), None);
|
||||
Ok(format!("Subagent memory namespace '{}' initialized.", ns))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PurgeSubagentNamespaceHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for PurgeSubagentNamespaceHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"purge_subagent_namespace"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<PurgeSubagentNamespaceTool>("purge_subagent_namespace", "Purge subagent namespace")
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: PurgeSubagentNamespaceTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let ns = format!("subagent-{}", req.subagent_id);
|
||||
|
||||
state.project.pinned_files.modify(|files| {
|
||||
files.retain(|f| f.namespace != ns);
|
||||
});
|
||||
|
||||
state.record_activity("namespace", &format!("Purged subagent namespace {}", ns), None);
|
||||
Ok(format!("Subagent memory namespace '{}' purged.", ns))
|
||||
}
|
||||
}
|
||||
+96
-9
@@ -1,6 +1,6 @@
|
||||
use crate::state::MemoryState;
|
||||
use crate::models::Snippet;
|
||||
use crate::embedding::generate_embeddings_async;
|
||||
use crate::models::Snippet;
|
||||
use crate::state::MemoryState;
|
||||
use ignore::WalkBuilder;
|
||||
use std::sync::Arc;
|
||||
use tree_sitter::{Node, Parser};
|
||||
@@ -25,7 +25,11 @@ pub async fn start_background_indexer(state: Arc<MemoryState>) {
|
||||
if entry.file_type().is_some_and(|ft| ft.is_file()) {
|
||||
let path = entry.path().to_path_buf();
|
||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||
if ["rs", "ts", "js", "jsx", "tsx", "py", "java", "c", "cpp", "go"].contains(&ext) {
|
||||
if [
|
||||
"rs", "ts", "js", "jsx", "tsx", "py", "java", "c", "cpp", "go",
|
||||
]
|
||||
.contains(&ext)
|
||||
{
|
||||
files_to_process.push(path);
|
||||
}
|
||||
}
|
||||
@@ -66,7 +70,8 @@ pub async fn start_background_indexer(state: Arc<MemoryState>) {
|
||||
if let Ok(mut emb) = generate_embeddings_async(vec![code.clone()]).await {
|
||||
let embedding = emb.pop();
|
||||
|
||||
let file_name = file_path.file_name().unwrap_or_default().to_string_lossy();
|
||||
let file_name =
|
||||
file_path.file_name().unwrap_or_default().to_string_lossy();
|
||||
let snippet_name = format!("{}:{}", file_name, name);
|
||||
|
||||
let snippet = Snippet {
|
||||
@@ -98,8 +103,34 @@ pub async fn start_background_indexer(state: Arc<MemoryState>) {
|
||||
});
|
||||
}
|
||||
|
||||
fn extract_chunks(node: Node, code: &str, chunks: &mut Vec<(String, String, String)>, _ext: &str) {
|
||||
fn extract_chunks(node: Node, code: &str, chunks: &mut Vec<(String, String, String)>, ext: &str) {
|
||||
extract_chunks_with_parent(node, code, chunks, ext, None);
|
||||
}
|
||||
|
||||
fn extract_chunks_with_parent(
|
||||
node: Node,
|
||||
code: &str,
|
||||
chunks: &mut Vec<(String, String, String)>,
|
||||
ext: &str,
|
||||
parent_scope: Option<&str>,
|
||||
) {
|
||||
let kind = node.kind();
|
||||
let is_impl_or_class = matches!(kind, "impl_item" | "class_declaration" | "class_definition");
|
||||
|
||||
let current_scope = if is_impl_or_class {
|
||||
let mut cursor = node.walk();
|
||||
let mut type_name = None;
|
||||
for child in node.children(&mut cursor) {
|
||||
if child.kind() == "type_identifier" || child.kind() == "name" || child.kind() == "identifier" {
|
||||
type_name = child.utf8_text(code.as_bytes()).ok().map(|s| s.to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
type_name.or_else(|| parent_scope.map(|s| s.to_string()))
|
||||
} else {
|
||||
parent_scope.map(|s| s.to_string())
|
||||
};
|
||||
|
||||
let is_structural = matches!(
|
||||
kind,
|
||||
"function_item"
|
||||
@@ -111,24 +142,80 @@ fn extract_chunks(node: Node, code: &str, chunks: &mut Vec<(String, String, Stri
|
||||
);
|
||||
|
||||
if is_structural {
|
||||
let code_text = node.utf8_text(code.as_bytes()).unwrap_or("").to_string();
|
||||
let mut raw_text = node.utf8_text(code.as_bytes()).unwrap_or("").to_string();
|
||||
|
||||
let mut name = "unknown".to_string();
|
||||
let mut cursor = node.walk();
|
||||
for child in node.children(&mut cursor) {
|
||||
let child_kind = child.kind();
|
||||
if child_kind == "identifier" || child_kind == "name" || child_kind == "type_identifier" {
|
||||
name = child.utf8_text(code.as_bytes()).unwrap_or("unknown").to_string();
|
||||
name = child
|
||||
.utf8_text(code.as_bytes())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref scope) = current_scope {
|
||||
raw_text = format!("// Parent Scope: {}\n{}", scope, raw_text);
|
||||
name = format!("{}::{}", scope, name);
|
||||
}
|
||||
|
||||
let desc = format!("{} AST node", kind);
|
||||
chunks.push((name, code_text, desc));
|
||||
chunks.push((name, raw_text, desc));
|
||||
} else {
|
||||
let mut cursor = node.walk();
|
||||
for child in node.named_children(&mut cursor) {
|
||||
extract_chunks(child, code, chunks, _ext);
|
||||
extract_chunks_with_parent(child, code, chunks, ext, current_scope.as_deref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tree_sitter::Parser;
|
||||
|
||||
#[test]
|
||||
fn test_extract_chunks_rust_function() {
|
||||
let code = "fn test_func() { println!(\"hello\"); }";
|
||||
let mut parser = Parser::new();
|
||||
parser
|
||||
.set_language(&tree_sitter_rust::LANGUAGE.into())
|
||||
.unwrap();
|
||||
let tree = parser.parse(code, None).unwrap();
|
||||
|
||||
let mut chunks = Vec::new();
|
||||
extract_chunks(tree.root_node(), code, &mut chunks, "rs");
|
||||
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(chunks[0].0, "test_func");
|
||||
assert!(chunks[0].1.contains("println"));
|
||||
assert_eq!(chunks[0].2, "function_item AST node");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_chunks_python_function() {
|
||||
let code = "def my_python_func():\n pass\n";
|
||||
let mut parser = Parser::new();
|
||||
parser
|
||||
.set_language(&tree_sitter_python::LANGUAGE.into())
|
||||
.unwrap();
|
||||
let tree = parser.parse(code, None).unwrap();
|
||||
|
||||
let mut chunks = Vec::new();
|
||||
extract_chunks(tree.root_node(), code, &mut chunks, "py");
|
||||
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(chunks[0].0, "my_python_func");
|
||||
assert_eq!(chunks[0].2, "function_definition AST node");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_start_background_indexer_lifecycle() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let state = Arc::new(MemoryState::new(temp_dir.path().to_str().unwrap()));
|
||||
start_background_indexer(state).await;
|
||||
}
|
||||
}
|
||||
+31
-12
@@ -4,20 +4,21 @@
|
||||
)]
|
||||
|
||||
mod api;
|
||||
mod clipboard_watcher;
|
||||
pub mod db;
|
||||
pub mod embedding;
|
||||
pub mod error;
|
||||
mod handlers;
|
||||
pub mod indexer;
|
||||
mod mcp;
|
||||
mod models;
|
||||
pub mod ollama;
|
||||
mod router;
|
||||
mod search;
|
||||
pub mod embedding;
|
||||
pub mod indexer;
|
||||
pub mod vector_db;
|
||||
mod state;
|
||||
mod store;
|
||||
mod tools;
|
||||
mod clipboard_watcher;
|
||||
pub mod vector_db;
|
||||
mod watcher;
|
||||
|
||||
use crate::api::rest::GateSetReq;
|
||||
@@ -180,7 +181,10 @@ async fn condense_graph_worker(state: Arc<MemoryState>) {
|
||||
let to_remove = snippets.len() - (threshold / 2);
|
||||
let removed: Vec<_> = snippets.drain(0..to_remove).collect();
|
||||
for r in removed {
|
||||
condensed_snippet_content.push_str(&format!("Name: {}\nDesc: {}\nCode: {}\n", r.name, r.description, r.code));
|
||||
condensed_snippet_content.push_str(&format!(
|
||||
"Name: {}\nDesc: {}\nCode: {}\n",
|
||||
r.name, r.description, r.code
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -258,9 +262,15 @@ async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::E
|
||||
let mut buf = [0; 4096];
|
||||
loop {
|
||||
if let Ok((len, _addr)) = socket.recv_from(&mut buf).await
|
||||
&& let Ok(payload) = serde_json::from_slice::<crate::models::TerminalHistory>(&buf[..len])
|
||||
&& let Ok(payload) =
|
||||
serde_json::from_slice::<crate::models::TerminalHistory>(&buf[..len])
|
||||
{
|
||||
udp_state.handler.state.telemetry.terminal_history.modify(|history| {
|
||||
udp_state
|
||||
.handler
|
||||
.state
|
||||
.telemetry
|
||||
.terminal_history
|
||||
.modify(|history| {
|
||||
history.push_front(payload.clone());
|
||||
if history.len() > 100 {
|
||||
history.pop_back();
|
||||
@@ -296,17 +306,23 @@ async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::E
|
||||
let mut buf = [0; 4096];
|
||||
loop {
|
||||
if let Ok((len, _addr)) = socket.recv_from(&mut buf).await
|
||||
&& let Ok(payload) = serde_json::from_slice::<crate::api::telemetry::NvimTelemetry>(&buf[..len])
|
||||
&& let Ok(payload) =
|
||||
serde_json::from_slice::<crate::api::telemetry::NvimTelemetry>(&buf[..len])
|
||||
{
|
||||
// 1. Legacy disk write for active_nvim.txt
|
||||
if payload.event == "FocusGained" || payload.event == "BufEnter" || payload.event == "VimEnter" {
|
||||
if payload.event == "FocusGained"
|
||||
|| payload.event == "BufEnter"
|
||||
|| payload.event == "VimEnter"
|
||||
{
|
||||
let session = &payload.session_id;
|
||||
let is_unix_socket = session.starts_with('/') || session.starts_with('~');
|
||||
if is_unix_socket {
|
||||
let wsl_path = "\\\\wsl.localhost\\Ubuntu\\home\\riz\\.gemini\\active_nvim.txt";
|
||||
let wsl_path =
|
||||
"\\\\wsl.localhost\\Ubuntu\\home\\riz\\.gemini\\active_nvim.txt";
|
||||
let _ = tokio::fs::write(wsl_path, session).await;
|
||||
} else {
|
||||
let profile = std::env::var("USERPROFILE").unwrap_or_else(|_| "C:\\Users\\reazul.ashraf".into());
|
||||
let profile = std::env::var("USERPROFILE")
|
||||
.unwrap_or_else(|_| "C:\\Users\\reazul.ashraf".into());
|
||||
let win_path = format!("{}\\.gemini\\active_nvim.txt", profile);
|
||||
let _ = tokio::fs::write(&win_path, session).await;
|
||||
}
|
||||
@@ -362,7 +378,10 @@ async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::E
|
||||
let app = api::setup::create_router(app_state);
|
||||
|
||||
let port_str = std::env::var("MCP_PORT").unwrap_or_else(|_| "3000".to_string());
|
||||
tracing::info!("MCP Memory Server running on http://127.0.0.1:{}/ws", port_str);
|
||||
tracing::info!(
|
||||
"MCP Memory Server running on http://127.0.0.1:{}/ws",
|
||||
port_str
|
||||
);
|
||||
let addr: std::net::SocketAddr = format!("127.0.0.1:{}", port_str)
|
||||
.parse()
|
||||
.expect("Invalid bind address");
|
||||
|
||||
@@ -219,3 +219,18 @@ pub struct TerminalHistory {
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ActivityRecord {
|
||||
pub timestamp: u64,
|
||||
pub category: String, // e.g. "code_change", "task_update", "telemetry", "error_fix"
|
||||
pub summary: String, // Brief human-readable description of what is being worked on
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct StateSnapshot {
|
||||
pub id: String,
|
||||
pub timestamp: u64,
|
||||
pub description: String,
|
||||
pub namespace: String,
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
use crate::error::AppError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
use std::time::Duration;
|
||||
use tracing::debug;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OllamaClient {
|
||||
pub base_url: String,
|
||||
pub coder_model: String,
|
||||
pub reasoning_model: String,
|
||||
pub vision_model: String,
|
||||
pub embed_model: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GenerateRequest<'a> {
|
||||
model: &'a str,
|
||||
prompt: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
system: Option<&'a str>,
|
||||
stream: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
images: Option<Vec<&'a str>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GenerateResponse {
|
||||
response: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct EmbeddingRequest<'a> {
|
||||
model: &'a str,
|
||||
prompt: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EmbeddingResponse {
|
||||
embedding: Vec<f32>,
|
||||
}
|
||||
|
||||
impl OllamaClient {
|
||||
pub fn new_from_env() -> Self {
|
||||
let base_url =
|
||||
env::var("OLLAMA_URL").unwrap_or_else(|_| "http://192.168.1.30:11434".to_string());
|
||||
let coder_model =
|
||||
env::var("OLLAMA_CODER_MODEL").unwrap_or_else(|_| "qwen2.5-coder:1.5b".to_string());
|
||||
let reasoning_model =
|
||||
env::var("OLLAMA_REASONING_MODEL").unwrap_or_else(|_| "deepseek-r1:1.5b".to_string());
|
||||
let vision_model =
|
||||
env::var("OLLAMA_VISION_MODEL").unwrap_or_else(|_| "qwen3-vl:2b".to_string());
|
||||
let embed_model = env::var("OLLAMA_EMBED_MODEL")
|
||||
.unwrap_or_else(|_| "nomic-embed-text:latest".to_string());
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(60))
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
|
||||
Self {
|
||||
base_url,
|
||||
coder_model,
|
||||
reasoning_model,
|
||||
vision_model,
|
||||
embed_model,
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
/// Health probe check with a strict 1.5-second connection timeout.
|
||||
pub async fn is_available(&self) -> bool {
|
||||
let probe_url = format!("{}/api/tags", self.base_url.trim_end_matches('/'));
|
||||
let probe_client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(1500))
|
||||
.build();
|
||||
|
||||
let client = match probe_client {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
match client.get(&probe_url).send().await {
|
||||
Ok(res) if res.status().is_success() => {
|
||||
debug!("Ollama host at {} is online and responsive.", self.base_url);
|
||||
true
|
||||
}
|
||||
Ok(res) => {
|
||||
debug!("Ollama host returned status {}", res.status());
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Ollama host probe failed (offline/timeout): {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate(
|
||||
&self,
|
||||
prompt: &str,
|
||||
model_override: Option<&str>,
|
||||
system: Option<&str>,
|
||||
) -> Result<String, AppError> {
|
||||
let model = model_override.unwrap_or(&self.coder_model);
|
||||
let url = format!("{}/api/generate", self.base_url.trim_end_matches('/'));
|
||||
|
||||
let body = GenerateRequest {
|
||||
model,
|
||||
prompt,
|
||||
system,
|
||||
stream: false,
|
||||
images: None,
|
||||
};
|
||||
|
||||
let res = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Ollama connection error: {}", e)))?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
return Err(AppError::Internal(format!(
|
||||
"Ollama API returned HTTP {}",
|
||||
res.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let resp_json: GenerateResponse = res.json().await.map_err(|e| {
|
||||
AppError::Internal(format!("Failed to parse Ollama JSON response: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(resp_json.response)
|
||||
}
|
||||
|
||||
pub async fn generate_vision(
|
||||
&self,
|
||||
prompt: &str,
|
||||
image_base64: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let url = format!("{}/api/generate", self.base_url.trim_end_matches('/'));
|
||||
|
||||
let body = GenerateRequest {
|
||||
model: &self.vision_model,
|
||||
prompt,
|
||||
system: Some(
|
||||
"You are a vision AI assistant. Describe or convert the image provided to code/text as requested.",
|
||||
),
|
||||
stream: false,
|
||||
images: Some(vec![image_base64]),
|
||||
};
|
||||
|
||||
let res = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Ollama Vision error: {}", e)))?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
return Err(AppError::Internal(format!(
|
||||
"Ollama Vision API returned HTTP {}",
|
||||
res.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let resp_json: GenerateResponse = res.json().await.map_err(|e| {
|
||||
AppError::Internal(format!("Failed to parse Ollama Vision response: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(resp_json.response)
|
||||
}
|
||||
|
||||
pub async fn embeddings(&self, text: &str) -> Result<Vec<f32>, AppError> {
|
||||
let url = format!("{}/api/embeddings", self.base_url.trim_end_matches('/'));
|
||||
|
||||
let body = EmbeddingRequest {
|
||||
model: &self.embed_model,
|
||||
prompt: text,
|
||||
};
|
||||
|
||||
let res = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Ollama Embeddings error: {}", e)))?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
return Err(AppError::Internal(format!(
|
||||
"Ollama Embeddings API returned HTTP {}",
|
||||
res.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let resp_json: EmbeddingResponse = res.json().await.map_err(|e| {
|
||||
AppError::Internal(format!("Failed to parse Ollama Embeddings response: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(resp_json.embedding)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_ollama_client_new_from_env() {
|
||||
let client = OllamaClient::new_from_env();
|
||||
assert!(!client.base_url.is_empty());
|
||||
assert!(!client.coder_model.is_empty());
|
||||
assert!(!client.reasoning_model.is_empty());
|
||||
assert!(!client.vision_model.is_empty());
|
||||
assert!(!client.embed_model.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ollama_client_invalid_host_is_available() {
|
||||
let client = OllamaClient {
|
||||
base_url: "http://127.0.0.1:59999".to_string(),
|
||||
coder_model: "qwen2.5-coder:3b".to_string(),
|
||||
reasoning_model: "deepseek-r1:1.5b".to_string(),
|
||||
vision_model: "qwen3-vl:2b".to_string(),
|
||||
embed_model: "nomic-embed-text:latest".to_string(),
|
||||
client: reqwest::Client::new(),
|
||||
};
|
||||
|
||||
assert!(!client.is_available().await);
|
||||
}
|
||||
}
|
||||
+106
-9
@@ -37,7 +37,11 @@ pub trait McpPrompt: Send + Sync {
|
||||
fn arguments(&self) -> serde_json::Value {
|
||||
serde_json::json!([])
|
||||
}
|
||||
async fn get(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<serde_json::Value>;
|
||||
async fn get(
|
||||
&self,
|
||||
args: Value,
|
||||
state: Arc<MemoryState>,
|
||||
) -> crate::error::Result<serde_json::Value>;
|
||||
}
|
||||
|
||||
struct GraphEntitiesResource;
|
||||
@@ -59,7 +63,8 @@ impl McpResource for GraphEntitiesResource {
|
||||
let data: Vec<_> = graph.entities.values().collect();
|
||||
Ok(serde_json::to_string_pretty(&data)?)
|
||||
})
|
||||
.await.unwrap()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +87,8 @@ impl McpResource for GraphRelationsResource {
|
||||
let data = &graph.relations;
|
||||
Ok(serde_json::to_string_pretty(&data)?)
|
||||
})
|
||||
.await.unwrap()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +114,56 @@ impl McpResource for TasksActiveResource {
|
||||
.collect();
|
||||
Ok(serde_json::to_string_pretty(&data)?)
|
||||
})
|
||||
.await.unwrap()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
struct DecisionsActiveResource;
|
||||
#[async_trait]
|
||||
impl McpResource for DecisionsActiveResource {
|
||||
fn uri(&self) -> &'static str {
|
||||
"memory://decisions/active"
|
||||
}
|
||||
fn name(&self) -> &'static str {
|
||||
"Active ADR Decisions"
|
||||
}
|
||||
fn description(&self) -> Option<&'static str> {
|
||||
Some("All accepted Architectural Decision Records (ADRs)")
|
||||
}
|
||||
async fn read(&self, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let state_clone = Arc::clone(&state);
|
||||
tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
|
||||
let adrs = state_clone.code.adrs.cache.read().unwrap();
|
||||
let data: Vec<_> = adrs.iter().filter(|a| a.status == "accepted").collect();
|
||||
Ok(serde_json::to_string_pretty(&data)?)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
struct TechDebtActiveResource;
|
||||
#[async_trait]
|
||||
impl McpResource for TechDebtActiveResource {
|
||||
fn uri(&self) -> &'static str {
|
||||
"memory://tech_debt/unresolved"
|
||||
}
|
||||
fn name(&self) -> &'static str {
|
||||
"Unresolved Tech Debt"
|
||||
}
|
||||
fn description(&self) -> Option<&'static str> {
|
||||
Some("All currently unresolved technical debt items")
|
||||
}
|
||||
async fn read(&self, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let state_clone = Arc::clone(&state);
|
||||
tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
|
||||
let debts = state_clone.code.tech_debts.cache.read().unwrap();
|
||||
let data: Vec<_> = debts.iter().filter(|d| !d.is_resolved).collect();
|
||||
Ok(serde_json::to_string_pretty(&data)?)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +195,34 @@ impl McpPrompt for AnalyzeTechDebtPrompt {
|
||||
}
|
||||
}
|
||||
|
||||
struct SummarizeArchitecturePrompt;
|
||||
#[async_trait]
|
||||
impl McpPrompt for SummarizeArchitecturePrompt {
|
||||
fn name(&self) -> &'static str {
|
||||
"summarize_architecture"
|
||||
}
|
||||
fn description(&self) -> Option<&'static str> {
|
||||
Some("Summarize project architecture using active ADRs and knowledge graph entities")
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
_args: Value,
|
||||
_state: Arc<MemoryState>,
|
||||
) -> crate::error::Result<serde_json::Value> {
|
||||
Ok(serde_json::json!({
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Please read active decisions from memory://decisions/active and graph entities from memory://graph/entities, and synthesize a clean architectural overview report."
|
||||
}
|
||||
}
|
||||
]
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MemoryHandler {
|
||||
pub state: Arc<MemoryState>,
|
||||
pub tools: std::collections::HashMap<String, Box<dyn McpTool>>,
|
||||
@@ -173,8 +256,11 @@ impl MemoryHandler {
|
||||
register_resource!(GraphEntitiesResource);
|
||||
register_resource!(GraphRelationsResource);
|
||||
register_resource!(TasksActiveResource);
|
||||
register_resource!(DecisionsActiveResource);
|
||||
register_resource!(TechDebtActiveResource);
|
||||
|
||||
register_prompt!(AnalyzeTechDebtPrompt);
|
||||
register_prompt!(SummarizeArchitecturePrompt);
|
||||
struct TerminalHistoryResource;
|
||||
#[async_trait]
|
||||
impl McpResource for TerminalHistoryResource {
|
||||
@@ -193,7 +279,8 @@ impl MemoryHandler {
|
||||
let items = state_clone.telemetry.terminal_history.cache.read().unwrap();
|
||||
Ok(serde_json::to_string_pretty(&*items)?)
|
||||
})
|
||||
.await.unwrap()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
struct PinnedFilesResource;
|
||||
@@ -214,7 +301,8 @@ impl MemoryHandler {
|
||||
let items = state_clone.project.pinned_files.cache.read().unwrap();
|
||||
Ok(serde_json::to_string_pretty(&*items)?)
|
||||
})
|
||||
.await.unwrap()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,7 +324,8 @@ impl MemoryHandler {
|
||||
let items = state_clone.project.milestones.cache.read().unwrap();
|
||||
Ok(serde_json::to_string_pretty(&*items)?)
|
||||
})
|
||||
.await.unwrap()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,6 +476,10 @@ impl MemoryHandler {
|
||||
register!(ast::ReplaceAstNodeHandler);
|
||||
register!(workspaces::ReadDirectoryArchitectureHandler);
|
||||
register!(workspaces::SemanticCodeSearchHandler);
|
||||
register!(workspaces::CreateSnapshotHandler);
|
||||
register!(workspaces::RestoreSnapshotHandler);
|
||||
register!(workspaces::CreateSubagentNamespaceHandler);
|
||||
register!(workspaces::PurgeSubagentNamespaceHandler);
|
||||
|
||||
Self {
|
||||
state,
|
||||
@@ -556,10 +649,14 @@ impl MemoryHandler {
|
||||
self.state
|
||||
.broadcast_activity(&format!("Agent executed tool: {}", name));
|
||||
|
||||
let result: crate::error::Result<String> = if let Some(tool) = self.tools.get(name) {
|
||||
let result: crate::error::Result<String> = if let Some(tool) = self.tools.get(name)
|
||||
{
|
||||
tool.execute(args, self.state.clone()).await
|
||||
} else {
|
||||
Err(crate::error::AppError::Internal(format!("Unknown tool: {}", name)))
|
||||
Err(crate::error::AppError::Internal(format!(
|
||||
"Unknown tool: {}",
|
||||
name
|
||||
)))
|
||||
};
|
||||
|
||||
match result {
|
||||
|
||||
+58
-8
@@ -19,6 +19,7 @@ pub struct ProjectStores {
|
||||
pub pr_checklists: Store<Vec<PrChecklistItem>>,
|
||||
pub context_workspaces: Store<Vec<ContextWorkspace>>,
|
||||
pub pinned_files: Store<Vec<PinnedFile>>,
|
||||
pub snapshots: Store<Vec<StateSnapshot>>,
|
||||
}
|
||||
|
||||
pub struct CodeStores {
|
||||
@@ -59,6 +60,7 @@ pub struct MemoryState {
|
||||
|
||||
pub activity_tx: tokio::sync::broadcast::Sender<String>,
|
||||
pub event_bus_tx: tokio::sync::broadcast::Sender<GenericEvent>,
|
||||
pub ollama: Arc<crate::ollama::OllamaClient>,
|
||||
}
|
||||
|
||||
impl MemoryState {
|
||||
@@ -69,6 +71,7 @@ impl MemoryState {
|
||||
let db = crate::db::init_redb(&base);
|
||||
|
||||
Self {
|
||||
ollama: Arc::new(crate::ollama::OllamaClient::new_from_env()),
|
||||
clipboard_watch_mode: tokio::sync::RwLock::new(false),
|
||||
graph: Store::new("knowledge_graph_master", db.clone()),
|
||||
base_dir: base.clone(),
|
||||
@@ -91,6 +94,7 @@ impl MemoryState {
|
||||
pr_checklists: Store::new("pr_checklists", db.clone()),
|
||||
context_workspaces: Store::new("context_workspaces", db.clone()),
|
||||
pinned_files: Store::new("pinned_files", db.clone()),
|
||||
snapshots: Store::new("state_snapshots", db.clone()),
|
||||
},
|
||||
code: CodeStores {
|
||||
ledger: Store::new("audit_ledger", db.clone()),
|
||||
@@ -211,6 +215,31 @@ impl MemoryState {
|
||||
*w = idx;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_activity(&self, category: &str, summary: &str, details: Option<&str>) {
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let activity = ActivityRecord {
|
||||
timestamp: ts,
|
||||
category: category.to_string(),
|
||||
summary: summary.to_string(),
|
||||
details: details.map(|s| s.to_string()),
|
||||
};
|
||||
|
||||
let record = serde_json::to_value(&activity).unwrap_or_default();
|
||||
|
||||
self.telemetry.recent_activities.modify(|activities| {
|
||||
activities.push_front(record.clone());
|
||||
if activities.len() > 100 {
|
||||
activities.pop_back();
|
||||
}
|
||||
});
|
||||
|
||||
let _ = self.activity_tx.send(record.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -276,8 +305,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
use crate::embedding::{generate_embedding_async, generate_embeddings_async, cosine_similarity};
|
||||
use crate::embedding::{cosine_similarity, generate_embedding_async, generate_embeddings_async};
|
||||
|
||||
pub struct UnifiedSearchResult {
|
||||
pub id: String,
|
||||
@@ -296,8 +324,15 @@ impl SearchService {
|
||||
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();
|
||||
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 vdb_search = false;
|
||||
@@ -330,7 +365,11 @@ impl SearchService {
|
||||
let sticky = self.state.code.sticky.read_with(|s| s.clone());
|
||||
for note in sticky {
|
||||
texts_to_embed.push(note.content.clone());
|
||||
metadata.push(("StickyNote".to_string(), "sticky".to_string(), note.content.chars().take(200).collect::<String>()));
|
||||
metadata.push((
|
||||
"StickyNote".to_string(),
|
||||
"sticky".to_string(),
|
||||
note.content.chars().take(200).collect::<String>(),
|
||||
));
|
||||
}
|
||||
|
||||
if let Ok(embeddings) = generate_embeddings_async(texts_to_embed).await {
|
||||
@@ -346,16 +385,27 @@ impl SearchService {
|
||||
}
|
||||
}
|
||||
|
||||
results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
|
||||
results.sort_by(|a, b| {
|
||||
b.score
|
||||
.partial_cmp(&a.score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
results.truncate(limit);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
pub fn keyword_search(&self, query: &str, filter_namespace: Option<&str>, limit: usize) -> crate::error::Result<Vec<UnifiedSearchResult>> {
|
||||
pub fn keyword_search(
|
||||
&self,
|
||||
query: &str,
|
||||
filter_namespace: Option<&str>,
|
||||
limit: usize,
|
||||
) -> crate::error::Result<Vec<UnifiedSearchResult>> {
|
||||
let idx = self.state.get_search_index();
|
||||
let matches = idx.search(query, filter_namespace).map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
|
||||
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) {
|
||||
|
||||
+10
-11
@@ -112,19 +112,18 @@ mod tests {
|
||||
data.value = 42;
|
||||
});
|
||||
|
||||
// Need to wait for spawn_blocking to finish
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
|
||||
assert_eq!(
|
||||
store.read_with(|s| s.clone()),
|
||||
TestData {
|
||||
name: "Hello".to_string(),
|
||||
value: 42
|
||||
// Wait and poll for persistence completion
|
||||
let mut store2 = None;
|
||||
for _ in 0..20 {
|
||||
let s = Store::<TestData>::new("test_key", db.clone());
|
||||
if s.read_with(|data| data.value) == 42 {
|
||||
store2 = Some(s);
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
);
|
||||
|
||||
// Load again to verify persistence
|
||||
let store2 = Store::<TestData>::new("test_key", db.clone());
|
||||
let store2 = store2.expect("Timed out waiting for async store persistence");
|
||||
assert_eq!(
|
||||
store2.read_with(|s| s.clone()),
|
||||
TestData {
|
||||
|
||||
@@ -3,7 +3,8 @@ use arboard::{Clipboard, ImageData};
|
||||
use std::borrow::Cow;
|
||||
|
||||
fn main() {
|
||||
let img = image::open("C:/Users/reazul.ashraf/workspace/rust/mcp-memory/test.jpg").unwrap_or_else(|_| image::DynamicImage::new_rgb8(10, 10));
|
||||
let img = image::open("C:/Users/reazul.ashraf/workspace/rust/mcp-memory/test.jpg")
|
||||
.unwrap_or_else(|_| image::DynamicImage::new_rgb8(10, 10));
|
||||
let rgba = img.into_rgba8();
|
||||
let (w, h) = rgba.dimensions();
|
||||
|
||||
|
||||
@@ -700,3 +700,28 @@ pub struct SemanticCodeSearchTool {
|
||||
pub struct ReadDirectoryArchitectureTool {
|
||||
pub directory: String,
|
||||
}
|
||||
|
||||
/// Create a point-in-time state snapshot of memory stores for safety.
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct CreateSnapshotTool {
|
||||
pub description: String,
|
||||
pub namespace: Option<String>,
|
||||
}
|
||||
|
||||
/// Restore a point-in-time state snapshot of memory stores.
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct RestoreSnapshotTool {
|
||||
pub snapshot_id: String,
|
||||
}
|
||||
|
||||
/// Create an isolated memory namespace for a subagent session.
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct CreateSubagentNamespaceTool {
|
||||
pub subagent_id: String,
|
||||
}
|
||||
|
||||
/// Purge an isolated subagent memory namespace after session completion.
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct PurgeSubagentNamespaceTool {
|
||||
pub subagent_id: String,
|
||||
}
|
||||
+74
-16
@@ -1,10 +1,12 @@
|
||||
use qdrant_client::qdrant::{CreateCollectionBuilder, Distance, PointStruct, VectorParamsBuilder, UpsertPointsBuilder};
|
||||
use qdrant_client::Qdrant;
|
||||
use std::sync::Arc;
|
||||
use std::error::Error;
|
||||
use uuid::Uuid;
|
||||
use tracing::info;
|
||||
use qdrant_client::qdrant::{
|
||||
CreateCollectionBuilder, Distance, PointStruct, UpsertPointsBuilder, VectorParamsBuilder,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::error::Error;
|
||||
use std::sync::Arc;
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct VectorDB {
|
||||
@@ -21,7 +23,10 @@ pub struct VectorSearchResult {
|
||||
}
|
||||
|
||||
impl VectorDB {
|
||||
pub async fn new(url: &str, collection_name: &str) -> Result<Self, Box<dyn Error + Send + Sync>> {
|
||||
pub async fn new(
|
||||
url: &str,
|
||||
collection_name: &str,
|
||||
) -> Result<Self, Box<dyn Error + Send + Sync>> {
|
||||
let client = Qdrant::from_url(url).build()?;
|
||||
|
||||
let db = Self {
|
||||
@@ -42,7 +47,7 @@ impl VectorDB {
|
||||
self.client
|
||||
.create_collection(
|
||||
CreateCollectionBuilder::new(&self.collection_name)
|
||||
.vectors_config(vector_params)
|
||||
.vectors_config(vector_params),
|
||||
)
|
||||
.await?;
|
||||
info!("Created Qdrant collection: {}", self.collection_name);
|
||||
@@ -69,10 +74,20 @@ impl VectorDB {
|
||||
}
|
||||
};
|
||||
|
||||
let mut payload: std::collections::HashMap<String, serde_json::Value> = std::collections::HashMap::new();
|
||||
payload.insert("doc_type".to_string(), serde_json::Value::String(doc_type.to_string()));
|
||||
payload.insert("text".to_string(), serde_json::Value::String(text.to_string()));
|
||||
payload.insert("original_id".to_string(), serde_json::Value::String(id.to_string()));
|
||||
let mut payload: std::collections::HashMap<String, serde_json::Value> =
|
||||
std::collections::HashMap::new();
|
||||
payload.insert(
|
||||
"doc_type".to_string(),
|
||||
serde_json::Value::String(doc_type.to_string()),
|
||||
);
|
||||
payload.insert(
|
||||
"text".to_string(),
|
||||
serde_json::Value::String(text.to_string()),
|
||||
);
|
||||
payload.insert(
|
||||
"original_id".to_string(),
|
||||
serde_json::Value::String(id.to_string()),
|
||||
);
|
||||
|
||||
let point = PointStruct::new(point_id, vector, payload);
|
||||
|
||||
@@ -90,26 +105,33 @@ impl VectorDB {
|
||||
) -> Result<Vec<VectorSearchResult>, Box<dyn Error + Send + Sync>> {
|
||||
use qdrant_client::qdrant::SearchPointsBuilder;
|
||||
|
||||
let search_result = self.client
|
||||
let search_result = self
|
||||
.client
|
||||
.search_points(
|
||||
SearchPointsBuilder::new(&self.collection_name, query_vector, limit)
|
||||
.with_payload(true)
|
||||
.with_payload(true),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
for point in search_result.result {
|
||||
let id = point.payload.get("original_id")
|
||||
let id = point
|
||||
.payload
|
||||
.get("original_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let doc_type = point.payload.get("doc_type")
|
||||
let doc_type = point
|
||||
.payload
|
||||
.get("doc_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let text = point.payload.get("text")
|
||||
let text = point
|
||||
.payload
|
||||
.get("text")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default();
|
||||
@@ -125,3 +147,39 @@ impl VectorDB {
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_vector_search_result_serialization() {
|
||||
let res = VectorSearchResult {
|
||||
id: "test-id".into(),
|
||||
doc_type: "snippet".into(),
|
||||
text: "fn main() {}".into(),
|
||||
score: 0.95,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&res).unwrap();
|
||||
assert!(json.contains("test-id"));
|
||||
assert!(json.contains("0.95"));
|
||||
|
||||
let deserialized: VectorSearchResult = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.id, "test-id");
|
||||
assert_eq!(deserialized.doc_type, "snippet");
|
||||
assert_eq!(deserialized.text, "fn main() {}");
|
||||
assert_eq!(deserialized.score, 0.95);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deterministic_uuid_hashing() {
|
||||
let id_str = "custom-non-uuid-string";
|
||||
let namespace = Uuid::NAMESPACE_OID;
|
||||
let uuid1 = Uuid::new_v5(&namespace, id_str.as_bytes()).to_string();
|
||||
let uuid2 = Uuid::new_v5(&namespace, id_str.as_bytes()).to_string();
|
||||
|
||||
assert_eq!(uuid1, uuid2);
|
||||
assert!(Uuid::parse_str(&uuid1).is_ok());
|
||||
}
|
||||
}
|
||||
+27
-2
@@ -1,8 +1,8 @@
|
||||
use crate::state::MemoryState;
|
||||
use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use crate::state::MemoryState;
|
||||
use tracing::{info, error};
|
||||
use tracing::{error, info};
|
||||
|
||||
pub fn spawn_watcher(_state: Arc<MemoryState>) {
|
||||
let watch_path = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||
@@ -68,3 +68,28 @@ async fn trigger_autonomous_review(path: &Path) {
|
||||
// MVP: Just write an info log if Neovim isn't reachable.
|
||||
info!("Autonomous review complete for {:?}", path);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_should_review() {
|
||||
assert!(should_review(Path::new("src/lib.rs")));
|
||||
assert!(should_review(Path::new("README.md")));
|
||||
assert!(should_review(Path::new("Cargo.toml")));
|
||||
assert!(should_review(Path::new("init.lua")));
|
||||
|
||||
assert!(!should_review(Path::new("target/debug/app.exe")));
|
||||
assert!(!should_review(Path::new(".git/HEAD")));
|
||||
assert!(!should_review(Path::new("data.json")));
|
||||
assert!(!should_review(Path::new("image.png")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_spawn_watcher_lifecycle() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let state = Arc::new(MemoryState::new(temp_dir.path().to_str().unwrap()));
|
||||
spawn_watcher(state);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mcp-memory-stub"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
@@ -15,7 +15,7 @@ tracing = "0.1.44"
|
||||
tracing-subscriber = "0.3.23"
|
||||
dirs = "7.0.0"
|
||||
serde_json = "1.0.151"
|
||||
mcp-stdio = { version = "0.1.0", path = "../mcp-stdio" }
|
||||
mcp-stdio = { version = "1.0.0", path = "../mcp-stdio" }
|
||||
regex = "1.13.1"
|
||||
async-channel = "2.5.0"
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mcp-memory-win-nvim"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
Reference in new issue
Block a user