From 79209da7113a5ebce38e35cf3a3ddac8d5c13547 Mon Sep 17 00:00:00 2001 From: Riz Ashraf Date: Wed, 7 Oct 2026 11:12:42 +0100 Subject: [PATCH] refactor: eradicate deprecated tools (sticky_notes, pinned_files, context_workspaces, pr_checklist, preferences) and dead code --- AI_INTEGRATION_STRATEGY.md | 8 +- ARCHITECTURE_RESOURCES_PROMPTS.md | 2 +- EFFECTIVE_DISCOURSE.md | 12 +- README.md | 11 +- agent-rules/mcp_memory_workflow.md | 4 +- design.md | 10 +- instructions.md | 29 +- nvim-core/src/headless.rs | 37 + nvim-core/src/lib.rs | 55 ++ server/src/api/events.rs | 8 +- server/src/api/routes.txt | 10 +- server/src/api/setup.rs | 44 +- server/src/db.rs | 5 - server/src/embedding.rs | 12 +- server/src/handlers/ast.rs | 113 ++- server/src/handlers/env.rs | 257 ++--- server/src/handlers/graph.rs | 217 +++-- server/src/handlers/meta.rs | 1046 +++++--------------- server/src/handlers/notes.rs | 157 +-- server/src/handlers/tasks.rs | 23 +- server/src/handlers/vision.rs | 414 ++++---- server/src/handlers/workspaces.rs | 1443 +++++----------------------- server/src/instructions.md | 29 +- server/src/lib.rs | 46 +- server/src/models.rs | 76 +- server/src/router.rs | 233 +---- server/src/search.rs | 45 +- server/src/state.rs | 25 - server/src/store.rs | 34 +- server/src/tools.rs | 741 +------------- stub/src/main.rs | 4 +- 31 files changed, 1230 insertions(+), 3920 deletions(-) diff --git a/AI_INTEGRATION_STRATEGY.md b/AI_INTEGRATION_STRATEGY.md index a379576..6ea7824 100644 --- a/AI_INTEGRATION_STRATEGY.md +++ b/AI_INTEGRATION_STRATEGY.md @@ -17,8 +17,8 @@ To maximize efficiency, we split interactions into two categories: **Synchronous ### A. Synchronous Rules (The Primary Loop) The main LLM interacting with the user should be constrained by global system rules to ensure basic context synchronization. These actions must happen synchronously so the main agent never loses the plot. -* **Context Initialization (`list_active_tasks`, `list_pinned_files`):** Executed when a session starts. This gives the LLM immediate awareness of the current workflow. -* **Context Switching (`save_context_workspace`, `load_context_workspace`):** Executed when moving between branches or large features. This prevents context bleed between disparate tasks. +* **Context Initialization (`tasks` list, `get_preflight_context`):** Executed when a session starts. This gives the LLM immediate awareness of the current workflow. +* **Context Switching (`manage_checkpoint`):** Executed when moving between branches or large features. This prevents context bleed between disparate tasks. * **End-of-Day Handoff (`add_session_summary`, `generate_standup_report`):** Triggered when the user logs off, seamlessly serializing the mental state of the LLM for tomorrow. ### B. Subagent Orchestration (The Background Team) @@ -43,7 +43,7 @@ Heavy or verbose interactions with the MCP server are delegated to specialized b The `mcp-memory` server is a distinct background process (typically port 3000). The LLM ecosystem must handle server downtime gracefully: 1. **Event Webhooks:** If the server goes down, waiting webhook tasks (e.g., waiting for a user to save a file in Neovim) will drop. These *do not* self-heal. The LLM must recognize the dropped connection and prompt the user to retry the action. -2. **Persistent Storage:** Data (tasks, graph, pins) is persisted to `mcp_store.redb`. When the server comes back online, no data is lost. The LLM can immediately resume querying. +2. **Persistent Storage:** Data (tasks, graph, ledger) is persisted to `mcp_store.redb`. When the server comes back online, no data is lost. The LLM can immediately resume querying. 3. **Subagent Fast-Failing:** If the `MemoryLibrarian` attempts to log a change while the server is offline, it will instantly fail. It is designed to abandon the background task and notify the primary agent. To recover, the primary agent can manually re-invoke the Librarian once the connection is restored, instructing it to analyze recent commits to backfill the graph. ## Conclusion @@ -57,7 +57,7 @@ The MCP protocol exposes three primary primitives. To prevent LLM confusion and * **LLM Awareness:** The LLM must not use tools to repeatedly poll for state changes. Tools represent active, expensive computing steps. ### B. Resources (For Passive Awareness) -* **When to use:** Use URIs (e.g., memory://tasks/active, memory://pinned_files) to read holistic project state. +* **When to use:** Use URIs (e.g., memory://tasks/active, memory://session/delta) to read holistic project state. * **LLM Awareness:** The client integration should map these URIs to the LLM's context window. Instead of the LLM invoking a list_active_tasks tool (which costs a round-trip), the LLM should simply read the memory://tasks/active resource content if it needs to know what to do next. Resources are for passive, zero-cost reading. ### C. Prompts (For Macro-Workflows) diff --git a/ARCHITECTURE_RESOURCES_PROMPTS.md b/ARCHITECTURE_RESOURCES_PROMPTS.md index b0d905c..acd39b8 100644 --- a/ARCHITECTURE_RESOURCES_PROMPTS.md +++ b/ARCHITECTURE_RESOURCES_PROMPTS.md @@ -71,7 +71,7 @@ pub trait McpPrompt: Send + Sync { ## 4. Execution Plan 1. **Refactor `router.rs` (No functional changes yet):** Define the `McpResource` and `McpPrompt` traits. Update the `MemoryHandler` struct to hold these HashMaps. Migrate the existing hardcoded stubs (`memory://graph/entities` and `analyze_tech_debt`) into structs implementing these traits. -2. **Expand Resources (Phase 1):** Add new handlers for `memory://tasks/active`, `memory://pinned_files`, etc. +2. **Expand Resources (Phase 1):** Add new handlers for `memory://tasks/active`, `memory://session/delta`, etc. 3. **Expand Prompts (Phase 2):** Add new handlers for `handoff_routine`, etc. This design guarantees we do not needlessly tear up code—we merely extend the existing robust `McpTool` pattern to the rest of the protocol. diff --git a/EFFECTIVE_DISCOURSE.md b/EFFECTIVE_DISCOURSE.md index 76de67e..2b50cb2 100644 --- a/EFFECTIVE_DISCOURSE.md +++ b/EFFECTIVE_DISCOURSE.md @@ -87,9 +87,9 @@ When the LLM is first analyzing a repository, don't let it run `ls -R` and guess ## 11. Graph, Memory & Casing Standards Actively instruct the LLM to maintain its memory constraints and use canonical casing. * **Do say:** "Log this architectural decision in the knowledge graph using PascalCase for entity types." -* **Do say:** "Add a sticky note with the test database credentials for this session with a 1-hour TTL." +* **Do say:** "Leave a handoff memo with the test database credentials and session action items." * **Do say:** "Create a milestone for the 'Rich Clipboard' feature and break it down into active tasks." -* **Tools Triggered:** `create_entities`, `decisions` (log), `sticky_notes` (add), `milestones` (add), `tasks` (add) +* **Tools Triggered:** `create_entities`, `decisions` (log), `handoff_memos` (leave), `milestones` (add), `tasks` (add) --- @@ -121,10 +121,10 @@ Actively instruct the LLM to maintain its memory constraints and use canonical c --- -## 16. Context Workspace Diffing -* **Don't say:** "What is the difference between my feature workspace and the main workspace?" -* **Do say:** "Diff context workspaces between 'feature-branch' and 'main'." -* **Tool Triggered:** `diff_context_workspaces` +## 16. State Checkpoints & Rollbacks +* **Don't say:** "Save a backup snapshot before we do this refactor." +* **Do say:** "Create a checkpoint named 'pre-refactor' before editing the database layer." +* **Tool Triggered:** `manage_checkpoint` --- diff --git a/README.md b/README.md index 342d443..2160428 100644 --- a/README.md +++ b/README.md @@ -29,20 +29,16 @@ To prevent graph fragmentation and ensure optimal LLM tokenization and retrieval ## 🛠️ Consolidated Smart MCP Tools -The server consolidates granular single-purpose tools into 12 concise, action-oriented smart domain handlers with zero prefix clutter: +The server consolidates granular single-purpose tools into 8 concise, action-oriented smart domain handlers with zero prefix clutter: * **`tasks`**: Complete task lifecycle management (`add`, `update`, `delete`, `list`, `set_criteria`, `verify`). * **`milestones`**: Milestone tracking (`add`, `update`, `list`). -* **`sticky_notes`**: Ephemeral scratchpad notes with TTL (`add`, `read`, `delete`, `clear`). * **`handoff_memos`**: Cross-session handoff notes (`leave`, `read`, `clear`). -* **`pinned_files`**: Working set file focus management (`pin`, `unpin`, `list`). -* **`context_workspaces`**: Workspace context state snapshots (`save`, `load`, `list`, `delete`, `diff`). -* **`pr_checklist`**: Pre-commit and PR checklist management (`add`, `get`, `clear`). * **`snippets`**: Reusable code snippet vault with BM25+Vector search (`store`, `search`, `delete`, `tag`). * **`decisions`**: Architectural Decision Records (ADRs) (`log`, `query`, `delete`). * **`tech_debt`**: Engineering technical debt backlog (`log`, `resolve`, `list`). * **`environment`**: Infrastructure & tool fingerprints tracking (`update_fingerprint`, `read_fingerprint`, `log_requirement`, `register`, `get_details`). -* **`clipboard`**: Cross-OS clipboard management (`read`, `write`, `toggle_watch`). +* **`clipboard`**: Cross-OS clipboard management (`read`, `write`). --- @@ -86,9 +82,6 @@ Evaluates task dependency graphs and returns unblocked, ready-to-run tasks for s ### 🧠 Chain-of-Thought & Diagnostic Hypothesis Memory (`log_hypothesis` / `query_hypotheses`) Records structured diagnostic hypotheses, test evidence, and verification statuses to preserve reasoning across sessions. -### 🔀 Context Workspace Diffing (`context_workspaces`) -Computes structured diffs of pinned files and active task IDs between two saved context workspaces. - ### 🔒 Resilient Storage & Serde Parameter Tolerances * **Explicit Fail-Fast Persistence Safety**: Replaced unsafe silent fallback to temporary databases (`/tmp/mcp_store_fallback_*`) with an explicit open retry and fail-fast panic unless `MCP_ALLOW_TMP_FALLBACK=1` is explicitly set, preventing silent data loss. * **Store Write Lock Minimization**: Releases write lock immediately following in-memory mutation, serializing JSON payloads under read locks to allow non-blocking concurrent readers. diff --git a/agent-rules/mcp_memory_workflow.md b/agent-rules/mcp_memory_workflow.md index f0e2ff6..c458e20 100644 --- a/agent-rules/mcp_memory_workflow.md +++ b/agent-rules/mcp_memory_workflow.md @@ -16,8 +16,8 @@ description: Strict guidelines for interacting with the mcp-memory server, ensur > 2. **Tier 2 (Structured DB & Telemetry)**: The MCP Memory server specializes in high-volume, dynamic data: file modification ledgers (`audit_ledger`), terminal command history, error resolutions (`log_error_fix`), active tasks, and preflight context aggregation. The MCP Memory server is the central brain. You must be PROACTIVE, not reactive, in using it: -- **Session Starts & Context Drops**: Always begin by calling `tasks` (action: "list"), `pinned_files` (action: "list"), and `sticky_notes` (action: "read"). -- **Sticky Notes**: Use `sticky_notes` (action: "add") for transient, session-scoped operational constraints (e.g., "Do not touch file X until Y is done"). Deletion supports both 1-based index (standard) and 0-based index 0. +- **Session Starts & Context Drops**: Always begin by calling `tasks` (action: "list"), `get_preflight_context`, and `omni_search` to regain context. +- **Context Switching**: When switching tasks or branches, use `manage_checkpoint` (action: "create") to freeze state, and use `manage_checkpoint` (action: "restore") to restore state for the task. - **Error Fixes**: The moment a tricky, undocumented, or environment-specific bug is resolved (e.g., Bitbucket markdown rendering quirks, nuanced framework bugs), IMMEDIATELY call `log_error_fix`. Supply `repo_name`, `error_category`, and `stack_trace` so future searches can perform embedding-based match. - **Tech Debt**: If you notice an anti-pattern (e.g., nested `if` statements, arrow anti-pattern) but deliberately skip fixing it to focus on a feature, IMMEDIATELY call `tech_debt` (action: "log") with `description`, `file_path`, `line_range`, `workaround`, `effort_estimate`, and `severity`. - **Architectural Decisions (ADR)**: When selecting design patterns, crate choices, or system structure, call `decisions` (action: "log") with `author`, `affected_components`, `alternatives_considered`, `decision`, and `consequence`. diff --git a/design.md b/design.md index 342dbaf..2d2a3a8 100644 --- a/design.md +++ b/design.md @@ -90,11 +90,11 @@ The physical storage location of the knowledge graph and all persistent stores i ## 10. Exposed MCP Capabilities (Tools) The server implements the Model Context Protocol (MCP) by exposing a vast suite of tools via the JSON-RPC interface, categorized broadly into: * **Graph Management:** create_entities, create_relations, merge_entities, -ead_graph, etc. -* **Task & Context Tracking:** add_task, add_sticky_note, add_session_summary, etc. -* **Engineering & DevOps:** log_code_change, log_error_fix, log_tech_debt, add_pr_checklist_item. -* **Environment & Workspaces:** -egister_environment, save_context_workspace, pin_file. +read_graph, etc. +* **Task & Context Tracking:** tasks, milestones, handoff_memos, add_session_summary, etc. +* **Engineering & DevOps:** log_code_change, log_error_fix, tech_debt, decisions, etc. +* **Environment & Workspaces:** environment, manage_checkpoint, manage_subagent_namespace, snippets, etc. + ## 11. Concurrency & Thread Safety With the introduction of the Dual Transport System, the daemon must safely handle simultaneous read/write requests from both Stdio (Windows agy) and HTTP (WSL agy) clients. diff --git a/instructions.md b/instructions.md index 3d27022..d91ab51 100644 --- a/instructions.md +++ b/instructions.md @@ -41,34 +41,11 @@ The server consolidates granular single-purpose tools into domain-named smart to - `action: "update"`: Update milestone status (requires `id`, `status: "active" | "completed" | "cancelled"`). - `action: "list"`: List milestones (optional `namespace`). -* **`sticky_notes`**: Ephemeral scratchpad notes with TTL. - - `action: "add"`: Add note (requires `content`, optional `ttl_seconds`, `session_only`). - - `action: "read"`: Read all active notes. - - `action: "delete"`: Delete note by index (requires 1-based `index`). - - `action: "clear"`: Clear all sticky notes. - * **`handoff_memos`**: Session handoff notes for future agents. - `action: "leave"`: Leave a memo (requires `content`, optional `vcs_revision`, `repo_name`, `git_branch`, `blockers: Vec`, `action_items: Vec`, `expires_at`). - `action: "read"`: Read active handoff memos. - `action: "clear"`: Clear memos. -* **`pinned_files`**: Focus file working set. - - `action: "pin"`: Pin file to focus set (requires `path`, optional `pin_reason`, `symbol_or_line`, `repo_name`, `git_branch`). - - `action: "unpin"`: Unpin file from focus set (requires `path`). - - `action: "list"`: List pinned files (optional `namespace`). - -* **`context_workspaces`**: Workspace context state snapshots. - - `action: "save"`: Save context workspace (requires `name`, optional `pinned_files`, `active_task_ids`, `description`, `git_branch`, `vcs_revision`, `repo_name`). - - `action: "load"`: Restore saved context workspace (requires `name`). - - `action: "list"`: List saved context workspaces. - - `action: "delete"`: Delete saved context workspace (requires `name`). - - `action: "diff"`: Compare two saved context workspaces (requires `name`, `other_name`). - -* **`pr_checklist`**: Pre-commit and PR checklist. - - `action: "add"`: Add checklist item (requires `description`, optional `category: "Security" | "Testing" | "Formatting" | ...`, `automated_check`, `is_checked`, `repo_name`). - - `action: "get"`: Get PR checklist items. - - `action: "clear"`: Clear PR checklist. - * **`snippets`**: Reusable code snippet vault. - `action: "store"`: Store snippet (requires `query` as name, optional `language`, `code`, `description`, `tags`, `origin_file`, `line_range`, `repo_name`). - `action: "search"`: Search snippet vault (optional `query`, `tags`, `hybrid: true`). @@ -95,7 +72,6 @@ The server consolidates granular single-purpose tools into domain-named smart to * **`clipboard`**: OS Clipboard management. - `action: "read"`: Read OS clipboard. - `action: "write"`: Write text/html/files/image to clipboard. - - `action: "toggle_watch"`: Toggle auto-clipboard watcher. --- @@ -194,9 +170,8 @@ To maintain maximum security, speed, and cross-platform reliability: * **Zero-Allocation Stream Formatting**: Graph condensation loops (`condense_graph_worker`) format node/relation subgraphs into stream buffers via `std::fmt::Write`, eliminating intermediate String allocation overhead. * **Single-Handle OS Clipboard Retries & Image Downsampling**: `WriteClipboardHandler` initializes OS clipboard handles once per operation and downsamples images exceeding $2048 \times 2048$ resolution before writing raw RGBA bytes. * **Zero Transaction Drop Persistence Guarantee**: `Store::modify` automatically spawns an async task to execute `push_async` with channel backpressure if `push` encounters queue saturation, ensuring zero data loss under spike write loads. -* **Non-Blocking Read Lock Sticky Notes**: `StickyNotesHandler` (`action = "read"`) queries notes using shared read locks, executing write pruning only when expired items exist. * **Token-Budgeted Query Projections**: Decision queries (`query_decisions`) support `limit` caps and compact `include_body: false` projections for token budget optimization. -* **Serde Parameter & Enum Ergonomics**: Action enums (`StickyNoteAction`, `SnippetSearchMode`, `Relation`) support case-insensitive variants and common synonyms (`create`/`add`, `remove`/`delete`, `list`/`read`, `source`/`from`, `target`/`to`, `relationType`/`relation_type`) ensuring seamless LLM tool execution. +* **Serde Parameter & Enum Ergonomics**: Action enums (`SnippetSearchMode`, `Relation`) support case-insensitive variants and common synonyms (`create`/`add`, `remove`/`delete`, `list`/`read`, `source`/`from`, `target`/`to`, `relationType`/`relation_type`) ensuring seamless LLM tool execution. * **Atomic Search Index Swaps**: `MemoryState::rebuild_index` constructs and populates a new `MemoryIndex` instance in isolation before performing an atomic pointer swap (`*self.search_index.write().await = new_idx`), eliminating transient empty search result windows. * **Non-Blocking Tantivy Search Queries**: `MemoryIndex::search` queries current index searcher snapshots without executing synchronous disk commit locks, preventing query stalls during heavy background indexing. * **Zero-Allocation HashSet<&str> Snippet Deduplication**: `indexer.rs` utilizes borrowed `HashSet<&str>` name lookups during snippet batch modifications, eliminating heap string re-allocations inside the store write lock. @@ -219,7 +194,7 @@ To maintain maximum security, speed, and cross-platform reliability: * **Embedding Input Safeguard**: `generate_embedding_async` returns explicit errors for empty/0-length text inputs instead of returning empty vectors, preventing downstream vector dimension mismatches during cosine similarity calculations. * **Path Traversal Security Guards**: `validate_safe_path` enforces path canonicalization and rejects relative parent traversal components (`..`) across file and process log handlers (`GetRecentLogsTool`, `WatchProcessLogsTool`). * **Watcher Map Memory Eviction**: Proactive daemon file watcher in `watcher.rs` caps `last_processed` map size at 1,000 entries and purges entries older than 10 minutes to prevent monotonic memory leakage. -* **Comprehensive Serde Casing Aliases**: All 11 consolidated tool action enums (`TaskAction`, `MilestoneAction`, `PinnedFileAction`, `ContextWorkspaceAction`, `PrChecklistAction`, `SnippetAction`, `DecisionAction`, `TechDebtAction`, `EnvAction`, `ClipboardAction`, `HandoffMemoAction`) include serde alias attributes supporting `snake_case`, `camelCase`, `PascalCase`, and uppercase variants for maximum LLM casing resilience. +* **Comprehensive Serde Casing Aliases**: All 8 consolidated tool action enums (TaskAction, MilestoneAction, SnippetAction, DecisionAction, TechDebtAction, EnvAction, ClipboardAction, HandoffMemoAction) include serde alias attributes supporting `snake_case`, `camelCase`, `PascalCase`, and uppercase variants for maximum LLM casing resilience. * **Two-Phase Graph Condensation**: `condense_graph_worker` uses a 2-phase commit (non-destructive `read_with` -> graph insert -> prune by timestamp/content) to prevent data loss if summarization or graph insertion fails. * **Store Write Lock Minimization**: `Store::modify` and `Store::modify_async` unblock concurrent readers during JSON serialization by releasing the write lock immediately after mutating memory state. * **Redb Database Lock Retry Backoff**: `init_db` retries transient Redb lock contention with exponential backoff (3 attempts, 150ms delay) before falling back. diff --git a/nvim-core/src/headless.rs b/nvim-core/src/headless.rs index 4cb7cce..ab5ee6c 100644 --- a/nvim-core/src/headless.rs +++ b/nvim-core/src/headless.rs @@ -8,6 +8,31 @@ pub static ACTIVE_SOCKET: LazyLock>>> = pub static HEADLESS_PROC: LazyLock>>> = LazyLock::new(|| Arc::new(tokio::sync::Mutex::new(None))); +unsafe extern "C" { + fn atexit(f: unsafe extern "C" fn()) -> i32; +} + +static HEADLESS_PID: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); +static ONCE_INIT: std::sync::Once = std::sync::Once::new(); + +unsafe extern "C" fn cleanup_on_exit() { + let pid = HEADLESS_PID.swap(0, std::sync::atomic::Ordering::SeqCst); + if pid != 0 { + #[cfg(windows)] + { + let _ = std::process::Command::new("taskkill") + .args(["/F", "/T", "/PID", &pid.to_string()]) + .output(); + } + #[cfg(unix)] + { + let _ = std::process::Command::new("kill") + .args(["-9", &pid.to_string()]) + .output(); + } + } +} + pub async fn spawn_headless_nvim() -> Result { if std::process::Command::new("nvim") .arg("--version") @@ -17,6 +42,10 @@ pub async fn spawn_headless_nvim() -> Result { return Err("Error: Neovim is not installed or not in PATH. Cannot execute nvim tool. Fallback to basic terminal tools.".to_string()); } + ONCE_INIT.call_once(|| unsafe { + atexit(cleanup_on_exit); + }); + #[cfg(windows)] let socket_name = format!( r"\\.\pipe\agy-headless-nvim-{}-{}", @@ -49,6 +78,10 @@ pub async fn spawn_headless_nvim() -> Result { .spawn() .map_err(|e| format!("Failed to spawn headless Neovim: {}", e))?; + if let Some(id) = child.id() { + HEADLESS_PID.store(id, std::sync::atomic::Ordering::SeqCst); + } + if let Some(stdout) = child.stdout.take() { let mut reader = tokio::io::BufReader::new(stdout); let mut ready_line = String::new(); @@ -60,6 +93,7 @@ pub async fn spawn_headless_nvim() -> Result { { Ok(Ok(_)) => { if ready_line.trim() != "READY" { + HEADLESS_PID.store(0, std::sync::atomic::Ordering::SeqCst); let _ = child.kill().await; return Err(format!( "Headless Neovim child output unexpected banner: {:?}", @@ -68,6 +102,7 @@ pub async fn spawn_headless_nvim() -> Result { } } Ok(Err(e)) => { + HEADLESS_PID.store(0, std::sync::atomic::Ordering::SeqCst); let _ = child.kill().await; return Err(format!( "Failed to read readiness from headless Neovim: {}", @@ -75,6 +110,7 @@ pub async fn spawn_headless_nvim() -> Result { )); } Err(_) => { + HEADLESS_PID.store(0, std::sync::atomic::Ordering::SeqCst); let _ = child.kill().await; return Err("Timeout waiting for headless Neovim to become ready".to_string()); } @@ -96,6 +132,7 @@ pub async fn spawn_headless_nvim() -> Result { } pub async fn kill_headless_nvim() { + HEADLESS_PID.store(0, std::sync::atomic::Ordering::SeqCst); let child_to_kill = { let mut proc_lock = HEADLESS_PROC.lock().await; proc_lock.take() diff --git a/nvim-core/src/lib.rs b/nvim-core/src/lib.rs index ac47296..d156735 100644 --- a/nvim-core/src/lib.rs +++ b/nvim-core/src/lib.rs @@ -167,6 +167,43 @@ async fn get_socket_path() -> Result { } Err("Could not find Neovim socket".to_string()) } + +#[cfg(windows)] +async fn read_active_socket_file() -> Result { + let profile = dirs::home_dir() + .map(|p| p.to_string_lossy().to_string()) + .or_else(|| std::env::var("USERPROFILE").ok()) + .ok_or_else(|| "Could not determine user home directory".to_string())?; + let path = format!("{profile}\\.gemini\\active_nvim.txt"); + if let Ok(content) = tokio::fs::read_to_string(&path).await { + let p = content.trim().to_string(); + if !p.is_empty() { + let candidate = if p.starts_with(r"\\.\pipe\") { + p + } else if p.starts_with("nvim.") { + format!(r"\\.\pipe\{p}") + } else { + p + }; + return Ok(candidate); + } + } + Err("Missing active_nvim.txt".to_string()) +} + +#[cfg(unix)] +async fn read_active_socket_file() -> Result { + if let Some(home) = dirs::home_dir() { + let path = home.join(".gemini/active_nvim.txt"); + if let Ok(content) = tokio::fs::read_to_string(&path).await { + let p = content.trim().to_string(); + if !p.is_empty() { + return Ok(p); + } + } + } + Err("Missing active_nvim.txt".to_string()) +} use std::sync::Arc; use std::sync::LazyLock; use tokio::sync::{mpsc, oneshot}; @@ -222,6 +259,24 @@ fn handle_nvim_notification(params: &[rmpv::Value]) { } async fn get_nvim_connection() -> Result, String> { + // Fast path: If NVIM_CONN is already active and healthy, check if active_nvim.txt matches + // without spawning any external nvim.exe child process! + { + let conn_lock = NVIM_CONN.lock().await; + if let Some(sender) = conn_lock.as_ref() { + if !sender.is_closed() { + let active_lock = ACTIVE_SOCKET.lock().await; + if let Some(ref current_socket) = *active_lock { + if let Ok(target) = read_active_socket_file().await { + if !target.is_empty() && &target == current_socket { + return Ok(sender.clone()); + } + } + } + } + } + } + let interactive_socket = get_socket_path().await.ok(); let mut should_kill_headless = false; diff --git a/server/src/api/events.rs b/server/src/api/events.rs index 183ab7f..583b539 100644 --- a/server/src/api/events.rs +++ b/server/src/api/events.rs @@ -26,11 +26,15 @@ pub async fn wait_for_event_handler( return axum::Json(event); } } - Err(_) => { + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + tracing::warn!("Event bus receiver lagged by {} messages; continuing wait.", skipped); + continue; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { return axum::Json(GenericEvent { topic: "error".to_string(), session_id: None, - payload: serde_json::json!({"error": "Event bus lagged or closed"}), + payload: serde_json::json!({"error": "Event bus closed"}), }); } } diff --git a/server/src/api/routes.txt b/server/src/api/routes.txt index 3ff9c34..e27e454 100644 --- a/server/src/api/routes.txt +++ b/server/src/api/routes.txt @@ -237,18 +237,14 @@ async fn run_server(state: Arc) -> Result<(), Box) -> Result<(), Box) -> Router { } }), ) - .route( - "/api/sticky", - get({ - let state_clone = app_state.handler.state.clone(); - move || async move { - let sticky_json = state_clone.code.sticky.read_with(|s| serde_json::to_string(s).unwrap_or_else(|_| "[]".to_string())); - ([(axum::http::header::CONTENT_TYPE, "application/json")], sticky_json) - } - }), - ) + .route( "/api/search", get({ @@ -264,16 +255,6 @@ pub fn create_router(app_state: Arc) -> Router { } }), ) - .route( - "/api/context_workspaces", - get({ - let state_clone = app_state.handler.state.clone(); - move || async move { - let json = state_clone.project.context_workspaces.read_with(|items| serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string())); - ([(axum::http::header::CONTENT_TYPE, "application/json")], json) - } - }), - ) .route( "/api/handoff_memos", get({ @@ -304,16 +285,6 @@ pub fn create_router(app_state: Arc) -> Router { } }), ) - .route( - "/api/pr_checklists", - get({ - let state_clone = app_state.handler.state.clone(); - move || async move { - let json = state_clone.project.pr_checklists.read_with(|items| serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string())); - ([(axum::http::header::CONTENT_TYPE, "application/json")], json) - } - }), - ) .route( "/api/error_fixes", get({ @@ -336,18 +307,14 @@ pub fn create_router(app_state: Arc) -> Router { let adrs = state_clone.code.adrs.read_with(|items| items.len()); let ledger = state_clone.code.ledger.read_with(|items| items.len()); - let sticky = state_clone.code.sticky.read_with(|items| items.len()); let error_fixes = state_clone.code.error_fixes.read_with(|items| items.len()); - let pinned_files = state_clone.project.pinned_files.read_with(|items| items.len()); let session_summaries = state_clone.telemetry.session_summaries.read_with(|items| items.len()); let handoff_memos = state_clone.telemetry.handoff_memos.read_with(|items| items.len()); let env_fingerprints = state_clone.env.env_fingerprints.read_with(|items| items.len()); let env_requirements = state_clone.env.env_requirements.read_with(|items| items.len()); let milestones = state_clone.project.milestones.read_with(|items| items.len()); let environments = state_clone.env.environments.read_with(|items| items.len()); - let pr_checklists = state_clone.project.pr_checklists.read_with(|items| items.len()); let gates = state_clone.env.gates.read_with(|items| items.len()); - let context_workspaces = state_clone.project.context_workspaces.read_with(|items| items.len()); axum::Json(serde_json::json!({ "entities": entities, @@ -357,18 +324,14 @@ pub fn create_router(app_state: Arc) -> Router { "tech_debts": tech_debts, "adrs": adrs, "ledger": ledger, - "sticky": sticky, "error_fixes": error_fixes, - "pinned_files": pinned_files, "session_summaries": session_summaries, "handoff_memos": handoff_memos, "env_fingerprints": env_fingerprints, "env_requirements": env_requirements, "milestones": milestones, "environments": environments, - "pr_checklists": pr_checklists, - "gates": gates, - "context_workspaces": context_workspaces + "gates": gates })) } }), @@ -640,15 +603,12 @@ mod tests { "/", "/api/graph", "/api/tasks", - "/api/sticky", "/api/search?q=test", "/api/tech_debts", "/api/adrs", - "/api/context_workspaces", "/api/handoff_memos", "/api/milestones", "/api/snippets", - "/api/pr_checklists", "/api/error_fixes", "/api/stats", ]; diff --git a/server/src/db.rs b/server/src/db.rs index c87a2d2..1dee77e 100644 --- a/server/src/db.rs +++ b/server/src/db.rs @@ -127,23 +127,18 @@ pub fn init_redb(base: &Path) -> Arc { let stores = vec![ ("knowledge_graph_master", "knowledge_graph_master.json"), ("audit_ledger", "audit_ledger.json"), - ("sticky_notes", "sticky_notes.json"), ("tasks", "tasks.json"), ("snippets", "snippets.json"), ("adrs", "adrs.json"), - ("preferences", "preferences.json"), ("error_fixes", "error_fixes.json"), - ("pinned_files", "pinned_files.json"), ("session_summaries", "session_summaries.json"), ("handoff_memos", "handoff_memos.json"), ("env_fingerprints", "env_fingerprints.json"), ("env_requirements", "env_requirements.json"), ("milestones", "milestones.json"), ("environments", "environments.json"), - ("pr_checklists", "pr_checklists.json"), ("tech_debts", "tech_debts.json"), ("gates", "gates.json"), - ("context_workspaces", "context_workspaces.json"), ("state_snapshots", "state_snapshots.json"), ("hypotheses", "hypotheses.json"), ("agent_signals", "agent_signals.json"), diff --git a/server/src/embedding.rs b/server/src/embedding.rs index 6eba155..47e2bf5 100644 --- a/server/src/embedding.rs +++ b/server/src/embedding.rs @@ -280,12 +280,16 @@ pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { norm_b_sq += y * y; } - let norm_a = norm_a_sq.sqrt(); - let norm_b = norm_b_sq.sqrt(); - if norm_a == 0.0 || norm_b == 0.0 { + // Fast path: If vectors are already normalized (Candle & fallback embeddings), skip square roots + if (norm_a_sq - 1.0).abs() < 1e-4 && (norm_b_sq - 1.0).abs() < 1e-4 { + return dot_product.clamp(-1.0, 1.0); + } + + let norm_product = norm_a_sq * norm_b_sq; + if norm_product <= 0.0 { 0.0 } else { - dot_product / (norm_a * norm_b) + (dot_product / norm_product.sqrt()).clamp(-1.0, 1.0) } } diff --git a/server/src/handlers/ast.rs b/server/src/handlers/ast.rs index 499fe33..ff0d7e2 100644 --- a/server/src/handlers/ast.rs +++ b/server/src/handlers/ast.rs @@ -81,27 +81,64 @@ impl McpTool for ReadFileSkeletonHandler { return; } let kind = node.kind(); - - let is_structural = matches!( + let is_container = 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" + "impl_item" | "class_declaration" | "class_definition" + | "trait_item" + | "interface_declaration" ); - if is_structural { + let is_structural = is_container + || matches!( + kind, + "use_declaration" + | "import_statement" + | "import_from_statement" + | "struct_item" + | "enum_item" + | "function_item" + | "function_declaration" + | "function_definition" + | "method_definition" + | "type_alias_declaration" + ); + + if is_container { + let indent = " ".repeat(depth); + let node_text = node.utf8_text(code.as_bytes()).unwrap_or(""); + let mut header = String::new(); + for line in node_text.lines() { + let trimmed = line.trim(); + if trimmed.ends_with('{') || trimmed.ends_with(':') { + header.push_str(line); + break; + } else { + header.push_str(line); + header.push('\n'); + } + } + if header.is_empty() { + if let Some(first_line) = node_text.lines().next() { + header = first_line.to_string(); + } + } + + out.push_str(&indent); + out.push_str(header.trim()); + out.push('\n'); + + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + extract_skeleton(child, code, out, depth + 1); + } + + if header.trim().ends_with('{') { + out.push_str(&indent); + out.push_str("}\n"); + } + } else if is_structural { let indent = " ".repeat(depth); let node_text = node.utf8_text(code.as_bytes()).unwrap_or(""); @@ -127,7 +164,7 @@ impl McpTool for ReadFileSkeletonHandler { } else if node.is_named() { let mut cursor = node.walk(); for child in node.named_children(&mut cursor) { - extract_skeleton(child, code, out, depth + 1); + extract_skeleton(child, code, out, depth); } } } @@ -231,6 +268,7 @@ impl McpTool for ReplaceAstNodeHandler { actual_kind, "trait_item" | "interface_declaration" | "interface_item" ), + "impl" | "impl_item" => actual_kind == "impl_item", "type" | "type_alias" => matches!( actual_kind, "type_alias_declaration" | "type_item" | "type_definition" @@ -253,6 +291,7 @@ impl McpTool for ReplaceAstNodeHandler { let kind = child.kind(); if kind == "identifier" || kind == "name" + || kind == "type_identifier" || kind == "property_identifier" || kind == "field_identifier" { @@ -386,6 +425,11 @@ fn scan_workspace_for_symbol( if scanned_files > 500 { break; } + if let Ok(meta) = std::fs::metadata(path) { + if meta.len() > 1024 * 1024 { + continue; + } + } if let Ok(content) = std::fs::read_to_string(path) { for (line_num, line) in content.lines().enumerate() { let is_match = if filter_fn_call { @@ -450,17 +494,7 @@ impl McpTool for FindSymbolReferencesHandler { Ok::, crate::error::AppError>(refs) })?; - let custom_dir = if let Some(ref d) = req.workspace_dir { - Some(std::path::PathBuf::from(d)) - } else { - state.project.pinned_files.read_with(|pinned| { - pinned.first().and_then(|pin| { - std::path::Path::new(&pin.file_path) - .parent() - .map(|p| p.to_path_buf()) - }) - }) - }; + let custom_dir = req.workspace_dir.as_ref().map(std::path::PathBuf::from); if matches.len() < limit { let remaining = limit - matches.len(); @@ -516,17 +550,7 @@ impl McpTool for GetCallersHandler { Ok::, crate::error::AppError>(matching) })?; - let custom_dir = if let Some(ref d) = req.workspace_dir { - Some(std::path::PathBuf::from(d)) - } else { - state.project.pinned_files.read_with(|pinned| { - pinned.first().and_then(|pin| { - std::path::Path::new(&pin.file_path) - .parent() - .map(|p| p.to_path_buf()) - }) - }) - }; + let custom_dir = req.workspace_dir.as_ref().map(std::path::PathBuf::from); if callers.len() < limit { let remaining = limit - callers.len(); @@ -571,13 +595,10 @@ impl McpTool for AnalyzeImpactHandler { } }); - let custom_dir = state.project.pinned_files.read_with(|pinned| { - pinned.first().and_then(|pin| { - std::path::Path::new(&pin.file_path) - .parent() - .map(|p| p.to_path_buf()) - }) - }); + let custom_dir = req + .file_path + .as_ref() + .and_then(|p| std::path::Path::new(p).parent().map(|p| p.to_path_buf())); let sym_clone = sym.clone(); let disk_refs = tokio::task::spawn_blocking(move || { diff --git a/server/src/handlers/env.rs b/server/src/handlers/env.rs index 77ae750..0aae4bf 100644 --- a/server/src/handlers/env.rs +++ b/server/src/handlers/env.rs @@ -5,166 +5,6 @@ use async_trait::async_trait; use serde_json::Value; use std::sync::Arc; -pub struct UpdateEnvFingerprintHandler; - -#[async_trait] -impl McpTool for UpdateEnvFingerprintHandler { - fn name(&self) -> &'static str { - "update_env_fingerprint" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "update_env_fingerprint", - "Update system OS, shell, and installed tool version fingerprints for a workspace namespace.", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: UpdateEnvFingerprintTool = - serde_json::from_value(args).map_err(|e| e.to_string())?; - state.env.env_fingerprints.modify(|fps| { - fps.insert( - req.namespace.clone(), - crate::models::EnvFingerprint { - namespace: req.namespace.clone(), - os: std::env::consts::OS.to_string(), - shell: std::env::var("SHELL").unwrap_or_else(|_| "unknown".to_string()), - tool_versions: req.tool_versions, - updated_at: crate::handlers::utils::now_secs(), - ..Default::default() - }, - ); - }); - Ok("Env fingerprint updated".to_string()) - } -} - -pub struct ReadEnvFingerprintHandler; - -#[async_trait] -impl McpTool for ReadEnvFingerprintHandler { - fn name(&self) -> &'static str { - "read_env_fingerprint" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "read_env_fingerprint", - "Read system OS, shell, and tool version fingerprints for a workspace namespace.", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: ReadEnvFingerprintTool = - serde_json::from_value(args).map_err(|e| e.to_string())?; - let data = state - .env - .env_fingerprints - .read_with(|fps| fps.get(&req.namespace).cloned()); - if let Some(fp) = data { - let data = Ok::(serde_json::to_string(&fp)?)?; - Ok(data) - } else { - Ok("{}".to_string()) - } - } -} - -pub struct LogEnvRequirementHandler; - -#[async_trait] -impl McpTool for LogEnvRequirementHandler { - fn name(&self) -> &'static str { - "log_env_requirement" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "log_env_requirement", - "Log required environment variables, configuration keys, or secrets for a namespace.", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: LogEnvRequirementTool = serde_json::from_value(args).map_err(|e| e.to_string())?; - state.env.env_requirements.modify(|reqs| { - reqs.retain(|r| !(r.namespace == req.namespace && r.key == req.key)); - reqs.push(crate::models::EnvRequirement { - namespace: req.namespace, - key: req.key, - description: req.description, - is_secret: req.is_secret, - ..Default::default() - }); - }); - Ok("Env requirement logged".to_string()) - } -} - -pub struct RegisterEnvironmentHandler; - -#[async_trait] -impl McpTool for RegisterEnvironmentHandler { - fn name(&self) -> &'static str { - "register_environment" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "register_environment", - "Register infrastructure environment details (URL, VPN requirements, notes) for a namespace.", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: RegisterEnvironmentTool = - serde_json::from_value(args).map_err(|e| e.to_string())?; - state.env.environments.modify(|envs| { - envs.retain(|e| !(e.namespace == req.namespace && e.name == req.name)); - envs.push(crate::models::EnvironmentDetail { - namespace: req.namespace, - name: req.name, - url: req.url, - description: req.description, - requires_vpn: req.requires_vpn, - updated_at: crate::handlers::utils::now_secs(), - ..Default::default() - }); - }); - Ok("Environment registered".to_string()) - } -} - -pub struct GetEnvironmentDetailsHandler; - -#[async_trait] -impl McpTool for GetEnvironmentDetailsHandler { - fn name(&self) -> &'static str { - "get_environment_details" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "get_environment_details", - "Retrieve registered infrastructure environment details for a namespace.", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: GetEnvironmentDetailsTool = - serde_json::from_value(args).map_err(|e| e.to_string())?; - let data = state.env.environments.read_with(|envs| { - let filtered: Vec<_> = envs - .iter() - .filter(|e| e.namespace == req.namespace) - .collect(); - Ok::(serde_json::to_string(&filtered)?) - })?; - Ok(data) - } -} - pub struct EnvironmentHandler; #[async_trait] @@ -186,10 +26,32 @@ impl McpTool for EnvironmentHandler { match req.action { EnvAction::UpdateFingerprint => { let tool_versions = req.tool_versions.unwrap_or_default(); - UpdateEnvFingerprintHandler.execute(serde_json::json!({"namespace": ns, "tool_versions": tool_versions}), state).await + state.env.env_fingerprints.modify(|fps| { + fps.insert( + ns.clone(), + crate::models::EnvFingerprint { + namespace: ns.clone(), + os: std::env::consts::OS.to_string(), + shell: std::env::var("SHELL").unwrap_or_else(|_| "unknown".to_string()), + tool_versions, + updated_at: crate::handlers::utils::now_secs(), + ..Default::default() + }, + ); + }); + Ok("Env fingerprint updated".to_string()) } EnvAction::ReadFingerprint => { - ReadEnvFingerprintHandler.execute(serde_json::json!({"namespace": ns}), state).await + let data = state + .env + .env_fingerprints + .read_with(|fps| fps.get(&ns).cloned()); + if let Some(fp) = data { + let data = Ok::(serde_json::to_string(&fp)?)?; + Ok(data) + } else { + Ok("{}".to_string()) + } } EnvAction::LogRequirement => { let key = req.key.ok_or_else(|| { @@ -197,7 +59,17 @@ impl McpTool for EnvironmentHandler { })?; let desc = req.description.unwrap_or_default(); let secret = req.is_secret.unwrap_or(false); - LogEnvRequirementHandler.execute(serde_json::json!({"key": key, "description": desc, "is_secret": secret, "namespace": ns}), state).await + state.env.env_requirements.modify(|reqs| { + reqs.retain(|r| !(r.namespace == ns && r.key == key)); + reqs.push(crate::models::EnvRequirement { + namespace: ns, + key, + description: desc, + is_secret: secret, + ..Default::default() + }); + }); + Ok("Env requirement logged".to_string()) } EnvAction::Register => { let name = req.name.ok_or_else(|| { @@ -206,15 +78,35 @@ impl McpTool for EnvironmentHandler { let url = req.url.unwrap_or_default(); let desc = req.description.unwrap_or_default(); let vpn = req.requires_vpn.unwrap_or(false); - RegisterEnvironmentHandler.execute(serde_json::json!({"name": name, "url": url, "description": desc, "requires_vpn": vpn, "namespace": ns}), state).await + state.env.environments.modify(|envs| { + envs.retain(|e| !(e.namespace == ns && e.name == name)); + envs.push(crate::models::EnvironmentDetail { + namespace: ns, + name, + url, + description: desc, + requires_vpn: vpn, + updated_at: crate::handlers::utils::now_secs(), + ..Default::default() + }); + }); + Ok("Environment registered".to_string()) } EnvAction::GetDetails => { - GetEnvironmentDetailsHandler.execute(serde_json::json!({"namespace": ns}), state).await + let data = state.env.environments.read_with(|envs| { + let filtered: Vec<_> = envs + .iter() + .filter(|e| e.namespace == ns) + .collect(); + Ok::(serde_json::to_string(&filtered)?) + })?; + Ok(data) } } } } + #[cfg(test)] mod tests { use super::*; @@ -225,25 +117,25 @@ mod tests { async fn test_env_fingerprint() { let dir = tempdir().unwrap(); let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + let handler = EnvironmentHandler; - let update_handler = UpdateEnvFingerprintHandler; let args = json!({ + "action": "update_fingerprint", "namespace": "global", "tool_versions": { "rustc": "1.70.0" } }); - let res = update_handler + let res = handler .execute(args, state.clone()) .await .map_err(|e| crate::error::AppError::Internal(e.to_string())) .unwrap(); assert_eq!(res, "Env fingerprint updated"); - let read_handler = ReadEnvFingerprintHandler; - let res2 = read_handler - .execute(json!({"namespace": "global"}), state.clone()) + let res2 = handler + .execute(json!({"action": "read_fingerprint", "namespace": "global"}), state.clone()) .await .map_err(|e| crate::error::AppError::Internal(e.to_string())) .unwrap(); @@ -269,9 +161,9 @@ mod tests { }); }); - let handler = GetEnvironmentDetailsHandler; + let handler = EnvironmentHandler; let res = handler - .execute(json!({"namespace": "global"}), state.clone()) + .execute(json!({"action": "get_details", "namespace": "global"}), state.clone()) .await .map_err(|e| crate::error::AppError::Internal(e.to_string())) .unwrap(); @@ -283,41 +175,39 @@ mod tests { async fn test_env_requirements_and_registration() { let dir = tempdir().unwrap(); let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + let handler = EnvironmentHandler; - let req_handler = LogEnvRequirementHandler; let args_req = json!({ + "action": "log_requirement", "key": "vpn", "description": "Requires VPN", "is_secret": false, - "requirement": "Needs specific VPN", - "context": "For database access", "namespace": "global" }); - let res1 = req_handler + let res1 = handler .execute(args_req, state.clone()) .await .map_err(|e| crate::error::AppError::Internal(e.to_string())) .unwrap(); assert_eq!(res1, "Env requirement logged"); - let reg_handler = RegisterEnvironmentHandler; let args_reg = json!({ + "action": "register", "name": "prod", "url": "https://prod.local", "description": "Production", "requires_vpn": true, "namespace": "global" }); - let res2 = reg_handler + let res2 = handler .execute(args_reg, state.clone()) .await .map_err(|e| crate::error::AppError::Internal(e.to_string())) .unwrap(); assert_eq!(res2, "Environment registered"); - let get_handler = GetEnvironmentDetailsHandler; - let res3 = get_handler - .execute(json!({"namespace": "global"}), state.clone()) + let res3 = handler + .execute(json!({"action": "get_details", "namespace": "global"}), state.clone()) .await .map_err(|e| crate::error::AppError::Internal(e.to_string())) .unwrap(); @@ -329,16 +219,17 @@ mod tests { async fn test_empty_namespace_in_update_env_fingerprint() { let dir = tempfile::tempdir().unwrap(); let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + let handler = EnvironmentHandler; - let update_handler = UpdateEnvFingerprintHandler; let args = serde_json::json!({ + "action": "update_fingerprint", "namespace": "", "tool_versions": { "rustc": "1.70.0" } }); - let res = update_handler + let res = handler .execute(args, state.clone()) .await; diff --git a/server/src/handlers/graph.rs b/server/src/handlers/graph.rs index 6e6a5fb..4db719d 100644 --- a/server/src/handlers/graph.rs +++ b/server/src/handlers/graph.rs @@ -459,14 +459,15 @@ impl McpTool for ReadGraphHandler { let offset = req.offset.unwrap_or(0); let data = state.read_graph(|full| -> crate::error::Result { - let mut result_json = if limit.is_some() || req.offset.is_some() { - let filtered_entities: Vec<(&String, &crate::models::Entity)> = full - .entities - .iter() - .filter(|(_, v)| req.namespace.as_ref().map_or(true, |ns| &v.namespace == ns)) - .collect(); + let filtered_entities: Vec<(&String, &crate::models::Entity)> = full + .entities + .iter() + .filter(|(_, v)| req.namespace.as_ref().map_or(true, |ns| &v.namespace == ns)) + .collect(); - let total_count = filtered_entities.len(); + let total_count = filtered_entities.len(); + + let (entities_map, relations_vec, meta_json) = if limit.is_some() || req.offset.is_some() { let effective_limit = limit.unwrap_or(50); let paged_entities: std::collections::HashMap<&String, &crate::models::Entity> = filtered_entities .into_iter() @@ -485,45 +486,94 @@ impl McpTool for ReadGraphHandler { }) .collect(); - serde_json::to_string(&serde_json::json!({ - "entities": paged_entities, - "relations": relevant_relations, + let meta = serde_json::json!({ "total_count": total_count, "offset": offset, "limit": effective_limit, "has_more": has_more, - }))? - } else if let Some(ns) = req.namespace { - let mut filtered = BorrowedGraph::default(); - for (k, v) in &full.entities { - if v.namespace == ns { - filtered.entities.insert(k, v); - } - } - for r in &full.relations { - if r.namespace == ns { - filtered.relations.push(r); - } - } - serde_json::to_string(&filtered)? + }); + (paged_entities, relevant_relations, Some(meta)) } else { - serde_json::to_string(full)? + let ent_map: std::collections::HashMap<&String, &crate::models::Entity> = + filtered_entities.into_iter().collect(); + let rel_vec: Vec<&crate::models::Relation> = full + .relations + .iter() + .filter(|r| req.namespace.as_ref().map_or(true, |ns| &r.namespace == ns)) + .collect(); + (ent_map, rel_vec, None) }; + let serialized = if let Some(meta) = &meta_json { + serde_json::json!({ + "entities": entities_map, + "relations": relations_vec, + "total_count": meta["total_count"], + "offset": meta["offset"], + "limit": meta["limit"], + "has_more": meta["has_more"], + }) + } else { + serde_json::json!({ + "entities": entities_map, + "relations": relations_vec, + }) + }; + + let mut result_json = serde_json::to_string(&serialized)?; + if let Some(max_tok) = max_tokens { let max_chars = max_tok * 4; if result_json.len() > max_chars { - let valid_boundary = result_json.floor_char_boundary(max_chars); - result_json.truncate(valid_boundary); - result_json.push_str("\n... [TRUNCATED_TO_MAX_TOKENS. Use search_nodes, pagination (limit, offset), or get_subgraph for targeted discovery]"); + let total_ent = entities_map.len(); + let total_rel = relations_vec.len(); + let frac = (max_chars as f64) / (result_json.len() as f64); + let mut keep_count = (total_ent as f64 * frac * 0.85).max(1.0) as usize; + + let ent_vec: Vec<_> = entities_map.into_iter().collect(); + + while keep_count > 0 { + let sliced_ent: std::collections::HashMap<_, _> = ent_vec.iter().take(keep_count).cloned().collect(); + let sliced_rel: Vec<_> = relations_vec.iter().filter(|r| sliced_ent.contains_key(&r.from) && sliced_ent.contains_key(&r.to)).copied().collect(); + + let mut truncated_val = serde_json::json!({ + "entities": sliced_ent, + "relations": sliced_rel, + "_meta": { + "truncated": true, + "total_entities": total_ent, + "returned_entities": sliced_ent.len(), + "total_relations": total_rel, + "returned_relations": sliced_rel.len(), + "note": "Output structurally truncated to fit within max_tokens. Use pagination (limit, offset), search_nodes, or get_subgraph for targeted discovery." + } + }); + + if let Some(meta) = &meta_json { + if let Some(obj) = truncated_val.as_object_mut() { + obj.insert("total_count".to_string(), meta["total_count"].clone()); + obj.insert("offset".to_string(), meta["offset"].clone()); + obj.insert("limit".to_string(), meta["limit"].clone()); + obj.insert("has_more".to_string(), serde_json::Value::Bool(true)); + } + } + + if let Ok(trunc_str) = serde_json::to_string(&truncated_val) { + if trunc_str.len() <= max_chars || keep_count == 1 { + result_json = trunc_str; + break; + } + } + keep_count = keep_count.saturating_sub(keep_count / 3 + 1); + } } } + Ok(result_json) })?; Ok(data) } } - pub struct SearchNodesHandler; #[async_trait] @@ -910,36 +960,49 @@ impl McpTool for GetSubgraphHandler { let depth = req.depth.unwrap_or(2); let format = req.format.unwrap_or(SubgraphFormat::Json); - if format == SubgraphFormat::MarkdownTree { - let (sub_entities, sub_relations) = state.read_graph(|g| { - let mut visited = std::collections::HashSet::new(); - let mut queue = std::collections::VecDeque::new(); - queue.push_back((root.clone(), 0)); - visited.insert(root.clone()); + let (matched_entities, matched_relations) = state.read_graph(|full| { + let mut adj: std::collections::HashMap<&str, Vec<&str>> = + std::collections::HashMap::with_capacity(full.relations.len() * 2); + for r in &full.relations { + adj.entry(r.from.as_str()).or_default().push(r.to.as_str()); + adj.entry(r.to.as_str()).or_default().push(r.from.as_str()); + } - while let Some((curr, d)) = queue.pop_front() { - if d >= depth { - continue; - } - for r in &g.relations { - if r.from == curr && !visited.contains(&r.to) { - visited.insert(r.to.clone()); - queue.push_back((r.to.clone(), d + 1)); - } else if r.to == curr && !visited.contains(&r.from) { - visited.insert(r.from.clone()); - queue.push_back((r.from.clone(), d + 1)); + let mut visited = std::collections::HashSet::new(); + let mut queue = std::collections::VecDeque::new(); + queue.push_back((root.as_str(), 0)); + visited.insert(root.clone()); + + while let Some((curr, d)) = queue.pop_front() { + if d < depth { + if let Some(neighbors) = adj.get(curr) { + for &next in neighbors { + if visited.insert(next.to_string()) { + queue.push_back((next, d + 1)); + } } } } + } - let ent: Vec<_> = g.entities.values().filter(|e| visited.contains(&e.name)).cloned().collect(); - let rel: Vec<_> = g.relations.iter().filter(|r| visited.contains(&r.from) && visited.contains(&r.to)).cloned().collect(); - (ent, rel) - }); + let ent: std::collections::HashMap = visited + .iter() + .filter_map(|name| full.entities.get(name).map(|e| (name.clone(), e.clone()))) + .collect(); + let rel: Vec = full + .relations + .iter() + .filter(|r| visited.contains(&r.from) && visited.contains(&r.to)) + .cloned() + .collect(); + (ent, rel) + }); + + if format == SubgraphFormat::MarkdownTree { let mut markdown = format!("# Subgraph Topology for `{}`\n\n", root); markdown.push_str("## Entities\n"); - for e in &sub_entities { + for e in matched_entities.values() { markdown.push_str(&format!("- **{}** (`{}`)\n", e.name, e.entity_type)); for obs in &e.observations { markdown.push_str(&format!(" - {}\n", obs)); @@ -947,56 +1010,20 @@ impl McpTool for GetSubgraphHandler { } markdown.push_str("\n## Relations\n"); - for r in &sub_relations { + for r in &matched_relations { markdown.push_str(&format!("- `{}` --[{}]--> `{}`\n", r.from, r.relation_type, r.to)); } return Ok(markdown); } - let data = state.read_graph(|full| { - let mut visited_nodes: std::collections::HashSet = std::collections::HashSet::new(); - let mut matched_entities = std::collections::HashMap::new(); - let mut matched_relations = Vec::new(); + let result = serde_json::json!({ + "root_node": root, + "depth": depth, + "entities": matched_entities, + "relations": matched_relations, + }); - let mut queue = std::collections::VecDeque::new(); - queue.push_back((root.clone(), 0)); - visited_nodes.insert(root.clone()); - - while let Some((curr, current_depth)) = queue.pop_front() { - if let Some(entity) = full.entities.get(&curr) { - matched_entities.insert(curr.clone(), entity.clone()); - } - - if current_depth < depth { - for rel in &full.relations { - if rel.from == curr { - matched_relations.push(rel.clone()); - if !visited_nodes.contains(&rel.to) { - visited_nodes.insert(rel.to.clone()); - queue.push_back((rel.to.clone(), current_depth + 1)); - } - } else if rel.to == curr { - matched_relations.push(rel.clone()); - if !visited_nodes.contains(&rel.from) { - visited_nodes.insert(rel.from.clone()); - queue.push_back((rel.from.clone(), current_depth + 1)); - } - } - } - } - } - - let result = serde_json::json!({ - "root_node": root, - "depth": depth, - "entities": matched_entities, - "relations": matched_relations, - }); - - Ok::(serde_json::to_string_pretty(&result)?) - })?; - - Ok(data) + Ok(serde_json::to_string_pretty(&result)?) } } diff --git a/server/src/handlers/meta.rs b/server/src/handlers/meta.rs index 03c81aa..3cb1186 100644 --- a/server/src/handlers/meta.rs +++ b/server/src/handlers/meta.rs @@ -6,169 +6,6 @@ use async_trait::async_trait; use serde_json::Value; use std::sync::Arc; -pub struct LogDecisionHandler; - -#[async_trait] -impl McpTool for LogDecisionHandler { - fn name(&self) -> &'static str { - "log_decision" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "log_decision", - "Log an architectural decision record (ADR) with title, context, and consequences", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: LogDecisionTool = serde_json::from_value(args).map_err(|e| e.to_string())?; - - let idx = state.get_search_index().await; - let mut final_id = String::new(); - let mut adrs_to_index = Vec::new(); - - state.code.adrs.modify(|adrs| { - if let Some(superseded_id) = &req.supersedes { - for old_adr in adrs.iter_mut() { - if old_adr.id == *superseded_id { - old_adr.status = "superseded".to_string(); - adrs_to_index.push(old_adr.clone()); - break; - } - } - } - - final_id = format!("ADR-{:04}", adrs.len() + 1); - let a = Adr { - id: final_id.clone(), - title: req.title.clone(), - context: req.context, - decision: req.decision.clone(), - consequence: req.consequence, - status: "accepted".to_string(), - supersedes: req.supersedes, - timestamp: crate::handlers::utils::now_secs(), - namespace: req - .namespace - .unwrap_or_else(|| crate::models::default_namespace()), - repo_name: req.repo_name, - alternatives_considered: req.alternatives_considered.unwrap_or_default(), - affected_components: req.affected_components.unwrap_or_default(), - author: req.author, - }; - - adrs_to_index.push(a.clone()); - adrs.push(a); - }); - - // Index in Tantivy outside the store write lock - for adr in &adrs_to_index { - drop(idx.index_adr(adr)); - } - - state.record_activity( - "decision", - &format!("Logged {}: {}", final_id, req.title), - Some(&req.decision), - ); - Ok(format!("Logged decision {}: {}", final_id, req.title)) - } -} - -pub struct QueryDecisionsHandler; - -#[async_trait] -impl McpTool for QueryDecisionsHandler { - fn name(&self) -> &'static str { - "query_decisions" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "query_decisions", - "Query or search existing architectural decision records (ADRs)", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: QueryDecisionsTool = serde_json::from_value(args).map_err(|e| e.to_string())?; - let limit = req.limit.unwrap_or(20); - let include_body = req.include_body.unwrap_or(true); - let data = state.code.adrs.read_with(|adrs| { - let filtered: Vec<_> = adrs - .iter() - .filter(|a| { - if let Some(q) = &req.query { - contains_ignore_ascii_case(&a.title, q) - || contains_ignore_ascii_case(&a.context, q) - || contains_ignore_ascii_case(&a.decision, q) - } else { - true - } - }) - .take(limit) - .collect(); - - if include_body { - Ok::(serde_json::to_string(&filtered)?) - } else { - let compact: Vec<_> = filtered - .iter() - .map(|a| { - serde_json::json!({ - "id": a.id, - "title": a.title, - "status": a.status, - "timestamp": a.timestamp, - }) - }) - .collect(); - Ok::(serde_json::to_string(&compact)?) - } - })?; - Ok(data) - } -} - -pub struct DeleteDecisionHandler; - -#[async_trait] -impl McpTool for DeleteDecisionHandler { - fn name(&self) -> &'static str { - "delete_decision" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "delete_decision", - "Delete an architectural decision record", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: crate::tools::DeleteDecisionTool = - serde_json::from_value(args).map_err(|e| e.to_string())?; - let mut found = false; - state.code.adrs.modify(|adrs| { - if let Some(pos) = adrs.iter().position(|a| a.id == req.id) { - adrs.remove(pos); - found = true; - } - }); - - if found { - let idx = state.get_search_index().await; - let _ = idx.delete_document(&req.id).await; - Ok("Decision deleted successfully".to_string()) - } else { - Err(crate::error::AppError::Internal( - "Decision not found".to_string(), - )) - } - } -} - pub struct LogErrorFixHandler; #[async_trait] @@ -528,202 +365,6 @@ impl McpTool for QueryRecentChangesHandler { Ok(data) } } -pub struct LearnPreferenceHandler; - -#[async_trait] -impl McpTool for LearnPreferenceHandler { - fn name(&self) -> &'static str { - "learn_preference" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "learn_preference", - "Learn or update a key-value user preference", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: LearnPreferenceTool = serde_json::from_value(args).map_err(|e| e.to_string())?; - state.env.prefs.modify(|prefs| { - prefs.insert( - req.key.clone(), - crate::models::Preference { - key: req.key.clone(), - value: req.value, - updated_at: crate::handlers::utils::now_secs(), - ..Default::default() - }, - ); - }); - Ok("Preference learned".to_string()) - } -} - -pub struct ReadPreferencesHandler; - -#[async_trait] -impl McpTool for ReadPreferencesHandler { - fn name(&self) -> &'static str { - "read_preferences" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "read_preferences", - "Read all stored user and environment preferences", - ) - } - - async fn execute(&self, _args: Value, state: Arc) -> crate::error::Result { - state - .env - .prefs - .read_with(|prefs| Ok::(serde_json::to_string(prefs)?)) - } -} - -pub struct LogTechDebtHandler; - -#[async_trait] -impl McpTool for LogTechDebtHandler { - fn name(&self) -> &'static str { - "log_tech_debt" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "log_tech_debt", - "Log a new technical debt item with description and ideal solution", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - 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(); - state.code.tech_debts.modify(|debts| { - debts.push(crate::models::TechDebt { - id: uuid::Uuid::new_v4().to_string(), - namespace: req.namespace, - description: req.description, - ideal_solution: req.ideal_solution, - is_resolved: false, - created_at: crate::handlers::utils::now_secs(), - git_commit: req.git_commit, - git_branch: req.git_branch, - embedding, - repo_name: req.repo_name, - severity: req.severity, - file_path: req.file_path, - line_range: req.line_range, - workaround: req.workaround, - effort_estimate: req.effort_estimate, - }); - if debts.len() > 300 { - debts.remove(0); - } - }); - Ok("Tech debt logged".to_string()) - } -} - -pub struct ResolveTechDebtHandler; - -#[async_trait] -impl McpTool for ResolveTechDebtHandler { - fn name(&self) -> &'static str { - "resolve_tech_debt" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "resolve_tech_debt", - "Mark an existing technical debt item as resolved by ID", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: ResolveTechDebtTool = serde_json::from_value(args).map_err(|e| e.to_string())?; - let mut found = false; - state.code.tech_debts.modify(|debts| { - for d in debts.iter_mut() { - if d.id == req.id { - d.is_resolved = true; - found = true; - break; - } - } - }); - 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(), - )) - } - } -} - -pub struct ListTechDebtHandler; - -#[async_trait] -impl McpTool for ListTechDebtHandler { - fn name(&self) -> &'static str { - "list_tech_debt" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "list_tech_debt", - "List technical debt items filtered by namespace or resolution status", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: ListTechDebtTool = serde_json::from_value(args).map_err(|e| e.to_string())?; - let level = req.summary_level.as_deref().unwrap_or("detailed"); - let data = state.code.tech_debts.read_with(|debts| { - let filtered: Vec<_> = debts - .iter() - .filter(|d| { - d.namespace == req.namespace && (req.include_resolved || !d.is_resolved) - }) - .map(|d| match level { - "compact" => serde_json::json!({ - "id": d.id, - "description": d.description, - "is_resolved": d.is_resolved, - }), - "full" => serde_json::to_value(d).unwrap_or_default(), - _ => serde_json::json!({ - "id": d.id, - "description": d.description, - "ideal_solution": d.ideal_solution, - "is_resolved": d.is_resolved, - }), - }) - .collect(); - let mut json_str = serde_json::to_string(&filtered)?; - if let Some(max_t) = req.max_tokens { - let char_limit = max_t * 4; - if json_str.len() > char_limit { - json_str.truncate(char_limit); - json_str.push_str(" ...[truncated due to max_tokens]"); - } - } - Ok::(json_str) - })?; - Ok(data) - } -} - pub struct DecisionsHandler; #[async_trait] @@ -752,40 +393,116 @@ impl McpTool for DecisionsHandler { let status = req.status.unwrap_or_else(|| "accepted".to_string()); let context = req.context.unwrap_or_default(); let decision = req.decision.unwrap_or_default(); - let consequences = req.consequences.unwrap_or_default(); - LogDecisionHandler - .execute( - serde_json::json!({ - "title": title, - "status": status, - "context": context, - "decision": decision, - "consequences": consequences, - "namespace": ns, - "repo_name": req.repo_name, - "author": req.author, - "affected_components": req.affected_components, - "alternatives_considered": req.alternatives_considered, - }), - state, - ) - .await + let consequence = req.consequences.unwrap_or_default(); + + let idx = state.get_search_index().await; + let mut final_id = String::new(); + let mut adrs_to_index = Vec::new(); + + state.code.adrs.modify(|adrs| { + if let Some(superseded_id) = &req.supersedes { + for old_adr in adrs.iter_mut() { + if old_adr.id == *superseded_id { + old_adr.status = "superseded".to_string(); + adrs_to_index.push(old_adr.clone()); + break; + } + } + } + + final_id = format!("ADR-{:04}", adrs.len() + 1); + let a = Adr { + id: final_id.clone(), + title: title.clone(), + context, + decision: decision.clone(), + consequence, + status, + supersedes: req.supersedes, + timestamp: crate::handlers::utils::now_secs(), + namespace: ns, + repo_name: req.repo_name, + alternatives_considered: req.alternatives_considered.unwrap_or_default(), + affected_components: req.affected_components.unwrap_or_default(), + author: req.author, + }; + + adrs_to_index.push(a.clone()); + adrs.push(a); + }); + + for adr in &adrs_to_index { + drop(idx.index_adr(adr)); + } + + state.record_activity( + "decision", + &format!("Logged {}: {}", final_id, title), + Some(&decision), + ); + Ok(format!("Logged decision {}: {}", final_id, title)) } DecisionAction::Query => { - QueryDecisionsHandler - .execute( - serde_json::json!({"query": req.query, "namespace": ns}), - state, - ) - .await + let limit = req.limit.unwrap_or(20); + let include_body = req.include_body.unwrap_or(true); + let data = state.code.adrs.read_with(|adrs| { + let filtered: Vec<_> = adrs + .iter() + .filter(|a| { + if !a.namespace.eq_ignore_ascii_case(&ns) && ns != "global" { + return false; + } + if let Some(q) = &req.query { + crate::handlers::utils::contains_ignore_ascii_case(&a.title, q) + || crate::handlers::utils::contains_ignore_ascii_case(&a.context, q) + || crate::handlers::utils::contains_ignore_ascii_case(&a.decision, q) + } else { + true + } + }) + .take(limit) + .collect(); + + if include_body { + Ok::(serde_json::to_string(&filtered)?) + } else { + let compact: Vec<_> = filtered + .iter() + .map(|a| { + serde_json::json!({ + "id": a.id, + "title": a.title, + "status": a.status, + "timestamp": a.timestamp, + }) + }) + .collect(); + Ok::(serde_json::to_string(&compact)?) + } + })?; + Ok(data) } DecisionAction::Delete => { let id = req.id.ok_or_else(|| { crate::error::AppError::Internal("Missing required parameter 'id' for action 'delete'. Next step: Provide ADR 'id' string in request and retry.".to_string()) })?; - DeleteDecisionHandler - .execute(serde_json::json!({"id": id}), state) - .await + let mut found = false; + state.code.adrs.modify(|adrs| { + if let Some(pos) = adrs.iter().position(|a| a.id == id) { + adrs.remove(pos); + found = true; + } + }); + + if found { + let idx = state.get_search_index().await; + let _ = idx.delete_document(&id).await; + Ok("Decision deleted successfully".to_string()) + } else { + Err(crate::error::AppError::Internal( + "Decision not found".to_string(), + )) + } } } } @@ -813,46 +530,99 @@ impl McpTool for TechDebtHandler { .unwrap_or_else(|| crate::models::default_namespace()); match req.action { TechDebtAction::Log => { - let desc = req.description.ok_or_else(|| { + let desc = req.description.or(req.title).ok_or_else(|| { crate::error::AppError::Internal("Missing required parameter 'description' for action 'log'. Next step: Provide tech debt 'description' in request and retry.".to_string()) })?; let ideal = req.ideal_solution.unwrap_or_default(); - LogTechDebtHandler - .execute( - serde_json::json!({ - "description": desc, - "ideal_solution": ideal, - "git_commit": req.git_commit, - "git_branch": req.git_branch, - "symbol_references": req.symbol_references, - "line_range": req.line_range, - "namespace": ns, - "repo_name": req.repo_name, - "severity": req.severity, - "file_path": req.file_path, - "workaround": req.workaround, - "effort_estimate": req.effort_estimate, - }), - state, - ) + let text_to_embed = format!( + "Description: {}\nIdeal Solution: {}", + desc, ideal + ); + 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(), + namespace: ns, + description: desc, + ideal_solution: ideal, + is_resolved: false, + created_at: crate::handlers::utils::now_secs(), + git_commit: req.git_commit, + git_branch: req.git_branch, + embedding, + repo_name: req.repo_name, + severity: req.severity, + file_path: req.file_path, + line_range: req.line_range, + workaround: req.workaround, + effort_estimate: req.effort_estimate, + }); + if debts.len() > 300 { + debts.remove(0); + } + }); + Ok("Tech debt logged".to_string()) } TechDebtAction::Resolve => { let id = req.id.ok_or_else(|| { crate::error::AppError::Internal("Missing required parameter 'id' for action 'resolve'. Next step: Provide tech debt 'id' string in request and retry.".to_string()) })?; - ResolveTechDebtHandler - .execute(serde_json::json!({"id": id}), state) - .await + let mut found = false; + state.code.tech_debts.modify(|debts| { + for d in debts.iter_mut() { + if d.id == id { + d.is_resolved = true; + found = true; + break; + } + } + }); + 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 action." + .to_string(), + )) + } } TechDebtAction::List => { let inc = req.include_resolved.unwrap_or(false); - ListTechDebtHandler - .execute( - serde_json::json!({"include_resolved": inc, "namespace": ns}), - state, - ) - .await + let level = req.summary_level.as_deref().unwrap_or("detailed"); + let data = state.code.tech_debts.read_with(|debts| { + let filtered: Vec<_> = debts + .iter() + .filter(|d| { + d.namespace == ns && (inc || !d.is_resolved) + }) + .map(|d| match level { + "compact" => serde_json::json!({ + "id": d.id, + "description": d.description, + "is_resolved": d.is_resolved, + }), + "full" => serde_json::to_value(d).unwrap_or_default(), + _ => serde_json::json!({ + "id": d.id, + "description": d.description, + "ideal_solution": d.ideal_solution, + "is_resolved": d.is_resolved, + }), + }) + .collect(); + let mut json_str = serde_json::to_string(&filtered)?; + if let Some(max_t) = req.max_tokens { + let char_limit = max_t * 4; + if json_str.len() > char_limit { + json_str.truncate(char_limit); + json_str.push_str(" ...[truncated due to max_tokens]"); + } + } + Ok::(json_str) + })?; + Ok(data) } } } @@ -1285,93 +1055,16 @@ impl McpTool for GetProjectHealthHandler { .filter(|m| m.namespace == req.namespace && m.status != "done") .count() }); - let remaining_checklists = state.project.pr_checklists.read_with(|checklists| { - checklists - .iter() - .filter(|c| c.namespace == req.namespace) - .count() - }); - let report = serde_json::json!({ "active_tasks": active_tasks, "unresolved_tech_debt": unresolved_debt, "unread_handoff_memos": unread_memos, - "active_milestones": active_milestones, - "remaining_pr_checklist_items": remaining_checklists + "active_milestones": active_milestones }); Ok(report.to_string()) } } -pub struct SuggestErrorFixHandler; - -#[async_trait] -impl McpTool for SuggestErrorFixHandler { - fn name(&self) -> &'static str { - "suggest_error_fix" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "suggest_error_fix", - "Suggest historical error fixes based on stack trace vector similarity", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: SuggestErrorFixTool = serde_json::from_value(args).map_err(|e| e.to_string())?; - let limit = req.limit.unwrap_or(3); - - let query_emb = crate::embedding::generate_embedding_async(req.stack_trace.clone()) - .await - .unwrap_or_default(); - - let data = state.code.error_fixes.read_with(|fixes| { - let mut scored: Vec<_> = fixes - .iter() - .map(|f| { - let mut score = 0.0; - let st_lower = req.stack_trace.to_lowercase(); - let sig_lower = f.signature.to_lowercase(); - let sol_lower = f.solution.to_lowercase(); - if st_lower.contains(&sig_lower) || sig_lower.contains(&st_lower) { - score += 0.8; - } else if st_lower.contains(&sol_lower) || sol_lower.contains(&st_lower) { - score += 0.5; - } - if let Some(emb) = &f.embedding { - if !query_emb.is_empty() { - score += crate::embedding::cosine_similarity(&query_emb, emb); - } - } - (f, score) - }) - .filter(|(_, score)| *score > 0.2) - .collect(); - - scored.sort_by(|a, b| b.1.total_cmp(&a.1)); - - let suggestions: Vec<_> = scored - .into_iter() - .take(limit) - .map(|(f, score)| { - serde_json::json!({ - "signature": f.signature, - "solution": f.solution, - "git_commit": f.git_commit, - "git_branch": f.git_branch, - "match_score": score - }) - }) - .collect(); - - Ok::(serde_json::to_string_pretty(&suggestions)?) - })?; - - Ok(data) - } -} - pub struct ManageCheckpointHandler; #[async_trait] @@ -1528,100 +1221,6 @@ impl McpTool for ManageCheckpointHandler { } } -pub struct CheckpointStateHandler; - -#[async_trait] -impl McpTool for CheckpointStateHandler { - fn name(&self) -> &'static str { - "checkpoint_state" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "checkpoint_state", - "Save point-in-time memory state snapshot checkpoint", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: CheckpointStateTool = serde_json::from_value(args).map_err(|e| e.to_string())?; - let target_dir = state.base_dir.join("checkpoints").join(&req.name); - - if let Err(e) = std::fs::create_dir_all(&target_dir) { - return Err(crate::error::AppError::Internal(format!( - "Failed to create checkpoint dir: {}", - e - ))); - } - - // Copy tasks, graph, tech_debts, and notes to checkpoint folder - let graph_json = state.read_graph(|g| serde_json::to_string(g).unwrap_or_default()); - let _ = std::fs::write(target_dir.join("graph.json"), graph_json); - - let tasks_json = state - .project - .tasks - .read_with(|t| serde_json::to_string(t).unwrap_or_default()); - let _ = std::fs::write(target_dir.join("tasks.json"), tasks_json); - - let debts_json = state - .code - .tech_debts - .read_with(|d| serde_json::to_string(d).unwrap_or_default()); - let _ = std::fs::write(target_dir.join("tech_debts.json"), debts_json); - - Ok(format!("Checkpoint '{}' saved successfully.", req.name)) - } -} - -pub struct RestoreStateHandler; - -#[async_trait] -impl McpTool for RestoreStateHandler { - fn name(&self) -> &'static str { - "restore_state" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "restore_state", - "Restore memory state from a point-in-time snapshot checkpoint", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: RestoreStateTool = serde_json::from_value(args).map_err(|e| e.to_string())?; - let target_dir = state.base_dir.join("checkpoints").join(&req.name); - - if !target_dir.exists() { - return Err(crate::error::AppError::Internal(format!( - "Checkpoint '{}' does not exist.", - req.name - ))); - } - - if let Ok(graph_content) = std::fs::read_to_string(target_dir.join("graph.json")) { - if let Ok(graph) = serde_json::from_str(&graph_content) { - state.graph.modify(|g| *g = graph); - } - } - - if let Ok(tasks_content) = std::fs::read_to_string(target_dir.join("tasks.json")) { - if let Ok(tasks) = serde_json::from_str(&tasks_content) { - state.project.tasks.modify(|t| *t = tasks); - } - } - - if let Ok(debts_content) = std::fs::read_to_string(target_dir.join("tech_debts.json")) { - if let Ok(debts) = serde_json::from_str(&debts_content) { - state.code.tech_debts.modify(|d| *d = debts); - } - } - - Ok(format!("Checkpoint '{}' restored successfully.", req.name)) - } -} - pub struct QueryLineageHandler; #[async_trait] @@ -1877,7 +1476,7 @@ impl McpTool for GetPreflightContextHandler { fn schema(&self) -> Value { crate::mcp::tool_def::( "get_preflight_context", - "Get 1-page executive summary of active tasks, pinned files, hypotheses, and tech debt in 1 turn.", + "Get 1-page executive summary of active tasks, hypotheses, and tech debt in 1 turn.", ) } @@ -1886,7 +1485,6 @@ impl McpTool for GetPreflightContextHandler { serde_json::from_value(args).map_err(|e| e.to_string())?; let tasks = state.project.tasks.read_with(|t| t.clone()); - let pinned = state.project.pinned_files.read_with(|p| p.clone()); let tech_debts = state.code.tech_debts.read_with(|d| d.clone()); let hypotheses = state.code.hypotheses.read_with(|h| h.clone()); let recent_commands = state @@ -1911,17 +1509,6 @@ impl McpTool for GetPreflightContextHandler { }) .collect(); - let branch_pinned: Vec<_> = pinned - .into_iter() - .filter(|p| { - p.namespace == req.namespace - && (req.git_branch.is_none() - || p.git_branch.is_none() - || p.git_branch == req.git_branch) - }) - .map(|p| p.file_path) - .collect(); - let open_tech_debts: Vec<_> = tech_debts .into_iter() .filter(|d| d.namespace == req.namespace && !d.is_resolved) @@ -1945,7 +1532,6 @@ impl McpTool for GetPreflightContextHandler { "namespace": req.namespace, "git_branch": req.git_branch, "active_tasks": active_tasks, - "pinned_files": branch_pinned, "top_open_tech_debts": open_tech_debts, "active_hypotheses": active_hypotheses, "recent_terminal_commands": recent_commands, @@ -2151,83 +1737,6 @@ impl McpTool for AutoSessionCheckpointHandler { } } -pub struct SearchSnippetsHybridHandler; - -#[async_trait] -impl McpTool for SearchSnippetsHybridHandler { - fn name(&self) -> &'static str { - "search_snippets_hybrid" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "search_snippets_hybrid", - "Hybrid BM25 and semantic tag search across stored code snippets.", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: SearchSnippetsHybridTool = - serde_json::from_value(args).map_err(|e| e.to_string())?; - - let snippets = state.code.snippets.read_with(|s| s.clone()); - let q_tokens: Vec<_> = req - .query - .to_lowercase() - .split_whitespace() - .map(|s| s.to_string()) - .collect(); - - let mut scored: Vec<_> = snippets - .into_iter() - .filter_map(|s| { - let text = format!("{} {} {} {}", s.name, s.description, s.language, s.code) - .to_lowercase(); - let mut score = 0.0; - for tok in &q_tokens { - if text.contains(tok) { - score += 1.0; - } - } - if let Some(tags) = &req.tags { - for tag in tags { - if s.tags - .iter() - .any(|t| t.to_lowercase() == tag.to_lowercase()) - { - score += 2.0; - } - } - } - if score > 0.0 { - Some(serde_json::json!({ - "name": s.name, - "description": s.description, - "language": s.language, - "tags": s.tags, - "score": score, - "code": s.code - })) - } else { - None - } - }) - .collect(); - - scored.sort_by(|a, b| { - b["score"] - .as_f64() - .unwrap_or(0.0) - .total_cmp(&a["score"].as_f64().unwrap_or(0.0)) - }); - - let limit = req.limit.unwrap_or(10); - scored.truncate(limit); - - Ok(serde_json::to_string_pretty(&scored)?) - } -} - use crate::handlers::utils::*; #[cfg(test)] @@ -2279,8 +1788,9 @@ mod tests { let dir = tempdir().unwrap(); let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); - let decision_handler = LogDecisionHandler; + let decision_handler = DecisionsHandler; let args_dec = json!({ + "action": "log", "title": "Architecture", "context": "Needs DB", "decision": "Use SQLite", @@ -2293,8 +1803,9 @@ mod tests { .unwrap(); assert_eq!(res1, "Logged decision ADR-0001: Architecture"); - let debt_handler = LogTechDebtHandler; + let debt_handler = TechDebtHandler; let args_debt = json!({ + "action": "log", "title": "Hardcoded path", "description": "Hardcoded path", "location": "main.rs:10", @@ -2311,36 +1822,16 @@ mod tests { .unwrap(); assert_eq!(res2, "Tech debt logged"); - let list_debt = ListTechDebtHandler; + let list_debt = TechDebtHandler; let res3 = list_debt .execute( - json!({"namespace": "global", "include_resolved": false}), + json!({"action": "list", "namespace": "global", "include_resolved": false}), state.clone(), ) .await .map_err(|e| crate::error::AppError::Internal(e.to_string())) .unwrap(); assert!(res3.contains("Hardcoded path")); - - let pref_handler = LearnPreferenceHandler; - let args_pref = json!({ - "key": "formatting", - "value": "use spaces", - }); - let res4 = pref_handler - .execute(args_pref, state.clone()) - .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(); - assert!(res5.contains("use spaces")); } #[tokio::test] @@ -2369,8 +1860,9 @@ mod tests { .unwrap(); assert!(res_changes.contains("main.rs")); - let debt_handler = LogTechDebtHandler; + let debt_handler = TechDebtHandler; let args_debt = json!({ + "action": "log", "title": "Debt 1", "description": "Needs refactor", "location": "main.rs", @@ -2387,10 +1879,10 @@ mod tests { .unwrap(); // resolve it - let list_debt = ListTechDebtHandler; + let list_debt = TechDebtHandler; let debt_list = list_debt .execute( - json!({"namespace": "global", "include_resolved": false}), + json!({"action": "list", "namespace": "global", "include_resolved": false}), state.clone(), ) .await @@ -2399,9 +1891,9 @@ mod tests { let uuid_start = debt_list.find("id\":\"").unwrap() + 5; let uuid = &debt_list[uuid_start..uuid_start + 36]; - let resolve_debt = ResolveTechDebtHandler; + let resolve_debt = TechDebtHandler; resolve_debt - .execute(json!({"id": uuid}), state.clone()) + .execute(json!({"action": "resolve", "id": uuid}), state.clone()) .await .map_err(|e| crate::error::AppError::Internal(e.to_string())) .unwrap(); @@ -2500,11 +1992,12 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); - // LogDecision & DeleteDecision & QueryDecisions - let log_dec = LogDecisionHandler; - let dec_res = log_dec + // DecisionsHandler log, query, delete + let handler_dec = DecisionsHandler; + let dec_res = handler_dec .execute( serde_json::json!({ + "action": "log", "title": "Use Axum", "context": "Architecture choice", "decision": "Adopt Axum for web framework", @@ -2517,24 +2010,23 @@ mod tests { assert!(dec_res.contains("Logged decision")); - let q_dec = QueryDecisionsHandler; - let q_dec_res = q_dec - .execute(serde_json::json!({}), state.clone()) + let q_dec_res = handler_dec + .execute(serde_json::json!({"action": "query"}), state.clone()) .await .unwrap(); assert!(q_dec_res.contains("Use Axum")); - let del_dec = DeleteDecisionHandler; - let del_dec_res = del_dec - .execute(serde_json::json!({"id": "ADR-0001"}), state.clone()) + let del_dec_res = handler_dec + .execute(serde_json::json!({"action": "delete", "id": "ADR-0001"}), state.clone()) .await; assert!(del_dec_res.is_ok()); - // LogTechDebt & ResolveTechDebt & ListTechDebt - let log_td = LogTechDebtHandler; - let td_res = log_td + // TechDebtHandler log, list, resolve + let handler_td = TechDebtHandler; + let td_res = handler_td .execute( serde_json::json!({ + "action": "log", "description": "Replace unwraps with error handling", "ideal_solution": "Use Result and AppError enum" }), @@ -2544,17 +2036,15 @@ mod tests { .unwrap(); assert!(td_res.contains("Tech debt logged")); - let list_td = ListTechDebtHandler; - let list_td_res = list_td - .execute(serde_json::json!({"include_resolved": true}), state.clone()) + let list_td_res = handler_td + .execute(serde_json::json!({"action": "list", "include_resolved": true}), state.clone()) .await .unwrap(); assert!(list_td_res.contains("Replace unwraps")); let debt_id = state.code.tech_debts.read_with(|debts| debts[0].id.clone()); - let res_td = ResolveTechDebtHandler; - let res_td_res = res_td - .execute(serde_json::json!({"id": debt_id}), state.clone()) + let res_td_res = handler_td + .execute(serde_json::json!({"action": "resolve", "id": debt_id}), state.clone()) .await; assert!(res_td_res.is_ok()); @@ -2737,31 +2227,6 @@ mod tests { .unwrap(); assert!(search_ef_res.contains("E0425")); - let sug_ef = SuggestErrorFixHandler; - let sug_ef_res = sug_ef - .execute(serde_json::json!({"stack_trace": "E0425"}), state.clone()) - .await - .unwrap(); - assert!(sug_ef_res.contains("E0425")); - - // Preferences & Recent changes - let learn_p = LearnPreferenceHandler; - let learn_p_res = learn_p - .execute( - serde_json::json!({"key": "theme", "value": "dark"}), - state.clone(), - ) - .await - .unwrap(); - assert_eq!(learn_p_res, "Preference learned"); - - let read_p = ReadPreferencesHandler; - let read_p_res = read_p - .execute(serde_json::json!({}), state.clone()) - .await - .unwrap(); - assert!(read_p_res.contains("dark")); - let q_rec = QueryRecentChangesHandler; let q_rec_res = q_rec .execute(serde_json::json!({}), state.clone()) @@ -2785,22 +2250,26 @@ mod tests { .unwrap(); assert!(preflight_res.contains("active_tasks")); - // ManageCheckpoint / CheckpointState / RestoreState - let chk_state = CheckpointStateHandler; - let chk_state_res = chk_state - .execute(serde_json::json!({"name": "test_chk"}), state.clone()) - .await - .unwrap(); - assert!(chk_state_res.contains("saved successfully")); - - let rest_state = RestoreStateHandler; - let rest_state_res = rest_state - .execute(serde_json::json!({"name": "test_chk"}), state.clone()) - .await - .unwrap(); - assert!(rest_state_res.contains("restored successfully")); - + // ManageCheckpoint let mg_chk = ManageCheckpointHandler; + let chk_state_res = mg_chk + .execute( + serde_json::json!({"action": "create", "name_or_id": "test_chk"}), + state.clone(), + ) + .await + .unwrap(); + assert!(chk_state_res.contains("created successfully")); + + let rest_state_res = mg_chk + .execute( + serde_json::json!({"action": "restore", "name_or_id": "test_chk"}), + state.clone(), + ) + .await + .unwrap(); + assert!(rest_state_res.contains("restored successfully") || rest_state_res.contains("restored memory state from snapshot")); + let mg_list = mg_chk .execute(serde_json::json!({"action": "list"}), state.clone()) .await @@ -2816,7 +2285,7 @@ mod tests { .unwrap(); assert!(mg_del.contains("deleted successfully")); - // SearchSnippetsHybrid with populated snippet + // SnippetsHandler search with populated snippet state.code.snippets.modify(|snips| { snips.push(crate::models::Snippet { name: "Rust MCP Helper".to_string(), @@ -2830,10 +2299,10 @@ mod tests { }); }); - let search_hyb = SearchSnippetsHybridHandler; + let search_hyb = crate::handlers::workspaces::SnippetsHandler; let search_hyb_res = search_hyb .execute( - serde_json::json!({"query": "rust mcp", "tags": ["rust"]}), + serde_json::json!({"action": "search", "query": "rust mcp", "tags": ["rust"]}), state.clone(), ) .await @@ -2841,9 +2310,10 @@ mod tests { assert!(search_hyb_res.contains("Rust MCP Helper")); // Decision supersedes - let dec_super = log_dec + let dec_super = handler_dec .execute( serde_json::json!({ + "action": "log", "title": "Use Axum 0.7", "context": "Upgrade Axum", "decision": "Adopt Axum 0.7", @@ -2856,20 +2326,19 @@ mod tests { .unwrap(); assert!(dec_super.contains("Logged decision")); - // ListTechDebt summary levels - let list_debt = ListTechDebtHandler; - let list_compact = list_debt + // TechDebtHandler summary levels + let list_compact = handler_td .execute( - serde_json::json!({"namespace": "global", "include_resolved": true, "summary_level": "compact"}), + serde_json::json!({"action": "list", "namespace": "global", "include_resolved": true, "summary_level": "compact"}), state.clone(), ) .await .unwrap(); assert!(list_compact.contains("description")); - let list_full = list_debt + let list_full = handler_td .execute( - serde_json::json!({"namespace": "global", "include_resolved": true, "summary_level": "full", "max_tokens": 10}), + serde_json::json!({"action": "list", "namespace": "global", "include_resolved": true, "summary_level": "full", "max_tokens": 10}), state.clone(), ) .await @@ -3010,8 +2479,8 @@ mod tests { assert!(!q_hyp_res.is_empty()); // DeleteDecision non-existent - let del_dec_err = del_dec - .execute(serde_json::json!({"id": "ADR-9999"}), state.clone()) + let del_dec_err = handler_dec + .execute(serde_json::json!({"action": "delete", "id": "ADR-9999"}), state.clone()) .await; assert!(del_dec_err.is_err()); @@ -3076,10 +2545,10 @@ mod tests { .unwrap(); assert!(!omni_kg.is_empty()); - // RestoreState non-existent error - let rest_err = rest_state + // ManageCheckpoint non-existent restore error + let rest_err = mg_chk .execute( - serde_json::json!({"name": "non_existent_chk"}), + serde_json::json!({"action": "restore", "name_or_id": "non_existent_chk"}), state.clone(), ) .await; @@ -3177,9 +2646,8 @@ mod tests { .unwrap(); assert!(auto_res.contains("Session checkpoint created")); - // SuggestErrorFix matching score > 0.2 - let sug_fix = SuggestErrorFixHandler; - let sug_res = sug_fix + // SearchErrorFixes matching score > 0.2 + let sug_res = search_ef .execute( serde_json::json!({ "stack_trace": "Import struct into scope error on line 42", @@ -3219,17 +2687,7 @@ mod tests { .unwrap(); assert!(next_act_res.contains("actionable_count")); - // GetPreflightContext with branch & pinned files - state.project.pinned_files.modify(|p| { - p.push(crate::models::PinnedFile { - file_path: "src/lib.rs".to_string(), - timestamp: 0, - namespace: "global".to_string(), - git_branch: Some("main".to_string()), - ..Default::default() - }); - }); - + // GetPreflightContext with branch let preflight = GetPreflightContextHandler; let pre_res = preflight .execute( @@ -3238,6 +2696,6 @@ mod tests { ) .await .unwrap(); - assert!(pre_res.contains("src/lib.rs")); + assert!(pre_res.contains("active_tasks")); } } diff --git a/server/src/handlers/notes.rs b/server/src/handlers/notes.rs index 0c7c3af..1bd3cd6 100644 --- a/server/src/handlers/notes.rs +++ b/server/src/handlers/notes.rs @@ -7,100 +7,6 @@ use serde_json::Value; use std::collections::HashSet; use std::sync::Arc; -pub struct StickyNotesHandler; - -#[async_trait] -impl McpTool for StickyNotesHandler { - fn name(&self) -> &'static str { - "sticky_notes" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "sticky_notes", - "Ephemeral sticky notes with TTL (add, read, delete, clear)", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: StickyNotesTool = serde_json::from_value(args).map_err(|e| e.to_string())?; - match req.action { - StickyNoteAction::Add => { - let content = req.content.ok_or_else(|| { - crate::error::AppError::Internal("Missing required parameter 'content' for action 'add'. Next step: Provide non-empty 'content' string in request and retry.".to_string()) - })?; - let now = crate::handlers::utils::now_secs(); - let expires_at = if let Some(ttl) = req.ttl_seconds { - Some(now + ttl) - } else if req.session_only.unwrap_or(false) { - Some(now + 14400) - } else { - None - }; - - state.code.sticky.modify(|notes| { - notes.push(StickyNote { - timestamp: now, - content, - expires_at, - ..Default::default() - }); - }); - Ok("Sticky note added.".to_string()) - } - StickyNoteAction::Read => { - let now = crate::handlers::utils::now_secs(); - let has_expired = state.code.sticky.read_with(|notes| { - notes.iter().any(|n| matches!(n.expires_at, Some(exp) if exp <= now)) - }); - if has_expired { - state.code.sticky.modify(|notes| { - notes.retain(|n| { - if let Some(exp) = n.expires_at { - exp > now - } else { - true - } - }); - }); - } - let active_notes = state.code.sticky.read_with(|notes| notes.clone()); - Ok(serde_json::to_string(&active_notes)?) - } - StickyNoteAction::Delete => { - let idx = req.index.ok_or_else(|| { - crate::error::AppError::Internal("Missing required parameter 'index' for action 'delete'. Next step: Provide 1-based sticky note 'index' integer in request and retry.".to_string()) - })?; - let mut success = false; - state.code.sticky.modify(|notes| { - if idx > 0 && idx <= notes.len() { - notes.remove(idx - 1); - success = true; - } else if idx == 0 && !notes.is_empty() { - notes.remove(0); - success = true; - } - }); - if success { - Ok("Sticky note deleted.".to_string()) - } else { - Err(crate::error::AppError::Internal( - "Invalid sticky note index. Next step: Call sticky_notes with action='read' to view active notes and valid indices, then retry.".to_string(), - )) - } - } - StickyNoteAction::Clear => { - state.code.sticky.modify(|notes| { - notes.clear(); - }); - Ok("All sticky notes cleared.".to_string()) - } - } - } -} - - - pub struct HandoffMemosHandler; #[async_trait] @@ -255,7 +161,7 @@ impl McpTool for PromoteToEntityHandler { fn schema(&self) -> Value { crate::mcp::tool_def::( "promote_to_entity", - "Promote a transient sticky note or active task observation into a permanent Knowledge Graph entity.", + "Promote a task observation or finding into a permanent Knowledge Graph entity.", ) } @@ -285,10 +191,6 @@ impl McpTool for PromoteToEntityHandler { } }); - state.code.sticky.modify(|notes| { - notes.retain(|n| !n.content.contains(&obs)); - }); - Ok(format!( "Promoted transient note into permanent entity '{}'.", req.entity_name @@ -302,47 +204,6 @@ mod tests { use serde_json::json; use tempfile::tempdir; - #[tokio::test] - async fn test_notes_lifecycle() { - let dir = tempdir().unwrap(); - let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); - - let handler = StickyNotesHandler; - let args = json!({ - "action": "add", - "content": "Buy milk", - }); - - let res = handler - .execute(args, state.clone()) - .await - .map_err(|e| crate::error::AppError::Internal(e.to_string())) - .unwrap(); - assert!(res.contains("Sticky note added")); - - let res2 = handler - .execute(json!({"action": "read"}), state.clone()) - .await - .map_err(|e| crate::error::AppError::Internal(e.to_string())) - .unwrap(); - assert!(res2.contains("Buy milk")); - - let args2 = json!({"action": "delete", "index": 1}); - let res3 = 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 = handler - .execute(json!({"action": "read"}), state.clone()) - .await - .map_err(|e| crate::error::AppError::Internal(e.to_string())) - .unwrap(); - assert!(!res4.contains("Buy milk")); - } - #[tokio::test] async fn test_handoff_and_summaries() { let dir = tempdir().unwrap(); @@ -392,22 +253,6 @@ mod tests { .unwrap(); assert!(!res4.is_empty()); } - - #[tokio::test] - async fn test_invalid_sticky_note_action() { - let dir = tempfile::tempdir().unwrap(); - let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); - - let handler = StickyNotesHandler; - let invalid_args = serde_json::json!({ - "action": "invalid_action" - }); - - let res = handler - .execute(invalid_args, state.clone()) - .await; - assert!(res.is_err()); - } } diff --git a/server/src/handlers/tasks.rs b/server/src/handlers/tasks.rs index f2387be..fdefb8f 100644 --- a/server/src/handlers/tasks.rs +++ b/server/src/handlers/tasks.rs @@ -81,7 +81,7 @@ impl McpTool for TasksHandler { crate::error::AppError::Internal("Missing required parameter 'id' for action 'update'. Next step: Provide task 'id' string in request and retry.".to_string()) })?; let status = req.status.ok_or_else(|| { - crate::error::AppError::Internal("Missing required parameter 'status' for action 'update'. Next step: Provide valid 'status' ('pending', 'completed', or 'cancelled') in request and retry.".to_string()) + crate::error::AppError::Internal("Missing required parameter 'status' for action 'update'. Next step: Provide valid 'status' ('pending', 'active', 'in_progress', 'blocked', 'completed', or 'cancelled') in request and retry.".to_string()) })?; let target_status = status.to_lowercase(); let mut found = false; @@ -113,6 +113,7 @@ impl McpTool for TasksHandler { if let Some(dep_task) = tasks.iter().find(|dt| dt.id == *dep_id) && dep_task.status != "completed" && dep_task.status != "done" + && dep_task.status != "cancelled" { uncompleted_deps.push(dep_task.title.as_str()); } @@ -132,7 +133,10 @@ impl McpTool for TasksHandler { .iter() .filter(|t| t.parent_id.as_deref() == Some(target_id_ref)) { - if child.status != "completed" && child.status != "done" { + if child.status != "completed" + && child.status != "done" + && child.status != "cancelled" + { uncompleted_children.push(child.title.as_str()); } } @@ -303,6 +307,7 @@ impl McpTool for TasksHandler { crate::error::AppError::Internal("Missing required parameter 'criteria' for action 'set_criteria'. Next step: Provide array of acceptance criteria strings in request and retry.".to_string()) })?; let mut success = false; + let mut updated_task: Option = None; state.project.tasks.modify(|tasks| { if let Some(task) = tasks.iter_mut().find(|t| t.id == id || t.title == id) { task.acceptance_criteria = criteria_list @@ -315,10 +320,15 @@ impl McpTool for TasksHandler { }) .collect(); task.updated_at = crate::handlers::utils::now_secs(); + updated_task = Some(task.clone()); success = true; } }); if success { + if let Some(ref ut) = updated_task { + let idx = state.get_search_index().await; + drop(idx.index_task(ut)); + } state.broadcast_task_event(TaskEvent { task_id: id.clone(), status: "criteria_set".to_string(), @@ -343,6 +353,7 @@ impl McpTool for TasksHandler { let proof_str = req.proof.unwrap_or_else(|| "Verified".to_string()); let mut success = false; let mut already_met = false; + let mut updated_task: Option = None; state.project.tasks.modify(|tasks| { if let Some(task) = tasks.iter_mut().find(|t| t.id == id || t.title == id) { if let Some(ac) = task.acceptance_criteria.iter_mut().next() { @@ -351,7 +362,8 @@ impl McpTool for TasksHandler { } else { ac.is_met = true; success = true; - task.updated_at = crate::handlers::utils::now_secs(); + task.updated_at = crate::handlers::utils::now_secs(); + updated_task = Some(task.clone()); } } else { task.acceptance_criteria @@ -362,11 +374,16 @@ impl McpTool for TasksHandler { ..Default::default() }); task.updated_at = crate::handlers::utils::now_secs(); + updated_task = Some(task.clone()); success = true; } } }); if success { + if let Some(ref ut) = updated_task { + let idx = state.get_search_index().await; + drop(idx.index_task(ut)); + } state.broadcast_task_event(TaskEvent { task_id: id.clone(), status: "verified".to_string(), diff --git a/server/src/handlers/vision.rs b/server/src/handlers/vision.rs index 56a3e3b..f9ffb43 100644 --- a/server/src/handlers/vision.rs +++ b/server/src/handlers/vision.rs @@ -1,6 +1,6 @@ use crate::router::McpTool; use crate::state::MemoryState; -use crate::tools::{ReadClipboardTool, WriteClipboardTool}; +use crate::tools::{ClipboardAction, ClipboardTool}; use arboard::{Clipboard, ImageData}; use async_trait::async_trait; use image::{ImageBuffer, imageops::FilterType}; @@ -8,104 +8,6 @@ use serde_json::{Value, json}; use std::borrow::Cow; use std::sync::Arc; -pub struct WriteClipboardHandler; - -#[async_trait] -impl McpTool for WriteClipboardHandler { - fn name(&self) -> &'static str { - "write_clipboard" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "write_clipboard", - "Write text, HTML, files, and/or images directly to the OS clipboard.", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let tool_args: WriteClipboardTool = serde_json::from_value(args) - .map_err(|e| crate::error::AppError::Internal(format!("Invalid args: {}", e)))?; - - let res = tokio::task::spawn_blocking(move || { - let mut msgs = Vec::new(); - - if let Some(text) = &tool_args.text { - let mut written = false; - if let Ok(mut clipboard) = Clipboard::new() { - for _ in 0..3 { - if clipboard.set_text(text).is_ok() { - written = true; - break; - } - std::thread::sleep(std::time::Duration::from_millis(20)); - } - } - if written { - msgs.push("Wrote text"); - } - } - - // Handle arboard for image - if let Some(image_path) = &tool_args.image_path { - match image::open(image_path) { - Ok(img) => { - let img = if img.width() > 2048 || img.height() > 2048 { - img.resize(2048, 2048, FilterType::Triangle) - } else { - img - }; - let rgba = img.into_rgba8(); - let (w, h) = rgba.dimensions(); - let raw_bytes = rgba.into_raw(); - let mut written = false; - if let Ok(mut clipboard) = arboard::Clipboard::new() { - for _ in 0..3 { - let img_data = ImageData { - width: w as usize, - height: h as usize, - bytes: Cow::Borrowed(&raw_bytes), - }; - if clipboard.set_image(img_data).is_ok() { - written = true; - break; - } - std::thread::sleep(std::time::Duration::from_millis(20)); - } - } - if written { - msgs.push("Wrote image"); - } else { - 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 - ))); - } - } - } - - if msgs.is_empty() { - Ok("No valid clipboard data provided or failed to write.".to_string()) - } else { - Ok(format!( - "Successfully populated clipboard: {}", - msgs.join(", ") - )) - } - }) - .await - .map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??; - - state.record_activity("clipboard", &res, None); - Ok(res) - } -} pub fn get_native_clipboard_text() -> Option { for _ in 0..3 { @@ -121,7 +23,53 @@ pub fn get_native_clipboard_text() -> Option { None } +#[cfg(target_os = "windows")] +fn get_windows_png_clipboard_image() -> Option { + use windows_sys::Win32::System::DataExchange::{ + CloseClipboard, GetClipboardData, IsClipboardFormatAvailable, OpenClipboard, + RegisterClipboardFormatW, + }; + use windows_sys::Win32::System::Memory::{GlobalLock, GlobalSize, GlobalUnlock}; + let format_name: Vec = "PNG\0".encode_utf16().collect(); + unsafe { + let format_id = RegisterClipboardFormatW(format_name.as_ptr()); + if format_id == 0 { + return None; + } + for _ in 0..3 { + if OpenClipboard(std::ptr::null_mut()) != 0 { + let has_format = IsClipboardFormatAvailable(format_id) != 0; + let mut img_opt = None; + if has_format { + let handle = GetClipboardData(format_id); + if !handle.is_null() { + let size = GlobalSize(handle as _); + if size > 0 { + let ptr = GlobalLock(handle as _); + if !ptr.is_null() { + let slice = std::slice::from_raw_parts(ptr as *const u8, size); + img_opt = image::load_from_memory(slice).ok(); + GlobalUnlock(handle as _); + } + } + } + } + CloseClipboard(); + if img_opt.is_some() { + return img_opt; + } + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + } + None +} pub fn get_native_clipboard_image() -> Option { + #[cfg(target_os = "windows")] + if let Some(img) = get_windows_png_clipboard_image() { + return Some(img); + } for _ in 0..3 { if let Ok(mut clipboard) = arboard::Clipboard::new() { if let Ok(image_data) = clipboard.get_image() { @@ -139,105 +87,6 @@ pub fn get_native_clipboard_image() -> Option { None } -pub struct ReadClipboardHandler; - -#[async_trait] -impl McpTool for ReadClipboardHandler { - fn name(&self) -> &'static str { - "read_clipboard" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "read_clipboard", - "Read the current OS clipboard. Extracts text, HTML, file paths, and/or an image if available.", - ) - } - - async fn execute(&self, _args: Value, state: Arc) -> crate::error::Result { - let result = - tokio::task::spawn_blocking(move || -> crate::error::Result { - let mut out = serde_json::Map::new(); - - if let Some(text) = get_native_clipboard_text() { - out.insert("text".into(), json!(text)); - } - - if let Some(dynamic_img) = get_native_clipboard_image() { - let mut img = dynamic_img; - let max_dim = 1024; - if img.width() > max_dim || img.height() > max_dim { - img = img.resize(max_dim, max_dim, FilterType::Lanczos3); - } - let rgb_img = img.into_rgb8(); - - let cache_dir = dirs::home_dir() - .unwrap_or_default() - .join(".gemini/mcp_memory/clipboard"); - let _ = std::fs::create_dir_all(&cache_dir); - - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .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() - { - let path_str = file_path.to_string_lossy().to_string(); - out.insert("image_path".into(), json!(path_str)); - #[cfg(target_os = "windows")] - if let Some(rest) = path_str.strip_prefix("C:\\") { - let wsl_path = format!("/mnt/c/{}", rest.replace('\\', "/")); - out.insert("image_path_wsl".into(), json!(wsl_path)); - } else if let Some(rest) = path_str.strip_prefix("c:\\") { - let wsl_path = format!("/mnt/c/{}", rest.replace('\\', "/")); - out.insert("image_path_wsl".into(), json!(wsl_path)); - } - - if let Ok(bytes) = std::fs::read(&file_path) { - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); - out.insert("image_base64".into(), json!(b64)); - } - } - } - - Ok(Value::Object(out)) - }) - .await - .map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??; - - 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(); - 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())); - } - } - - state.record_activity("clipboard", "Read contents from OS clipboard", None); - Ok::(serde_json::to_string_pretty(&final_obj)?) - } -} - - -use crate::tools::{ClipboardAction, ClipboardTool}; - pub struct ClipboardHandler; #[async_trait] @@ -257,23 +106,161 @@ impl McpTool for ClipboardHandler { let req: ClipboardTool = serde_json::from_value(args).map_err(|e| e.to_string())?; match req.action { ClipboardAction::Read => { - ReadClipboardHandler.execute(serde_json::json!({}), state).await + let result = + tokio::task::spawn_blocking(move || -> crate::error::Result { + let mut out = serde_json::Map::new(); + + if let Some(text) = get_native_clipboard_text() { + out.insert("text".into(), json!(text)); + } + + if let Some(dynamic_img) = get_native_clipboard_image() { + let mut img = dynamic_img; + let max_dim = 1024; + if img.width() > max_dim || img.height() > max_dim { + img = img.resize(max_dim, max_dim, FilterType::Lanczos3); + } + let rgb_img = img.into_rgb8(); + + let mut jpeg_bytes = std::io::Cursor::new(Vec::new()); + if rgb_img.write_to(&mut jpeg_bytes, image::ImageFormat::Jpeg).is_ok() { + let bytes = jpeg_bytes.into_inner(); + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + out.insert("image_base64".into(), json!(b64)); + + let cache_dir = dirs::home_dir() + .unwrap_or_default() + .join(".gemini/mcp_memory/clipboard"); + let _ = std::fs::create_dir_all(&cache_dir); + let file_path = cache_dir.join("clipboard_latest.jpg"); + if std::fs::write(&file_path, &bytes).is_ok() { + let path_str = file_path.to_string_lossy().to_string(); + out.insert("image_path".into(), json!(path_str)); + #[cfg(target_os = "windows")] + if let Some(rest) = path_str.strip_prefix("C:\\") { + let wsl_path = format!("/mnt/c/{}", rest.replace('\\', "/")); + out.insert("image_path_wsl".into(), json!(wsl_path)); + } else if let Some(rest) = path_str.strip_prefix("c:\\") { + let wsl_path = format!("/mnt/c/{}", rest.replace('\\', "/")); + out.insert("image_path_wsl".into(), json!(wsl_path)); + } + } + } + } + Ok(Value::Object(out)) + }) + .await + .map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??; + + 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(); + 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())); + } + } + + state.record_activity("clipboard", "Read contents from OS clipboard", None); + Ok::(serde_json::to_string_pretty(&final_obj)?) } ClipboardAction::Write => { - WriteClipboardHandler.execute( - serde_json::json!({ - "text": req.text, - "html": req.html, - "files": req.files, - "image_path": req.image_path - }), - state, - ).await + let text_opt = req.text; + let image_path_opt = req.image_path; + let res = tokio::task::spawn_blocking(move || { + let mut msgs = Vec::new(); + + if let Some(text) = &text_opt { + let mut written = false; + if let Ok(mut clipboard) = Clipboard::new() { + for _ in 0..3 { + if clipboard.set_text(text).is_ok() { + written = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + } + if written { + msgs.push("Wrote text"); + } + } + + if let Some(image_path) = &image_path_opt { + match image::open(image_path) { + Ok(img) => { + let img = if img.width() > 2048 || img.height() > 2048 { + img.resize(2048, 2048, FilterType::Triangle) + } else { + img + }; + let rgba = img.into_rgba8(); + let (w, h) = rgba.dimensions(); + let raw_bytes = rgba.into_raw(); + let mut written = false; + if let Ok(mut clipboard) = arboard::Clipboard::new() { + for _ in 0..3 { + let img_data = ImageData { + width: w as usize, + height: h as usize, + bytes: Cow::Borrowed(&raw_bytes), + }; + if clipboard.set_image(img_data).is_ok() { + written = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + } + if written { + msgs.push("Wrote image"); + } else { + 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 + ))); + } + } + } + + if msgs.is_empty() { + Ok("No valid clipboard data provided or failed to write.".to_string()) + } else { + Ok(format!( + "Successfully populated clipboard: {}", + msgs.join(", ") + )) + } + }) + .await + .map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??; + + state.record_activity("clipboard", &res, None); + Ok(res) } } } } + #[cfg(test)] mod tests { use super::*; @@ -286,9 +273,10 @@ mod tests { async fn test_write_clipboard() { let dir = tempdir().unwrap(); let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); - let handler = WriteClipboardHandler; + let handler = ClipboardHandler; let args = json!({ + "action": "write", "text": "test_text" }); @@ -310,10 +298,10 @@ mod tests { async fn test_read_clipboard() { let dir = tempdir().unwrap(); let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); - let handler = ReadClipboardHandler; + let handler = ClipboardHandler; let result = handler - .execute(json!({}), state) + .execute(json!({"action": "read"}), state) .await .map_err(|e| format!("Failed to read clipboard: {}", e)) .unwrap(); @@ -327,10 +315,10 @@ mod tests { async fn test_read_clipboard_empty() { let dir = tempfile::tempdir().unwrap(); let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); - let handler = ReadClipboardHandler; + let handler = ClipboardHandler; let result = handler - .execute(serde_json::json!({}), state) + .execute(serde_json::json!({"action": "read"}), state) .await .map_err(|e| format!("Failed to read clipboard: {}", e)) .unwrap(); diff --git a/server/src/handlers/workspaces.rs b/server/src/handlers/workspaces.rs index 74a5aee..6872b5b 100644 --- a/server/src/handlers/workspaces.rs +++ b/server/src/handlers/workspaces.rs @@ -6,700 +6,13 @@ use async_trait::async_trait; use serde_json::Value; use std::sync::Arc; -pub struct PinFileHandler; -#[async_trait] -impl McpTool for PinFileHandler { - fn name(&self) -> &'static str { - "pin_file" - } - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "pin_file", - "Pin a file path in the workspace context to prioritize it across subagent turns.", - ) - } - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: PinFileTool = serde_json::from_value(args).map_err(|e| e.to_string())?; - state.project.pinned_files.modify(|pinned| { - pinned.retain(|p| p.namespace != req.namespace || p.file_path != req.file_path); - pinned.push(crate::models::PinnedFile { - namespace: req.namespace, - file_path: req.file_path, - timestamp: crate::handlers::utils::now_secs(), - git_branch: req.git_branch, - repo_name: req.repo_name, - symbol_or_line: req.symbol_or_line, - pin_reason: req.pin_reason, - }); - }); - Ok("File pinned".to_string()) - } -} -pub struct UnpinFileHandler; -#[async_trait] -impl McpTool for UnpinFileHandler { - fn name(&self) -> &'static str { - "unpin_file" - } - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "unpin_file", - "Unpin a file path from the workspace context.", - ) - } - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: UnpinFileTool = serde_json::from_value(args).map_err(|e| e.to_string())?; - state.project.pinned_files.modify(|pinned| { - pinned.retain(|p| p.namespace != req.namespace || p.file_path != req.file_path) - }); - Ok("File unpinned".to_string()) - } -} - -pub struct ListPinnedFilesHandler; - -#[async_trait] -impl McpTool for ListPinnedFilesHandler { - fn name(&self) -> &'static str { - "list_pinned_files" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "list_pinned_files", - "List active pinned files in the workspace context, optionally filtered by namespace or branch.", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: ListPinnedFilesTool = serde_json::from_value(args).map_err(|e| e.to_string())?; - let data = state.project.pinned_files.read_with(|pinned| { - let filtered: Vec<_> = pinned - .iter() - .filter(|p| { - let ns_match = match &req.namespace { - Some(ns) => &p.namespace == ns, - std::option::Option::None => true, - }; - let branch_match = match &req.git_branch { - Some(branch) => { - p.git_branch.is_none() - || p.git_branch.as_deref() == Some(branch.as_str()) - } - std::option::Option::None => true, - }; - ns_match && branch_match - }) - .collect(); - Ok::(serde_json::to_string(&filtered)?) - })?; - Ok(data) - } -} - -pub struct StoreSnippetHandler; - -#[async_trait] -impl McpTool for StoreSnippetHandler { - fn name(&self) -> &'static str { - "store_snippet" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "store_snippet", - "Store or replace a reusable code snippet with vector embedding and search tags.", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - 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 snippet = Snippet { - name: req.name, - language: req.language, - code: req.code, - description: req.description, - updated_at: crate::handlers::utils::now_secs(), - tags: req.tags.unwrap_or_default(), - embedding, - namespace: req - .namespace - .unwrap_or_else(|| crate::models::default_namespace()), - repo_name: req.repo_name, - origin_file: req.origin_file, - line_range: req.line_range, - }; - - let idx = state.get_search_index().await; - drop(idx.index_snippet(&snippet)); - - state.code.snippets.modify(|snippets| { - snippets.retain(|s| s.name != req_name); - snippets.push(snippet); - }); - - Ok(format!("Snippet '{}' stored.", req_name).to_string()) - } -} - -pub struct TagSnippetHandler; - -#[async_trait] -impl McpTool for TagSnippetHandler { - fn name(&self) -> &'static str { - "tag_snippet" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "tag_snippet", - "Add organizational tags to an existing stored code snippet.", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: TagSnippetTool = serde_json::from_value(args).map_err(|e| e.to_string())?; - let mut found = false; - state.code.snippets.modify(|snippets| { - for s in snippets.iter_mut() { - if s.name == req.name { - for tag in &req.tags { - if !s.tags.contains(tag) { - s.tags.push(tag.clone()); - } - } - found = true; - break; - } - } - }); - - if found { - Ok(format!( - "Snippet '{}' tagged with: {:?}", - req.name, req.tags - )) - } else { - Err(crate::error::AppError::Internal(format!( - "Snippet '{}' not found.", - req.name - ))) - } - } -} - -pub struct SearchSnippetsHandler; - -#[async_trait] -impl McpTool for SearchSnippetsHandler { - fn name(&self) -> &'static str { - "search_snippets" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "search_snippets", - "Search stored code snippets using Hybrid BM25 + Vector ranking or text search", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: SearchSnippetsTool = serde_json::from_value(args).map_err(|e| e.to_string())?; - let query = req.query; - let limit = req.limit.unwrap_or(10); - let include_body = req.include_body.unwrap_or(false); - let tags_filter = req.tags.unwrap_or_default(); - - let data = state.code.snippets.read_with(|snippets| { - let results: Vec<_> = snippets - .iter() - .filter(|s| { - let matches_text = query.is_empty() - || contains_ignore_ascii_case(&s.name, &query) - || contains_ignore_ascii_case(&s.description, &query) - || contains_ignore_ascii_case(&s.language, &query) - || s.tags.iter().any(|t| contains_ignore_ascii_case(t, &query)); - - let matches_tags = tags_filter.is_empty() - || tags_filter - .iter() - .any(|tf| s.tags.iter().any(|st| contains_ignore_ascii_case(st, tf))); - - matches_text && matches_tags - }) - .take(limit) - .map(|s| { - if include_body { - serde_json::json!(s) - } else { - serde_json::json!({ - "name": s.name, - "description": s.description, - "language": s.language, - "tags": s.tags - }) - } - }) - .collect(); - Ok::(serde_json::to_string_pretty(&results)?) - })?; - Ok(data) - } -} - -pub struct DeleteSnippetHandler; - -#[async_trait] -impl McpTool for DeleteSnippetHandler { - fn name(&self) -> &'static str { - "delete_snippet" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::("delete_snippet", "Execute delete_snippet") - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: DeleteSnippetTool = serde_json::from_value(args).map_err(|e| e.to_string())?; - let mut deleted = false; - state.code.snippets.modify(|snippets| { - let orig = snippets.len(); - snippets.retain(|s| s.name != req.name); - deleted = snippets.len() < orig; - }); - if deleted { - let idx = state.get_search_index().await; - 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(), - )) - } - } -} - -pub struct SaveContextWorkspaceHandler; - -#[async_trait] -impl McpTool for SaveContextWorkspaceHandler { - fn name(&self) -> &'static str { - "save_context_workspace" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "save_context_workspace", - "Execute save_context_workspace", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: SaveContextWorkspaceTool = - serde_json::from_value(args).map_err(|e| e.to_string())?; - state.project.context_workspaces.modify(|ws| { - ws.retain(|w| w.namespace != req.namespace || w.name != req.name); - ws.push(crate::models::ContextWorkspace { - namespace: req.namespace, - name: req.name, - pinned_files: req.pinned_files, - active_task_ids: req.active_task_ids, - saved_at: crate::handlers::utils::now_secs(), - description: req.description, - repo_name: req.repo_name, - git_branch: req.git_branch, - vcs_revision: req.vcs_revision, - }); - }); - Ok("Context workspace saved".to_string()) - } -} - -pub struct LoadContextWorkspaceHandler; - -#[async_trait] -impl McpTool for LoadContextWorkspaceHandler { - fn name(&self) -> &'static str { - "load_context_workspace" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "load_context_workspace", - "Execute load_context_workspace", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: LoadContextWorkspaceTool = - serde_json::from_value(args).map_err(|e| e.to_string())?; - let data = state.project.context_workspaces.read_with(|ws| { - let filtered: Vec<_> = ws - .iter() - .filter(|w| w.namespace == req.namespace && w.name == req.name) - .collect(); - serde_json::to_string(&filtered.first()).map_err(|e| e.to_string()) - })?; - Ok(data) - } -} - -pub struct ListContextWorkspacesHandler; - -#[async_trait] -impl McpTool for ListContextWorkspacesHandler { - fn name(&self) -> &'static str { - "list_context_workspaces" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "list_context_workspaces", - "Execute list_context_workspaces", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: ListContextWorkspacesTool = - serde_json::from_value(args).map_err(|e| e.to_string())?; - let data = state.project.context_workspaces.read_with(|ws| { - let filtered: Vec<_> = ws - .iter() - .filter(|w| req.namespace.as_ref().is_none_or(|ns| &w.namespace == ns)) - .collect(); - Ok::(serde_json::to_string(&filtered)?) - })?; - Ok(data) - } -} - -pub struct DeleteContextWorkspaceHandler; - -#[async_trait] -impl McpTool for DeleteContextWorkspaceHandler { - fn name(&self) -> &'static str { - "delete_context_workspace" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "delete_context_workspace", - "Delete a saved context workspace", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: crate::tools::DeleteContextWorkspaceTool = - serde_json::from_value(args).map_err(|e| e.to_string())?; - - let mut found = false; - state.project.context_workspaces.modify(|ws| { - if let Some(pos) = ws - .iter() - .position(|w| w.namespace == req.namespace && w.name == req.name) - { - ws.remove(pos); - found = true; - } - }); - - if found { - Ok("Context workspace deleted successfully".to_string()) - } else { - Err(crate::error::AppError::Internal( - "Context workspace not found".to_string(), - )) - } - } -} - -pub struct AddPrChecklistItemHandler; - -#[async_trait] -impl McpTool for AddPrChecklistItemHandler { - fn name(&self) -> &'static str { - "add_pr_checklist_item" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "add_pr_checklist_item", - "Execute add_pr_checklist_item", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: AddPrChecklistItemTool = - serde_json::from_value(args).map_err(|e| e.to_string())?; - state.project.pr_checklists.modify(|items| { - items.push(crate::models::PrChecklistItem { - namespace: req.namespace, - id: uuid::Uuid::new_v4().to_string(), - description: req.description, - is_checked: req.is_checked.unwrap_or(false), - category: req.category, - repo_name: req.repo_name, - automated_check: req.automated_check, - }) - }); - Ok("PR checklist item added".to_string()) - } -} - -pub struct GetPrChecklistHandler; - -#[async_trait] -impl McpTool for GetPrChecklistHandler { - fn name(&self) -> &'static str { - "get_pr_checklist" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::("get_pr_checklist", "Execute get_pr_checklist") - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: GetPrChecklistTool = serde_json::from_value(args).map_err(|e| e.to_string())?; - let data = state.project.pr_checklists.read_with(|items| { - let filtered: Vec<_> = items - .iter() - .filter(|i| i.namespace == req.namespace) - .collect(); - Ok::(serde_json::to_string(&filtered)?) - })?; - Ok(data) - } -} - -pub struct ClearPrChecklistHandler; - -#[async_trait] -impl McpTool for ClearPrChecklistHandler { - fn name(&self) -> &'static str { - "clear_pr_checklist" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "clear_pr_checklist", - "Execute clear_pr_checklist", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: ClearPrChecklistTool = serde_json::from_value(args).map_err(|e| e.to_string())?; - state - .project - .pr_checklists - .modify(|items| items.retain(|i| i.namespace != req.namespace)); - Ok("PR checklist cleared".to_string()) - } -} - -pub struct PinnedFilesHandler; - -#[async_trait] -impl McpTool for PinnedFilesHandler { - fn name(&self) -> &'static str { - "pinned_files" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "pinned_files", - "Consolidated pinned files focus management (pin, unpin, list)", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: PinnedFilesTool = serde_json::from_value(args).map_err(|e| e.to_string())?; - let ns = req - .namespace - .unwrap_or_else(|| crate::models::default_namespace()); - match req.action { - PinnedFileAction::Pin => { - let path = req.path.ok_or_else(|| { - crate::error::AppError::Internal("Missing required parameter 'path' for action 'pin'. Next step: Provide absolute or relative file 'path' in request and retry.".to_string()) - })?; - PinFileHandler - .execute( - serde_json::json!({ - "file_path": path, - "namespace": ns, - "git_branch": req.git_branch, - "pin_reason": req.pin_reason, - "symbol_or_line": req.symbol_or_line, - "repo_name": req.repo_name, - }), - state, - ) - .await - } - PinnedFileAction::Unpin => { - let path = req.path.ok_or_else(|| { - crate::error::AppError::Internal("Missing required parameter 'path' for action 'unpin'. Next step: Provide file 'path' in request and retry.".to_string()) - })?; - UnpinFileHandler - .execute( - serde_json::json!({"file_path": path, "namespace": ns}), - state, - ) - .await - } - PinnedFileAction::List => { - ListPinnedFilesHandler - .execute(serde_json::json!({"namespace": ns}), state) - .await - } - } - } -} - -pub struct ContextWorkspacesHandler; - -#[async_trait] -impl McpTool for ContextWorkspacesHandler { - fn name(&self) -> &'static str { - "context_workspaces" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "context_workspaces", - "Consolidated context workspace state management (save, load, list, delete, diff)", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: ContextWorkspacesTool = serde_json::from_value(args).map_err(|e| e.to_string())?; - let ns = req - .namespace - .unwrap_or_else(|| crate::models::default_namespace()); - match req.action { - ContextWorkspaceAction::Save => { - let name = req.name.ok_or_else(|| { - crate::error::AppError::Internal("Missing required parameter 'name' for action 'save'. Next step: Provide workspace 'name' string in request and retry.".to_string()) - })?; - let pinned_files = req.pinned_files.unwrap_or_default(); - let active_task_ids = req.active_task_ids.unwrap_or_default(); - SaveContextWorkspaceHandler - .execute( - serde_json::json!({ - "name": name, - "namespace": ns, - "pinned_files": pinned_files, - "active_task_ids": active_task_ids, - "description": req.description, - "repo_name": req.repo_name, - "git_branch": req.git_branch, - "vcs_revision": req.vcs_revision, - }), - state, - ) - .await - } - ContextWorkspaceAction::Load => { - let name = req.name.ok_or_else(|| { - crate::error::AppError::Internal("Missing required parameter 'name' for action 'load'. Next step: Provide workspace 'name' string in request and retry.".to_string()) - })?; - LoadContextWorkspaceHandler - .execute(serde_json::json!({"name": name, "namespace": ns}), state) - .await - } - ContextWorkspaceAction::List => { - ListContextWorkspacesHandler - .execute(serde_json::json!({"namespace": ns}), state) - .await - } - ContextWorkspaceAction::Delete => { - let name = req.name.ok_or_else(|| { - crate::error::AppError::Internal("Missing required parameter 'name' for action 'delete'. Next step: Provide workspace 'name' string in request and retry.".to_string()) - })?; - DeleteContextWorkspaceHandler - .execute(serde_json::json!({"name": name, "namespace": ns}), state) - .await - } - ContextWorkspaceAction::Diff => { - let name = req.name.ok_or_else(|| { - crate::error::AppError::Internal("Missing required parameter 'name' for action 'diff'. Next step: Provide primary workspace 'name' in request and retry.".to_string()) - })?; - let other = req.other_name.ok_or_else(|| { - crate::error::AppError::Internal("Missing required parameter 'other_name' for action 'diff'. Next step: Provide secondary workspace 'other_name' in request and retry.".to_string()) - })?; - DiffContextWorkspacesHandler.execute(serde_json::json!({"workspace_a": name, "workspace_b": other, "namespace": ns}), state).await - } - } - } -} - -pub struct PrChecklistHandler; - -#[async_trait] -impl McpTool for PrChecklistHandler { - fn name(&self) -> &'static str { - "pr_checklist" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "pr_checklist", - "Consolidated PR checklist management (add, get, clear)", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: PrChecklistTool = serde_json::from_value(args).map_err(|e| e.to_string())?; - let ns = req - .namespace - .unwrap_or_else(|| crate::models::default_namespace()); - match req.action { - PrChecklistAction::Add => { - let desc = req.description.ok_or_else(|| { - crate::error::AppError::Internal("Missing required parameter 'description' for action 'add'. Next step: Provide checklist item 'description' string in request and retry.".to_string()) - })?; - AddPrChecklistItemHandler - .execute( - serde_json::json!({ - "description": desc, - "namespace": ns, - "category": req.category, - "automated_check": req.automated_check, - "is_checked": req.is_checked, - "repo_name": req.repo_name, - }), - state, - ) - .await - } - PrChecklistAction::Get => { - GetPrChecklistHandler - .execute(serde_json::json!({"namespace": ns}), state) - .await - } - PrChecklistAction::Clear => { - ClearPrChecklistHandler - .execute(serde_json::json!({"namespace": ns}), state) - .await - } - } - } -} pub struct SnippetsHandler; @@ -726,51 +39,178 @@ impl McpTool for SnippetsHandler { let lang = req.language.unwrap_or_else(|| "text".to_string()); let code = req.code.unwrap_or_default(); let desc = req.description.unwrap_or_default(); - StoreSnippetHandler - .execute( - serde_json::json!({ - "name": name, - "language": lang, - "code": code, - "description": desc, - "tags": req.tags, - "namespace": req.namespace, - "repo_name": req.repo_name, - "origin_file": req.origin_file, - "line_range": req.line_range, - }), - state, - ) + + let text_to_embed = format!( + "Name: {}\nLanguage: {}\nDescription: {}\nCode: {}", + name, lang, desc, code + ); + let embedding = crate::embedding::generate_embedding_async(text_to_embed) .await + .ok(); + let snippet = Snippet { + name: name.clone(), + language: lang, + code, + description: desc, + updated_at: crate::handlers::utils::now_secs(), + tags: req.tags.unwrap_or_default(), + embedding, + namespace: req + .namespace + .unwrap_or_else(|| crate::models::default_namespace()), + repo_name: req.repo_name, + origin_file: req.origin_file, + line_range: req.line_range, + }; + + let idx = state.get_search_index().await; + drop(idx.index_snippet(&snippet)); + + state.code.snippets.modify(|snippets| { + snippets.retain(|s| s.name != name); + snippets.push(snippet); + }); + + Ok(format!("Snippet '{}' stored.", name)) } SnippetAction::Search => { - let q = req.query.or(req.name).unwrap_or_default(); - if req.hybrid.unwrap_or(false) { - crate::handlers::meta::SearchSnippetsHybridHandler - .execute(serde_json::json!({"query": q, "tags": req.tags}), state) - .await + let query = req.query.or(req.name).unwrap_or_default(); + let tags_filter = req.tags.unwrap_or_default(); + let is_hybrid = req.hybrid.unwrap_or(false); + + if is_hybrid { + let snippets = state.code.snippets.read_with(|s| s.clone()); + let q_tokens: Vec<_> = query + .to_lowercase() + .split_whitespace() + .map(|s| s.to_string()) + .collect(); + + let mut scored: Vec<_> = snippets + .into_iter() + .filter_map(|s| { + let text = format!("{} {} {} {}", s.name, s.description, s.language, s.code) + .to_lowercase(); + let mut score = 0.0; + for tok in &q_tokens { + if text.contains(tok) { + score += 1.0; + } + } + if !tags_filter.is_empty() { + for tag in &tags_filter { + if s.tags + .iter() + .any(|t| t.to_lowercase() == tag.to_lowercase()) + { + score += 2.0; + } + } + } + if score > 0.0 { + Some(serde_json::json!({ + "name": s.name, + "description": s.description, + "language": s.language, + "tags": s.tags, + "score": score, + "code": s.code + })) + } else { + None + } + }) + .collect(); + + scored.sort_by(|a, b| { + b["score"] + .as_f64() + .unwrap_or(0.0) + .total_cmp(&a["score"].as_f64().unwrap_or(0.0)) + }); + scored.truncate(10); + Ok(serde_json::to_string_pretty(&scored)?) } else { - SearchSnippetsHandler - .execute(serde_json::json!({"query": q, "tags": req.tags}), state) - .await + let data = state.code.snippets.read_with(|snippets| { + let results: Vec<_> = snippets + .iter() + .filter(|s| { + let matches_text = query.is_empty() + || contains_ignore_ascii_case(&s.name, &query) + || contains_ignore_ascii_case(&s.description, &query) + || contains_ignore_ascii_case(&s.language, &query) + || contains_ignore_ascii_case(&s.code, &query) + || s.tags.iter().any(|t| contains_ignore_ascii_case(t, &query)); + + let matches_tags = tags_filter.is_empty() + || tags_filter + .iter() + .any(|tf| s.tags.iter().any(|st| contains_ignore_ascii_case(st, tf))); + + matches_text && matches_tags + }) + .take(10) + .map(|s| { + serde_json::json!({ + "name": s.name, + "description": s.description, + "language": s.language, + "tags": s.tags, + "code": s.code, + }) + }) + .collect::>(); + serde_json::to_string_pretty(&results) + })?; + Ok(data) } } SnippetAction::Delete => { let id = req.name.or(req.id).or(req.query).ok_or_else(|| { crate::error::AppError::Internal("Missing required parameter 'name', 'id', or 'query' for action 'delete'. Next step: Provide snippet ID/name in request and retry.".to_string()) })?; - DeleteSnippetHandler - .execute(serde_json::json!({"name": id}), state) - .await + let mut deleted = false; + state.code.snippets.modify(|snippets| { + let initial = snippets.len(); + snippets.retain(|s| s.name != id); + deleted = snippets.len() < initial; + }); + if deleted { + Ok(format!("Snippet '{}' deleted.", id)) + } else { + Err(crate::error::AppError::Internal(format!( + "Snippet '{}' not found.", + id + ))) + } } SnippetAction::Tag => { let id = req.name.or(req.id).or(req.query).ok_or_else(|| { crate::error::AppError::Internal("Missing required parameter 'name', 'id', or 'query' for action 'tag'. Next step: Provide snippet ID/name and 'tags' array in request and retry.".to_string()) })?; let tags = req.tags.unwrap_or_default(); - TagSnippetHandler - .execute(serde_json::json!({"name": id, "tags": tags}), state) - .await + let mut found = false; + state.code.snippets.modify(|snippets| { + for s in snippets.iter_mut() { + if s.name == id { + for tag in &tags { + if !s.tags.contains(tag) { + s.tags.push(tag.clone()); + } + } + found = true; + break; + } + } + }); + if found { + Ok(format!("Snippet '{}' tagged with: {:?}", id, tags)) + } else { + Err(crate::error::AppError::Internal(format!( + "Snippet '{}' not found.", + id + ))) + } } } } @@ -778,200 +218,6 @@ impl McpTool for SnippetsHandler { use crate::handlers::utils::*; -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - use tempfile::tempdir; - - #[tokio::test] - async fn test_workspace_lifecycle() { - let dir = tempdir().unwrap(); - let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); - - let save_handler = SaveContextWorkspaceHandler; - let args = json!({ - "name": "wsl-session", - "namespace": "global", - "pinned_files": ["src/main.rs"], - "active_task_ids": ["123"] - }); - - let res = save_handler.execute(args, state.clone()).await.unwrap(); - assert_eq!(res, "Context workspace saved"); - - let list_handler = ListContextWorkspacesHandler; - let res2 = list_handler - .execute(json!({"namespace": "global"}), state.clone()) - .await - .unwrap(); - assert!(res2.contains("wsl-session")); - assert!(res2.contains("src/main.rs")); - } - - #[tokio::test] - async fn test_snippets_and_pr_checklists() { - let dir = tempdir().unwrap(); - let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); - - let store_handler = StoreSnippetHandler; - let args_snip = json!({ - "name": "init_db", - "language": "sql", - "description": "Initialize database", - "code": "SELECT 1;", - "namespace": "global" - }); - let res1 = store_handler - .execute(args_snip, state.clone()) - .await - .unwrap(); - assert_eq!(res1, "Snippet 'init_db' stored."); - - let search_handler = SearchSnippetsHandler; - let _res2 = search_handler - .execute( - json!({"query": "SELECT", "namespace": "global"}), - state.clone(), - ) - .await - .unwrap(); - // Skip assertion since it requires index rebuild - - let pr_handler = AddPrChecklistItemHandler; - let args_pr = json!({ - "description": "Check coverage", - "namespace": "global" - }); - let res3 = pr_handler.execute(args_pr, state.clone()).await.unwrap(); - assert_eq!(res3, "PR checklist item added"); - - let get_pr = GetPrChecklistHandler; - let res4 = get_pr - .execute(json!({"namespace": "global"}), state.clone()) - .await - .unwrap(); - assert!(res4.contains("Check coverage")); - - // Pin lifecycle - let pin = PinFileHandler; - let res5 = pin - .execute( - json!({"file_path": "src/lib.rs", "namespace": "global"}), - state.clone(), - ) - .await - .unwrap(); - assert_eq!(res5, "File pinned"); - - let list_pins = ListPinnedFilesHandler; - let res6 = list_pins - .execute(json!({"namespace": "global"}), state.clone()) - .await - .unwrap(); - assert!(res6.contains("src/lib.rs")); - - let unpin = UnpinFileHandler; - let res7 = unpin - .execute( - json!({"file_path": "src/lib.rs", "namespace": "global"}), - state.clone(), - ) - .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(); - assert_eq!(res8, "PR checklist cleared"); - } - - #[tokio::test] - async fn test_workspace_and_subagent_handlers() { - let temp_dir = tempfile::tempdir().unwrap(); - let state = Arc::new(MemoryState::new(temp_dir.path().to_str().unwrap())); - - // Snapshots - let create_snap = CreateSnapshotHandler; - let snap_res = create_snap - .execute( - json!({"description": "Test snap", "namespace": "global"}), - state.clone(), - ) - .await - .unwrap(); - assert!(snap_res.contains("SNAP-")); - let snap_id = snap_res.split_whitespace().nth(1).unwrap(); - - let restore_snap = RestoreSnapshotHandler; - let restore_res = restore_snap - .execute(json!({"snapshot_id": snap_id}), state.clone()) - .await - .unwrap(); - assert!(restore_res.contains("Successfully restored")); - - // Subagent Namespace - let create_sub_ns = CreateSubagentNamespaceHandler; - let sub_ns_res = create_sub_ns - .execute(json!({"subagent_id": "test_agent"}), state.clone()) - .await - .unwrap(); - assert!(sub_ns_res.contains("subagent-test_agent")); - - let manage_sub_ns = ManageSubagentNamespaceHandler; - let manage_res = manage_sub_ns - .execute( - json!({ - "subagent_id": "test_agent", - "action": "create" - }), - state.clone(), - ) - .await - .unwrap(); - assert!(manage_res.contains("initialized")); - - // Context Workspace Diff - let save_ws = SaveContextWorkspaceHandler; - save_ws.execute(json!({"name": "ws_a", "description": "Workspace A", "pinned_files": ["src/lib.rs"], "active_task_ids": []}), state.clone()).await.unwrap(); - save_ws.execute(json!({"name": "ws_b", "description": "Workspace B", "pinned_files": ["src/main.rs"], "active_task_ids": []}), state.clone()).await.unwrap(); - - let diff_ws = DiffContextWorkspacesHandler; - let diff_res = diff_ws - .execute( - json!({"workspace_a": "ws_a", "workspace_b": "ws_b"}), - state.clone(), - ) - .await - .unwrap(); - assert!(diff_res.contains("workspace_a")); - } - - #[tokio::test] - async fn test_diff_context_workspaces_handler() { - let dir = tempfile::tempdir().unwrap(); - let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); - - let save_ws = SaveContextWorkspaceHandler; - save_ws.execute(json!({"name": "ws_1", "description": "Workspace 1", "pinned_files": ["src/lib.rs"], "active_task_ids": []}), state.clone()).await.unwrap(); - save_ws.execute(json!({"name": "ws_2", "description": "Workspace 2", "pinned_files": ["src/main.rs"], "active_task_ids": []}), state.clone()).await.unwrap(); - - let diff_ws = DiffContextWorkspacesHandler; - let diff_res = diff_ws - .execute( - json!({"workspace_a": "ws_1", "workspace_b": "ws_2"}), - state.clone(), - ) - .await - .unwrap(); - assert!(diff_res.contains("ws_1")); - } -} - use crate::tools::ReadDirectoryArchitectureTool; use std::fs; @@ -1117,88 +363,6 @@ impl McpTool for SemanticCodeSearchHandler { } } -pub struct CreateSnapshotHandler; - -#[async_trait] -impl McpTool for CreateSnapshotHandler { - fn name(&self) -> &'static str { - "create_snapshot" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::("create_snapshot", "Create a state snapshot") - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - 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(), - ..Default::default() - }; - - 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::("restore_snapshot", "Restore a state snapshot") - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - 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 ManageSubagentNamespaceHandler; #[async_trait] @@ -1232,8 +396,9 @@ impl McpTool for ManageSubagentNamespaceHandler { )) } SubagentNamespaceAction::Purge => { - state.project.pinned_files.modify(|files| { - files.retain(|f| f.namespace != subagent_ns); + state.modify_graph(|g| { + g.entities.retain(|_, e| e.namespace != subagent_ns); + g.relations.retain(|r| r.namespace != subagent_ns); }); state.record_activity( "namespace", @@ -1270,6 +435,7 @@ impl McpTool for ManageSubagentNamespaceHandler { git_branch: e.git_branch.clone(), ..Default::default() }); + global_entry.namespace = "global".to_string(); for obs in e.observations { if !global_entry.observations.contains(&obs) { global_entry.observations.push(obs); @@ -1290,12 +456,6 @@ impl McpTool for ManageSubagentNamespaceHandler { } }); - if purge { - state.project.pinned_files.modify(|files| { - files.retain(|f| f.namespace != subagent_ns); - }); - } - Ok(format!( "Subagent namespace '{}' condensed: promoted {} entities and {} relations to global graph.", subagent_ns, promoted_entities, promoted_relations @@ -1305,208 +465,119 @@ impl McpTool for ManageSubagentNamespaceHandler { } } -pub struct CreateSubagentNamespaceHandler; +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use tempfile::tempdir; -#[async_trait] -impl McpTool for CreateSubagentNamespaceHandler { - fn name(&self) -> &'static str { - "create_subagent_namespace" - } + #[tokio::test] + async fn test_snippets_lifecycle() { + let dir = tempdir().unwrap(); + let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "create_subagent_namespace", - "Create subagent namespace", - ) - } + let snippets_handler = SnippetsHandler; - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - 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::( - "purge_subagent_namespace", - "Purge subagent namespace", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - 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); + // 1. Store + let store_args = json!({ + "action": "store", + "name": "init_db", + "language": "sql", + "description": "Initialize database", + "code": "SELECT 1;", + "tags": ["db", "sql"] }); + let res1 = snippets_handler + .execute(store_args, state.clone()) + .await + .unwrap(); + assert_eq!(res1, "Snippet 'init_db' stored."); - state.record_activity( - "namespace", - &format!("Purged subagent namespace {}", ns), - None, - ); - Ok(format!("Subagent memory namespace '{}' purged.", ns)) - } -} + // 2. Search + let search_args = json!({ + "action": "search", + "query": "SELECT" + }); + let res2 = snippets_handler + .execute(search_args, state.clone()) + .await + .unwrap(); + assert!(res2.contains("init_db")); -pub struct CondenseSubagentNamespaceHandler; + // 3. Tag + let tag_args = json!({ + "action": "tag", + "name": "init_db", + "tags": ["postgres"] + }); + let res3 = snippets_handler + .execute(tag_args, state.clone()) + .await + .unwrap(); + assert!(res3.contains("tagged with")); -#[async_trait] -impl McpTool for CondenseSubagentNamespaceHandler { - fn name(&self) -> &'static str { - "condense_subagent_namespace" + // 4. Delete + let delete_args = json!({ + "action": "delete", + "name": "init_db" + }); + let res4 = snippets_handler + .execute(delete_args, state.clone()) + .await + .unwrap(); + assert_eq!(res4, "Snippet 'init_db' deleted."); } - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "condense_subagent_namespace", - "Auto-condense a subagent's memory namespace and promote its entities/relations to the global Knowledge Graph.", - ) - } + #[tokio::test] + async fn test_subagent_namespace_lifecycle() { + let dir = tempdir().unwrap(); + let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: CondenseSubagentNamespaceTool = - serde_json::from_value(args).map_err(|e| e.to_string())?; - let subagent_ns = format!("subagent-{}", req.subagent_id); - let purge = req.purge_after_promotion.unwrap_or(true); + let manage_sub_ns = ManageSubagentNamespaceHandler; - let mut promoted_entities = 0; - let mut promoted_relations = 0; + // Create + let create_res = manage_sub_ns + .execute( + json!({ + "subagent_id": "auditor_1", + "action": "create" + }), + state.clone(), + ) + .await + .unwrap(); + assert!(create_res.contains("initialized")); + // Add subagent entity into graph state.modify_graph(|g| { - let sub_entities: Vec = g - .entities - .values() - .filter(|e| e.namespace == subagent_ns) - .cloned() - .collect(); - - for mut e in sub_entities { - promoted_entities += 1; - e.namespace = "global".to_string(); - let global_entry = g.entities.entry(e.name.clone()).or_insert_with(|| Entity { - name: e.name.clone(), - entity_type: e.entity_type.clone(), - observations: Vec::new(), - namespace: "global".to_string(), - git_branch: e.git_branch.clone(), + g.entities.insert( + "SubNode".to_string(), + Entity { + name: "SubNode".to_string(), + entity_type: "Module".to_string(), + observations: vec!["test observation".to_string()], + namespace: "subagent-auditor_1".to_string(), ..Default::default() - }); - for obs in e.observations { - if !global_entry.observations.contains(&obs) { - global_entry.observations.push(obs); - } - } - } - - for r in &mut g.relations { - if r.namespace == subagent_ns { - r.namespace = "global".to_string(); - promoted_relations += 1; - } - } - - if purge { - g.entities.retain(|_, e| e.namespace != subagent_ns); - g.relations.retain(|r| r.namespace != subagent_ns); - } + }, + ); }); - if purge { - state.project.pinned_files.modify(|files| { - files.retain(|f| f.namespace != subagent_ns); - }); - } + // Condense + let condense_res = manage_sub_ns + .execute( + json!({ + "subagent_id": "auditor_1", + "action": "condense", + "purge_after_promotion": true + }), + state.clone(), + ) + .await + .unwrap(); + assert!(condense_res.contains("condensed: promoted 1 entities")); - Ok(format!( - "Consolidated subagent namespace '{}': Promoted {} entities, {} relations to global. Purged: {}", - subagent_ns, promoted_entities, promoted_relations, purge - )) - } -} - -pub struct DiffContextWorkspacesHandler; - -#[async_trait] -impl McpTool for DiffContextWorkspacesHandler { - fn name(&self) -> &'static str { - "diff_context_workspaces" - } - - fn schema(&self) -> Value { - crate::mcp::tool_def::( - "diff_context_workspaces", - "Execute diff_context_workspaces", - ) - } - - async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { - let req: DiffContextWorkspacesTool = - serde_json::from_value(args).map_err(|e| e.to_string())?; - - let workspaces = state.project.context_workspaces.read_with(|ws| ws.clone()); - let ws_a = workspaces.iter().find(|w| w.name == req.workspace_a); - let ws_b = workspaces.iter().find(|w| w.name == req.workspace_b); - - if ws_a.is_none() || ws_b.is_none() { - return Err(crate::error::AppError::Internal(format!( - "One or both context workspaces not found: '{}' found={}, '{}' found={}", - req.workspace_a, - ws_a.is_some(), - req.workspace_b, - ws_b.is_some() - ))); - } - - let a = ws_a.unwrap(); - let b = ws_b.unwrap(); - - let set_files_a: std::collections::HashSet<_> = a.pinned_files.iter().cloned().collect(); - let set_files_b: std::collections::HashSet<_> = b.pinned_files.iter().cloned().collect(); - - let added_files: Vec<_> = set_files_b.difference(&set_files_a).cloned().collect(); - let removed_files: Vec<_> = set_files_a.difference(&set_files_b).cloned().collect(); - let shared_files: Vec<_> = set_files_a.intersection(&set_files_b).cloned().collect(); - - let set_tasks_a: std::collections::HashSet<_> = a.active_task_ids.iter().cloned().collect(); - let set_tasks_b: std::collections::HashSet<_> = b.active_task_ids.iter().cloned().collect(); - - let added_tasks: Vec<_> = set_tasks_b.difference(&set_tasks_a).cloned().collect(); - let removed_tasks: Vec<_> = set_tasks_a.difference(&set_tasks_b).cloned().collect(); - let shared_tasks: Vec<_> = set_tasks_a.intersection(&set_tasks_b).cloned().collect(); - - let diff = serde_json::json!({ - "workspace_a": a.name, - "workspace_b": b.name, - "pinned_files": { - "added_in_b": added_files, - "removed_in_b": removed_files, - "shared": shared_files - }, - "active_tasks": { - "added_in_b": added_tasks, - "removed_in_b": removed_tasks, - "shared": shared_tasks - } - }); - - Ok(serde_json::to_string_pretty(&diff)?) + // Verify entity is now global + let graph = state.graph.cache.read().unwrap(); + assert_eq!(graph.entities.get("SubNode").unwrap().namespace, "global"); } } diff --git a/server/src/instructions.md b/server/src/instructions.md index 5e63e4e..34516d1 100644 --- a/server/src/instructions.md +++ b/server/src/instructions.md @@ -36,34 +36,11 @@ The server consolidates granular single-purpose tools into domain-named smart to - `action: "update"`: Update milestone status (requires `id`, `status: "active" | "completed" | "cancelled"`). - `action: "list"`: List milestones (optional `namespace`). -* **`sticky_notes`**: Ephemeral scratchpad notes with TTL. - - `action: "add"`: Add note (requires `content`, optional `ttl_seconds`, `session_only`). - - `action: "read"`: Read all active notes. - - `action: "delete"`: Delete note by index (requires 1-based `index`). - - `action: "clear"`: Clear all sticky notes. - * **`handoff_memos`**: Session handoff notes for future agents. - `action: "leave"`: Leave a memo (requires `content`, optional `vcs_revision`, `repo_name`, `git_branch`, `blockers: Vec`, `action_items: Vec`, `expires_at`). - `action: "read"`: Read active handoff memos. - `action: "clear"`: Clear memos. -* **`pinned_files`**: Focus file working set. - - `action: "pin"`: Pin file to focus set (requires `path`, optional `pin_reason`, `symbol_or_line`, `repo_name`, `git_branch`). - - `action: "unpin"`: Unpin file from focus set (requires `path`). - - `action: "list"`: List pinned files (optional `namespace`). - -* **`context_workspaces`**: Workspace context state snapshots. - - `action: "save"`: Save context workspace (requires `name`, optional `pinned_files`, `active_task_ids`, `description`, `git_branch`, `vcs_revision`, `repo_name`). - - `action: "load"`: Restore saved context workspace (requires `name`). - - `action: "list"`: List saved context workspaces. - - `action: "delete"`: Delete saved context workspace (requires `name`). - - `action: "diff"`: Compare two saved context workspaces (requires `name`, `other_name`). - -* **`pr_checklist`**: Pre-commit and PR checklist. - - `action: "add"`: Add checklist item (requires `description`, optional `category: "Security" | "Testing" | "Formatting" | ...`, `automated_check`, `is_checked`, `repo_name`). - - `action: "get"`: Get PR checklist items. - - `action: "clear"`: Clear PR checklist. - * **`snippets`**: Reusable code snippet vault. - `action: "store"`: Store snippet (requires `query` as name, optional `language`, `code`, `description`, `tags`, `origin_file`, `line_range`, `repo_name`). - `action: "search"`: Search snippet vault (optional `query`, `tags`, `hybrid: true`). @@ -90,7 +67,6 @@ The server consolidates granular single-purpose tools into domain-named smart to * **`clipboard`**: OS Clipboard management. - `action: "read"`: Read OS clipboard. - `action: "write"`: Write text/html/files/image to clipboard. - - `action: "toggle_watch"`: Toggle auto-clipboard watcher. --- @@ -189,9 +165,8 @@ To maintain maximum security, speed, and cross-platform reliability: * **Zero-Allocation Stream Formatting**: Graph condensation loops (`condense_graph_worker`) format node/relation subgraphs into stream buffers via `std::fmt::Write`, eliminating intermediate String allocation overhead. * **Single-Handle OS Clipboard Retries & Image Downsampling**: `WriteClipboardHandler` initializes OS clipboard handles once per operation and downsamples images exceeding $2048 \times 2048$ resolution before writing raw RGBA bytes. * **Zero Transaction Drop Persistence Guarantee**: `Store::modify` automatically spawns an async task to execute `push_async` with channel backpressure if `push` encounters queue saturation, ensuring zero data loss under spike write loads. -* **Non-Blocking Read Lock Sticky Notes**: `StickyNotesHandler` (`action = "read"`) queries notes using shared read locks, executing write pruning only when expired items exist. * **Token-Budgeted Query Projections**: Decision queries (`query_decisions`) support `limit` caps and compact `include_body: false` projections for token budget optimization. -* **Serde Parameter & Enum Ergonomics**: Action enums (`StickyNoteAction`, `SnippetSearchMode`, `Relation`) support case-insensitive variants and common synonyms (`create`/`add`, `remove`/`delete`, `list`/`read`, `source`/`from`, `target`/`to`, `relationType`/`relation_type`) ensuring seamless LLM tool execution. +* **Serde Parameter & Enum Ergonomics**: Action enums (`SnippetSearchMode`, `Relation`) support case-insensitive variants and common synonyms (`create`/`add`, `remove`/`delete`, `list`/`read`, `source`/`from`, `target`/`to`, `relationType`/`relation_type`) ensuring seamless LLM tool execution. * **Atomic Search Index Swaps**: `MemoryState::rebuild_index` constructs and populates a new `MemoryIndex` instance in isolation before performing an atomic pointer swap (`*self.search_index.write().await = new_idx`), eliminating transient empty search result windows. * **Non-Blocking Tantivy Search Queries**: `MemoryIndex::search` queries current index searcher snapshots without executing synchronous disk commit locks, preventing query stalls during heavy background indexing. * **Zero-Allocation HashSet<&str> Snippet Deduplication**: `indexer.rs` utilizes borrowed `HashSet<&str>` name lookups during snippet batch modifications, eliminating heap string re-allocations inside the store write lock. @@ -214,7 +189,7 @@ To maintain maximum security, speed, and cross-platform reliability: * **Embedding Input Safeguard**: `generate_embedding_async` returns explicit errors for empty/0-length text inputs instead of returning empty vectors, preventing downstream vector dimension mismatches during cosine similarity calculations. * **Path Traversal Security Guards**: `validate_safe_path` enforces path canonicalization and rejects relative parent traversal components (`..`) across file and process log handlers (`GetRecentLogsTool`, `WatchProcessLogsTool`). * **Watcher Map Memory Eviction**: Proactive daemon file watcher in `watcher.rs` caps `last_processed` map size at 1,000 entries and purges entries older than 10 minutes to prevent monotonic memory leakage. -* **Comprehensive Serde Casing Aliases**: All 11 consolidated tool action enums (`TaskAction`, `MilestoneAction`, `PinnedFileAction`, `ContextWorkspaceAction`, `PrChecklistAction`, `SnippetAction`, `DecisionAction`, `TechDebtAction`, `EnvAction`, `ClipboardAction`, `HandoffMemoAction`) include serde alias attributes supporting `snake_case`, `camelCase`, `PascalCase`, and uppercase variants for maximum LLM casing resilience. +* **Comprehensive Serde Casing Aliases**: All 8 consolidated tool action enums (TaskAction, MilestoneAction, SnippetAction, DecisionAction, TechDebtAction, EnvAction, ClipboardAction, HandoffMemoAction) include serde alias attributes supporting `snake_case`, `camelCase`, `PascalCase`, and uppercase variants for maximum LLM casing resilience. * **Two-Phase Graph Condensation**: `condense_graph_worker` uses a 2-phase commit (non-destructive `read_with` -> graph insert -> prune by timestamp/content) to prevent data loss if summarization or graph insertion fails. * **Store Write Lock Minimization**: `Store::modify` and `Store::modify_async` unblock concurrent readers during JSON serialization by releasing the write lock immediately after mutating memory state. * **Redb Database Lock Retry Backoff**: `init_db` retries transient Redb lock contention with exponential backoff (3 attempts, 150ms delay) before falling back. diff --git a/server/src/lib.rs b/server/src/lib.rs index c7b2ac3..64a9169 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -105,9 +105,7 @@ pub async fn ttl_sweeper_worker(state: Arc) { state.project.tasks.modify(|tasks| { tasks.retain(|t| t.expires_at.is_none_or(|exp| exp > now)); }); - state.code.sticky.modify(|notes| { - notes.retain(|n| n.expires_at.is_none_or(|exp| exp > now)); - }); + state.telemetry.handoff_memos.modify(|memos| { memos.retain(|m| m.expires_at.is_none_or(|exp| exp > now)); }); @@ -139,48 +137,6 @@ pub async fn condense_graph_worker(state: Arc) { .unwrap_or_default() .as_secs(); - let sticky_condensation = state.code.sticky.read_with(|notes| { - if notes.len() > threshold { - let mut sorted = notes.clone(); - sorted.sort_by_key(|n| n.timestamp); - let to_remove = sorted.len() - (threshold / 2); - let removed: Vec<_> = sorted.into_iter().take(to_remove).collect(); - let mut content = String::new(); - let mut ids = Vec::new(); - for r in &removed { - content.push_str(&format!("{}\n", r.content)); - ids.push(r.id.clone()); - } - Some((content, ids)) - } else { - None - } - }); - - if let Some((content, ids)) = sticky_condensation { - if !content.is_empty() { - let name = format!("StickyNote History {}", now); - state.modify_graph(|graph| { - graph.entities.insert( - name.clone(), - crate::models::Entity { - name: name.clone(), - entity_type: "Historical Summary".to_string(), - observations: vec![content], - namespace: crate::models::default_namespace(), - git_branch: None, - ..Default::default() - }, - ); - }); - let id_set: std::collections::HashSet = ids.into_iter().collect(); - state.code.sticky.modify(|notes| { - notes.retain(|n| !id_set.contains(&n.id)); - }); - tracing::info!("Condensed sticky notes into Historical Summary."); - } - } - let snippet_condensation = state.code.snippets.read_with(|snippets| { if snippets.len() > threshold { let mut sorted = snippets.clone(); diff --git a/server/src/models.rs b/server/src/models.rs index 1b1237e..9226fd2 100644 --- a/server/src/models.rs +++ b/server/src/models.rs @@ -81,21 +81,6 @@ impl CodeChange { self.branch.as_deref().or(self.git_branch.as_deref()) } } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct StickyNote { - #[serde(default = "generate_uuid")] - pub id: String, - pub timestamp: u64, - pub content: String, - #[serde(default)] - pub expires_at: Option, - #[serde(default = "default_namespace")] - pub namespace: String, - #[serde(default)] - pub category: Option, - #[serde(default)] - pub author: Option, -} pub fn generate_uuid() -> String { uuid::Uuid::new_v4().to_string() @@ -162,18 +147,20 @@ pub fn normalize_relation_type(input: &str) -> String { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)] #[serde(rename_all = "snake_case")] pub enum TaskStatus { + Pending, #[default] Active, InProgress, Blocked, Completed, + Cancelled, } impl TaskStatus { pub fn is_active(&self) -> bool { matches!( self, - TaskStatus::Active | TaskStatus::InProgress | TaskStatus::Blocked + TaskStatus::Pending | TaskStatus::Active | TaskStatus::InProgress | TaskStatus::Blocked ) } @@ -183,10 +170,12 @@ impl TaskStatus { pub fn as_str(&self) -> &'static str { match self { + TaskStatus::Pending => "pending", TaskStatus::Active => "active", TaskStatus::InProgress => "in_progress", TaskStatus::Blocked => "blocked", TaskStatus::Completed => "completed", + TaskStatus::Cancelled => "cancelled", } } } @@ -400,18 +389,6 @@ fn default_adr_status() -> String { "accepted".to_string() } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct Preference { - pub key: String, - pub value: String, - pub updated_at: u64, - #[serde(default = "default_namespace")] - pub namespace: String, - #[serde(default)] - pub category: Option, - #[serde(default)] - pub rationale: Option, -} #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ErrorFix { @@ -434,19 +411,6 @@ pub struct ErrorFix { pub toolchain: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct PinnedFile { - pub namespace: String, - pub file_path: String, - pub timestamp: u64, - pub git_branch: Option, - #[serde(default)] - pub repo_name: Option, - #[serde(default)] - pub symbol_or_line: Option, - #[serde(default)] - pub pin_reason: Option, -} #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct SessionSummary { @@ -554,20 +518,6 @@ pub struct EnvironmentDetail { pub ssh_host: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct PrChecklistItem { - pub namespace: String, - pub id: String, - pub description: String, - #[serde(default)] - pub is_checked: bool, - #[serde(default)] - pub category: Option, - #[serde(default)] - pub automated_check: Option, - #[serde(default)] - pub repo_name: Option, -} #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct TechDebt { @@ -595,22 +545,6 @@ pub struct TechDebt { pub effort_estimate: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ContextWorkspace { - pub name: String, - pub namespace: String, - pub pinned_files: Vec, - pub active_task_ids: Vec, - pub saved_at: u64, - #[serde(default)] - pub repo_name: Option, - #[serde(default)] - pub git_branch: Option, - #[serde(default)] - pub vcs_revision: Option, - #[serde(default)] - pub description: Option, -} #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct GateRecord { diff --git a/server/src/router.rs b/server/src/router.rs index 4b49241..2711a93 100644 --- a/server/src/router.rs +++ b/server/src/router.rs @@ -261,21 +261,9 @@ impl McpResource for SessionDeltaResource { .unwrap_or_else(|e| e.into_inner()); let active_tasks: Vec<_> = tasks.iter().filter(|t| t.is_active()).collect(); - let notes = state - .code - .sticky - .cache - .read() - .unwrap_or_else(|e| e.into_inner()); - let active_notes: Vec<_> = notes - .iter() - .filter(|n| n.expires_at.map_or(true, |exp| exp > now)) - .collect(); - let delta = serde_json::json!({ "recent_changes": recent_changes, "active_tasks": active_tasks, - "active_sticky_notes": active_notes, "timestamp": now, }); @@ -291,7 +279,7 @@ impl McpPrompt for ContextWarmupPrompt { } fn description(&self) -> Option<&'static str> { Some( - "Consolidated context warmup prompt providing active tasks, pinned files, open tech debt, and sticky notes", + "Consolidated context warmup prompt providing active tasks, active decisions, and unresolved tech debt", ) } async fn get( @@ -305,7 +293,7 @@ impl McpPrompt for ContextWarmupPrompt { "role": "user", "content": { "type": "text", - "text": "Perform a complete context warmup. Please read memory://session/delta, memory://tasks/active, memory://tech_debt/active, and memory://pinned_files to synthesize current project focus and active workspace constraints." + "text": "Perform a complete context warmup. Please read memory://session/delta, memory://tasks/active, memory://decisions/active, and memory://tech_debt/unresolved to synthesize current project focus and active workspace constraints." } } ] @@ -397,28 +385,6 @@ impl MemoryHandler { Ok(serde_json::to_string_pretty(&*items)?) } } - struct PinnedFilesResource; - #[async_trait] - impl McpResource for PinnedFilesResource { - fn uri(&self) -> &'static str { - "memory://pinned_files" - } - fn name(&self) -> &'static str { - "Pinned Files" - } - fn description(&self) -> Option<&'static str> { - Some("Currently pinned files in the workspace") - } - async fn read(&self, state: Arc) -> crate::error::Result { - let items = state - .project - .pinned_files - .cache - .read() - .unwrap_or_else(|e| e.into_inner()); - Ok(serde_json::to_string_pretty(&*items)?) - } - } struct MilestonesResource; #[async_trait] @@ -473,7 +439,6 @@ impl MemoryHandler { register_resource!(TerminalHistoryResource); register_resource!(RecentActivitiesResource); - register_resource!(PinnedFilesResource); register_resource!(MilestonesResource); struct ArchiveRoutinePrompt; #[async_trait] @@ -529,8 +494,6 @@ impl MemoryHandler { register!(tasks::TasksHandler); register!(tasks::MilestonesHandler); - - register!(notes::StickyNotesHandler); register!(notes::HandoffMemosHandler); register!(notes::AddSessionSummaryHandler); register!(notes::GenerateStandupReportHandler); @@ -542,16 +505,11 @@ impl MemoryHandler { register!(meta::SearchErrorFixesHandler); register!(meta::LogCodeChangeHandler); register!(meta::QueryRecentChangesHandler); - register!(meta::LearnPreferenceHandler); - register!(meta::ReadPreferencesHandler); register!(meta::OmniSearchHandler); register!(meta::GetProjectHealthHandler); register!(env::EnvironmentHandler); - register!(workspaces::PinnedFilesHandler); - register!(workspaces::ContextWorkspacesHandler); - register!(workspaces::PrChecklistHandler); register!(workspaces::SnippetsHandler); register!(vision::ClipboardHandler); @@ -574,7 +532,6 @@ impl MemoryHandler { register!(meta::GetNextActionableTasksHandler); register!(meta::LogHypothesisHandler); register!(meta::QueryHypothesesHandler); - register!(workspaces::DiffContextWorkspacesHandler); register!(meta::GetPreflightContextHandler); register!(graph::ResolveStaleSymbolsHandler); register!(graph::SummarizeSubgraphHandler); @@ -774,14 +731,13 @@ impl MemoryHandler { "log_error_fix" => "ERROR_FIX", "tech_debt" => "TECH_DEBT", "tasks" | "milestones" => "TASK", - "sticky_notes" | "handoff_memos" => "STICKY_NOTE", + "handoff_memos" => "STICKY_NOTE", "manage_checkpoint" => "CHECKPOINT", "manage_subagent_namespace" => "SUBAGENT", "snippets" => "SNIPPET", "search_web" => "WEB_SEARCH", "omni_search" => "OMNI_SEARCH", "environment" => "ENVIRONMENT", - "pinned_files" | "context_workspaces" | "pr_checklist" => "WORKSPACE", _ => "TOOL", }; @@ -880,157 +836,25 @@ pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) -> .to_string(), ) } - "sticky_notes" => { - let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("add"); - let preview = args - .get("content") - .and_then(|v| v.as_str()) - .map(|c| c.chars().take(40).collect::()) - .unwrap_or_default(); - ( - "Sticky Notes", - format!("{}: {}", act, preview) - .trim_end_matches(": ") - .to_string(), - ) - } - "clipboard" => { - let act = args - .get("action") - .and_then(|v| v.as_str()) - .unwrap_or("read"); - ("Clipboard", act.to_string()) - } - "snippets" => { - let act = args - .get("action") - .and_then(|v| v.as_str()) - .unwrap_or("search"); - let q = args.get("query").and_then(|v| v.as_str()).unwrap_or(""); - ( - "Snippets", - format!("{}: {}", act, q).trim_end_matches(": ").to_string(), - ) - } - "log_code_change" => { - let file = args - .get("file_path") - .or_else(|| args.get("file")) - .or_else(|| args.get("target_file")) - .and_then(|v| v.as_str()); - let summary = args - .get("summary") - .or_else(|| args.get("description")) - .and_then(|v| v.as_str()); - - let d = match (file, summary) { - (Some(f), Some(s)) => format!("{}: {}", f, s), - (Some(f), None) => f.to_string(), - (None, Some(s)) => s.to_string(), - (None, None) => String::new(), - }; - ("Log Code Change", d) - } - "log_error_fix" => { - let d = args - .get("error") - .or_else(|| args.get("summary")) - .or_else(|| args.get("solution")) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - ("Log Error Fix", d) - } "create_entities" => { - let d = if let Some(entities) = args.get("entities").and_then(|v| v.as_array()) { - let names: Vec<&str> = entities - .iter() - .filter_map(|e| { - e.get("name") - .or_else(|| e.get("entity_name")) - .and_then(|n| n.as_str()) - }) - .take(3) - .collect(); - if !names.is_empty() { - let mut s = names.join(", "); - if entities.len() > 3 { - s.push_str(&format!(" (+{} more)", entities.len() - 3)); - } - s - } else { - format!("{} entity/entities", entities.len()) - } - } else if let Some(name) = args - .get("name") - .or_else(|| args.get("entity_name")) - .and_then(|v| v.as_str()) - { - name.to_string() - } else { - String::new() - }; - ("Create Entities", d) - } - "create_relations" => { - let d = if let Some(relations) = args.get("relations").and_then(|v| v.as_array()) { - if let Some(first) = relations.first() { - let from = first.get("from").and_then(|v| v.as_str()).unwrap_or("?"); - let to = first.get("to").and_then(|v| v.as_str()).unwrap_or("?"); - let pred = first - .get("relation_type") - .or_else(|| first.get("predicate")) - .and_then(|v| v.as_str()) - .unwrap_or("->"); - if relations.len() > 1 { - format!("{} {} {} (+{} more)", from, pred, to, relations.len() - 1) - } else { - format!("{} {} {}", from, pred, to) - } - } else { - format!("{} relation(s)", relations.len()) - } - } else { - String::new() - }; - ("Create Relations", d) - } - "omni_search" | "search_graph" | "search_web" => { - let d = args - .get("query") - .and_then(|v| v.as_str()) - .map(|q| format!("\"{}\"", q)) + let names = args + .get("entities") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|e| e.get("name").and_then(|n| n.as_str())) + .collect::>() + .join(", ") + }) .unwrap_or_default(); - ("Search", d) - } - _ => { - let d = args - .get("title") - .or_else(|| args.get("action")) - .or_else(|| args.get("summary")) - .or_else(|| args.get("description")) - .or_else(|| args.get("name")) - .or_else(|| args.get("query")) - .or_else(|| args.get("file_path")) - .or_else(|| args.get("file")) - .or_else(|| args.get("path")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - (name, d.to_string()) + ("Create Entities", names) } + _ => return name.to_string(), }; - - let mut truncated = detail.trim().to_string(); - if truncated.len() > 80 { - let valid_len = truncated.floor_char_boundary(77); - truncated.truncate(valid_len); - truncated.push_str("..."); - } - - if !truncated.is_empty() { - format!("{}: {}", action, truncated) + if detail.is_empty() { + action.to_string() } else { - format!("{}", action) + format!("{}: {}", action, detail) } } @@ -1047,20 +871,20 @@ async fn execute_tool_span( #[cfg(test)] mod tests { use super::*; - use serde_json::json; use tempfile::tempdir; + use serde_json::json; #[test] fn test_format_tool_activity_description() { - let msg1 = format_tool_activity_description( - "log_code_change", - &json!({ "file": "src/lib.rs", "summary": "Refactored library" }), + let msg = format_tool_activity_description( + "tasks", + &serde_json::json!({ "action": "add", "title": "Refactor router" }), ); - assert_eq!(msg1, "Log Code Change: src/lib.rs: Refactored library"); + assert_eq!(msg, "Tasks: add: Refactor router"); let msg2 = format_tool_activity_description( "create_entities", - &json!({ "entities": [{ "name": "EntityA" }, { "name": "EntityB" }] }), + &serde_json::json!({ "entities": [{ "name": "EntityA" }, { "name": "EntityB" }] }), ); assert_eq!(msg2, "Create Entities: EntityA, EntityB"); @@ -1115,7 +939,7 @@ mod tests { assert!( resources_arr .iter() - .any(|r| r["uri"] == "memory://pinned_files") + .any(|r| r["uri"] == "memory://decisions/active") ); assert!( resources_arr @@ -1292,8 +1116,8 @@ mod tests { .as_array() .expect("tools array expected"); assert!( - tools.len() >= 40, - "Expected at least 40 registered MCP tools, found {}", + tools.len() >= 30, + "Expected at least 30 registered MCP tools, found {}", tools.len() ); @@ -1353,16 +1177,11 @@ mod tests { ("tasks", json!({"action": "list"})), ("decisions", json!({"action": "query"})), ("tech_debt", json!({"action": "list"})), - ("sticky_notes", json!({"action": "read"})), - ("pinned_files", json!({"action": "list"})), - ("context_workspaces", json!({"action": "list"})), - ("pr_checklist", json!({"action": "get"})), ("snippets", json!({"action": "search", "query": "test"})), ("clipboard", json!({"action": "read"})), ("environment", json!({"action": "read_fingerprint"})), ("omni_search", json!({"query": "test"})), ("get_project_health", json!({})), - ("read_preferences", json!({})), ("find_orphans", json!({})), ("sweep_graph_health", json!({})), ("get_next_actionable_tasks", json!({})), diff --git a/server/src/search.rs b/server/src/search.rs index 74b2116..42fa3f3 100644 --- a/server/src/search.rs +++ b/server/src/search.rs @@ -253,15 +253,35 @@ impl MemoryIndex { })?; let target_limit = if is_wildcard_fallback { 10 } else { 50 }; - let fetch_limit = if namespace.is_some() { - // Fetch deeper candidate pool so other namespaces do not starve the target namespace - 1000 + let final_query: Box = if let Some(ns) = namespace { + let ns_term_query: Box = Box::new(tantivy::query::TermQuery::new( + tantivy::Term::from_field_text(self.namespace_field, ns), + tantivy::schema::IndexRecordOption::Basic, + )); + let ns_filter: Box = if ns != "global" { + let global_term_query = Box::new(tantivy::query::TermQuery::new( + tantivy::Term::from_field_text(self.namespace_field, "global"), + tantivy::schema::IndexRecordOption::Basic, + )); + Box::new(tantivy::query::BooleanQuery::new(vec![ + (tantivy::query::Occur::Should, ns_term_query), + (tantivy::query::Occur::Should, global_term_query), + ])) + } else { + ns_term_query + }; + + Box::new(tantivy::query::BooleanQuery::new(vec![ + (tantivy::query::Occur::Must, q), + (tantivy::query::Occur::Must, ns_filter), + ])) } else { - target_limit + q }; + let top_docs = searcher.search( - &q, - &tantivy::collector::TopDocs::with_limit(fetch_limit).order_by_score(), + &final_query, + &tantivy::collector::TopDocs::with_limit(target_limit).order_by_score(), )?; let mut results = Vec::with_capacity(target_limit); @@ -287,20 +307,7 @@ impl MemoryIndex { .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); - let doc_ns = retrieved_doc - .get_first(self.namespace_field) - .and_then(|v| v.as_str()) - .unwrap_or(""); - if let Some(ns) = namespace - && doc_ns != ns - && doc_ns != "global" - { - continue; - } results.push((id, doc_type, title, body, score)); - if results.len() >= target_limit { - break; - } } Ok(results) } diff --git a/server/src/state.rs b/server/src/state.rs index 2d019b3..dd82061 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -15,9 +15,6 @@ pub struct GenericEvent { pub struct ProjectStores { pub tasks: Store>, pub milestones: Store>, - pub pr_checklists: Store>, - pub context_workspaces: Store>, - pub pinned_files: Store>, pub snapshots: Store>, } @@ -27,7 +24,6 @@ pub struct CodeStores { pub adrs: Store>, pub error_fixes: Store>, pub tech_debts: Store>, - pub sticky: Store>, pub hypotheses: Store>, } @@ -36,7 +32,6 @@ pub struct EnvironmentStores { pub env_requirements: Store>, pub environments: Store>, pub gates: Store>, - pub prefs: Store>, } pub struct TelemetryStores { @@ -112,9 +107,6 @@ impl MemoryState { project: ProjectStores { tasks: Store::new("tasks", db.clone()), milestones: Store::new("milestones", db.clone()), - 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 { @@ -123,7 +115,6 @@ impl MemoryState { adrs: Store::new("adrs", db.clone()), error_fixes: Store::new("error_fixes", db.clone()), tech_debts: Store::new("tech_debts", db.clone()), - sticky: Store::new("sticky_notes", db.clone()), hypotheses: Store::new("hypotheses", db.clone()), }, env: EnvironmentStores { @@ -131,7 +122,6 @@ impl MemoryState { env_requirements: Store::new("env_requirements", db.clone()), environments: Store::new("environments", db.clone()), gates: Store::new("gates", db.clone()), - prefs: Store::new("preferences", db.clone()), }, telemetry: TelemetryStores { session_summaries: Store::new("session_summaries", db.clone()), @@ -527,21 +517,6 @@ impl SearchService { } } }); - - self.state.code.sticky.read_with(|sticky| { - for note in sticky.iter() { - if uncached_texts.len() >= 50 { - break; - } - let content_preview = note.content.chars().take(200).collect::(); - uncached_texts.push(note.content.clone()); - uncached_meta.push(( - "StickyNote".to_string(), - "sticky".to_string(), - content_preview, - )); - } - }); self.state.read_graph(|graph| { for entity in graph.entities.values() { if let Some(ns) = filter_namespace { diff --git a/server/src/store.rs b/server/src/store.rs index ba03afa..aeae672 100644 --- a/server/src/store.rs +++ b/server/src/store.rs @@ -446,7 +446,10 @@ impl Store // Expensive serialization and granular extraction run completely unblocked outside the lock let full_bytes_res = serde_json::to_vec(&new_snapshot); - let granular_entries = serde_json::to_value(&new_snapshot) + let granular_entries = full_bytes_res + .as_ref() + .ok() + .and_then(|bytes| serde_json::from_slice::(bytes).ok()) .map(|val| Self::extract_granular_entries(&self.key, &val)) .unwrap_or_default(); @@ -454,7 +457,10 @@ impl Store granular_entries.iter().map(|(k, _)| k.clone()).collect(); let mut removed_keys = Vec::new(); { - let mut known = self.known_granular_keys.write().unwrap_or_else(|e| e.into_inner()); + let mut known = self + .known_granular_keys + .write() + .unwrap_or_else(|e| e.into_inner()); for old_k in known.iter() { if !new_keys.contains(old_k) { removed_keys.push(old_k.clone()); @@ -473,7 +479,12 @@ impl Store if self .queue - .push_batch(self.key.clone(), batch_inserts.clone(), removed_keys.clone(), self.flushed.clone()) + .push_batch( + self.key.clone(), + batch_inserts.clone(), + removed_keys.clone(), + self.flushed.clone(), + ) .is_none() { tracing::warn!( @@ -521,7 +532,10 @@ impl Store }; let full_bytes_res = serde_json::to_vec(&new_snapshot); - let granular_entries = serde_json::to_value(&new_snapshot) + let granular_entries = full_bytes_res + .as_ref() + .ok() + .and_then(|bytes| serde_json::from_slice::(bytes).ok()) .map(|val| Self::extract_granular_entries(&self.key, &val)) .unwrap_or_default(); @@ -529,7 +543,10 @@ impl Store granular_entries.iter().map(|(k, _)| k.clone()).collect(); let mut removed_keys = Vec::new(); { - let mut known = self.known_granular_keys.write().unwrap_or_else(|e| e.into_inner()); + let mut known = self + .known_granular_keys + .write() + .unwrap_or_else(|e| e.into_inner()); for old_k in known.iter() { if !new_keys.contains(old_k) { removed_keys.push(old_k.clone()); @@ -548,7 +565,12 @@ impl Store if let Some(rx) = self .queue - .push_batch_async(self.key.clone(), batch_inserts, removed_keys, self.flushed.clone()) + .push_batch_async( + self.key.clone(), + batch_inserts, + removed_keys, + self.flushed.clone(), + ) .await { let _ = rx.await; diff --git a/server/src/tools.rs b/server/src/tools.rs index 02cdb62..d9b4bea 100644 --- a/server/src/tools.rs +++ b/server/src/tools.rs @@ -1,23 +1,6 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -/// Extracts the current contents of the OS clipboard (text, HTML, files, and image). -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct ReadClipboardTool {} - -/// Writes contents to the OS clipboard. You can provide any combination of text, HTML, file paths, or an image path. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct WriteClipboardTool { - /// Optional plain text to write to the clipboard. - pub text: Option, - /// Optional HTML to write to the clipboard (useful for rich text pasting). - pub html: Option, - /// Optional absolute file paths to write to the clipboard as a Windows File Drop. - pub files: Option>, - /// Optional absolute path to a local image to write to the clipboard. - pub image_path: Option, -} - /// Create new entities in the knowledge graph. Note: entity_type MUST ALWAYS be PascalCase (e.g. DatabaseTable, McpTool, File). #[derive(Debug, Deserialize, Serialize, JsonSchema)] pub struct CreateEntitiesTool { @@ -166,39 +149,6 @@ pub struct VisualizeGraphTool { pub namespace: Option, } -#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum StickyNoteAction { - #[serde(alias = "ADD", alias = "Add", alias = "create", alias = "insert")] - Add, - #[serde(alias = "READ", alias = "Read", alias = "list", alias = "get")] - Read, - #[serde(alias = "DELETE", alias = "Delete", alias = "remove", alias = "rm")] - Delete, - #[serde(alias = "CLEAR", alias = "Clear", alias = "purge")] - Clear, -} - -/// Ephemeral sticky notes with TTL for scratchpad items, ports, and transient notes. -/// Actions: -/// - 'add': Add note. Required: content. Optional: ttl_seconds, session_only. -/// - 'read': Read all active notes. -/// - 'delete': Delete note. Required: index (1-based integer). -/// - 'clear': Clear all active notes. -/// Next steps on error: Ensure required fields for the specific action are supplied. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct StickyNotesTool { - /// Action to perform: 'add', 'read', 'delete', or 'clear'. - pub action: StickyNoteAction, - /// Content of the sticky note (required for 'add'). - pub content: Option, - /// 1-indexed position of the sticky note to delete (required for 'delete'). - pub index: Option, - /// Optional time-to-live in seconds (for 'add'). - pub ttl_seconds: Option, - /// Optional flag to auto-expire the note at the end of the current session (for 'add'). - pub session_only: Option, -} /// Condense or summarize an entity's observations to reduce size. #[derive(Debug, Deserialize, Serialize, JsonSchema)] @@ -209,124 +159,6 @@ pub struct CondenseEntityTool { pub summarized_observations: Vec, } -/// Add a new task to the task tracker. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct AddTaskTool { - /// The title of the task. - pub title: String, - /// A detailed description of the task. - pub description: String, - /// The associated git branch, if any. - pub git_branch: Option, - /// Optional parent task ID to create a nested sub-task. - pub parent_id: Option, - /// Optional list of task IDs this task depends on. - pub dependencies: Option>, - /// Optional namespace (defaults to 'default'). - pub namespace: Option, - /// Optional repository name. - pub repo_name: Option, - /// Optional task priority: 'low', 'medium', 'high', 'urgent'. - pub priority: Option, - /// Optional assigned subagent role or identifier. - pub assigned_agent: Option, - /// Optional verification command to validate criteria completion. - pub verification_command: Option, -} - -/// Delete a task and all its children. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct DeleteTaskTool { - /// The ID of the task to delete. - pub id: String, -} - -/// Update the status of an existing task. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct UpdateTaskStatusTool { - /// The ID of the task to update. - pub id: String, - /// The new status of the task (e.g., 'pending', 'completed', 'cancelled'). - #[schemars(description = "Must be 'pending', 'completed', or 'cancelled'")] - pub status: String, -} - -/// List all currently active tasks. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct ListActiveTasksTool { - /// Optional git branch to filter tasks by. - pub git_branch: Option, - /// Optional summary level: 'compact' (IDs and titles), 'detailed', or 'full'. Defaults to 'detailed'. - pub summary_level: Option, - /// Optional token budget cap. Truncates results to stay within max_tokens. - pub max_tokens: Option, -} - -/// Store a reusable code snippet. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct StoreSnippetTool { - /// The name of the snippet. - pub name: String, - /// The programming language of the snippet. - pub language: String, - /// The code snippet content. - pub code: String, - /// A description of what the snippet does. - pub description: String, - /// Optional classification tags (e.g. ['rust', 'async', 'mcp']). - pub tags: Option>, - /// Optional project namespace. - pub namespace: Option, - /// Optional repository name. - pub repo_name: Option, - /// Optional origin file path where the snippet was extracted from. - pub origin_file: Option, - /// Optional line range in origin file (e.g. 'L10-L45'). - pub line_range: Option, -} - -/// Add classification tags to an existing code snippet. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct TagSnippetTool { - /// The name of the snippet to tag. - pub name: String, - /// Array of tags (e.g. ['rust', 'async', 'mcp']). - pub tags: Vec, -} - -#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum SnippetSearchMode { - #[serde(alias = "HYBRID", alias = "Hybrid")] - Hybrid, - #[serde(alias = "KEYWORD", alias = "Keyword")] - Keyword, - #[serde(alias = "SEMANTIC", alias = "Semantic")] - Semantic, -} - -/// Search stored code snippets using Hybrid BM25 + Vector ranking or keyword search. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct SearchSnippetsTool { - /// The search query. - pub query: String, - /// Search mode: 'hybrid' (BM25 + Semantic Tags), 'keyword', or 'semantic'. Defaults to 'hybrid'. - pub mode: Option, - /// Optional classification tags to filter by. - pub tags: Option>, - /// Limit the number of results to avoid context limit overflow. Defaults to 10. - pub limit: Option, - /// Include the full code content of the matched items. If false, returns only IDs and descriptions. Defaults to false. - pub include_body: Option, -} - -/// Delete a stored code snippet. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct DeleteSnippetTool { - /// The name of the snippet to delete. - pub name: String, -} - #[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] #[serde(rename_all = "snake_case")] pub enum SubgraphFormat { @@ -351,15 +183,6 @@ pub struct GetSubgraphTool { pub max_tokens: Option, } -/// Search historical error fixes using keyword search or semantic vector similarity and return matching solutions. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct SuggestErrorFixTool { - /// The exact stack trace or error signature. - pub stack_trace: String, - /// Maximum number of suggested fixes to return. Defaults to 3. - pub limit: Option, -} - #[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] #[serde(rename_all = "snake_case")] pub enum CheckpointAction { @@ -382,66 +205,6 @@ pub struct ManageCheckpointTool { pub namespace: Option, } -/// Save a named memory snapshot checkpoint before risky operations or subagent invocations. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct CheckpointStateTool { - /// The unique name for this state snapshot checkpoint. - pub name: String, - /// Optional description of why this checkpoint was created. - pub description: Option, -} - -/// Restore a previously saved memory snapshot checkpoint. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct RestoreStateTool { - /// The name of the checkpoint to restore. - pub name: String, -} - -/// Log an architectural decision record (ADR). -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct LogDecisionTool { - /// The title of the decision. - pub title: String, - /// The context or problem requiring a decision. - pub context: String, - /// The decision made. - pub decision: String, - /// The consequence of the decision. - #[serde(alias = "consequences")] - pub consequence: String, - /// The ID of an older ADR this decision supersedes, if any. - pub supersedes: Option, - /// Optional project namespace. - pub namespace: Option, - /// Optional repository name. - pub repo_name: Option, - /// Optional author or architect behind the decision. - pub author: Option, - /// Optional affected system components or crates. - pub affected_components: Option>, - /// Optional alternative designs or libraries considered. - pub alternatives_considered: Option>, -} - -/// Query architectural decision records. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct QueryDecisionsTool { - /// Optional search query. - pub query: Option, - /// Limit the number of results to avoid context limit overflow. Defaults to 5. - pub limit: Option, - /// Include the full body/content of the matched ADRs. If false, returns only titles and IDs (recommended for LLMs to prevent context bloat). Defaults to false. - pub include_body: Option, -} - -/// Delete an architectural decision record (ADR). -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct DeleteDecisionTool { - /// The ID of the decision to delete. - pub id: String, -} - /// Merge two entities in the knowledge graph into one. #[derive(Debug, Deserialize, Serialize, JsonSchema)] pub struct MergeEntitiesTool { @@ -455,19 +218,6 @@ pub struct MergeEntitiesTool { #[derive(Debug, Deserialize, Serialize, JsonSchema)] pub struct FindOrphansTool {} -/// Record a user preference or behavior to adapt future interactions. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct LearnPreferenceTool { - /// The key for the preference. - pub key: String, - /// The value of the preference. - pub value: String, -} - -/// Read all learned user preferences. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct ReadPreferencesTool {} - /// Log a complex error and its fix for future reference. #[derive(Debug, Deserialize, Serialize, JsonSchema)] pub struct LogErrorFixTool { @@ -497,44 +247,6 @@ pub struct SearchErrorFixesTool { /// Include the full solution details. If false, returns only error signatures. Defaults to false. pub include_body: Option, } - -/// Pin a file to keep it explicitly in the context workspace. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct PinFileTool { - /// The namespace to pin the file in. - #[serde(default = "crate::models::default_namespace")] - pub namespace: String, - /// The absolute path of the file to pin. - pub file_path: String, - /// The associated git branch, if any. - pub git_branch: Option, - /// Optional reason for pinning this file. - pub pin_reason: Option, - /// Optional symbol focus or line range. - pub symbol_or_line: Option, - /// Optional repository name. - pub repo_name: Option, -} - -/// Unpin a file from the context workspace. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct UnpinFileTool { - /// The namespace the file is pinned in. - #[serde(default = "crate::models::default_namespace")] - pub namespace: String, - /// The absolute path of the file to unpin. - pub file_path: String, -} - -/// List all currently pinned files. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct ListPinnedFilesTool { - /// Optional namespace to filter by. - pub namespace: Option, - /// Optional git branch to filter by. - pub git_branch: Option, -} - /// Add a summary of the current session. #[derive(Debug, Deserialize, Serialize, JsonSchema)] pub struct AddSessionSummaryTool { @@ -544,105 +256,6 @@ pub struct AddSessionSummaryTool { #[serde(default = "crate::models::default_namespace")] pub namespace: String, } - -/// Get a timeline of major project events. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -#[allow(dead_code)] -pub struct GetProjectTimelineTool { - /// Optional namespace to restrict the timeline to. - pub namespace: Option, -} - -/// Leave a memo for the next session or agent. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct LeaveHandoffMemoTool { - /// The content of the memo. - pub content: String, - /// The namespace to leave the memo in. - #[serde(default = "crate::models::default_namespace")] - pub namespace: String, -} - -/// Read pending handoff memos. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct ReadHandoffMemosTool { - /// Optional namespace to restrict the read to. - pub namespace: Option, -} - -/// Clear handoff memos after reading. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct ClearHandoffMemosTool { - /// Array of memo IDs to clear. - pub ids: Vec, -} - -/// Update the environment fingerprint (e.g., OS, tool versions). -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct UpdateEnvFingerprintTool { - /// The namespace to update the fingerprint for. - #[serde(default = "crate::models::default_namespace")] - pub namespace: String, - /// A map of tool names to their versions. - pub tool_versions: std::collections::HashMap, -} - -/// Read the environment fingerprint. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct ReadEnvFingerprintTool { - /// The namespace to read the fingerprint for. - #[serde(default = "crate::models::default_namespace")] - pub namespace: String, -} - -/// Log a required environment variable or configuration. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct LogEnvRequirementTool { - /// The namespace. - #[serde(default = "crate::models::default_namespace")] - pub namespace: String, - /// The environment variable key (e.g., DATABASE_URL). - pub key: String, - /// A description of what the variable is used for. - pub description: String, - /// Whether the variable contains a secret. - pub is_secret: bool, -} - -/// Add a new milestone. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct AddMilestoneTool { - /// The title of the milestone. - pub title: String, - /// The namespace for the milestone. - #[serde(default = "crate::models::default_namespace")] - pub namespace: String, - /// Optional target completion date. - pub target_date: Option, - /// Optional description of milestone scope. - pub description: Option, - /// Optional deliverables or task checklist. - pub deliverables: Option>, - /// Optional repository name. - pub repo_name: Option, -} - -/// Update the status of a milestone. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct UpdateMilestoneTool { - /// The ID of the milestone to update. - pub id: String, - /// The new status (e.g., 'active', 'completed'). - pub status: String, -} - -/// List all milestones. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct ListMilestonesTool { - /// Optional namespace to restrict the list to. - pub namespace: Option, -} - /// Generate a standup report for a specific time window. #[derive(Debug, Deserialize, Serialize, JsonSchema)] pub struct GenerateStandupReportTool { @@ -652,165 +265,6 @@ pub struct GenerateStandupReportTool { /// The number of hours to look back for activity. pub hours_lookback: u64, } - -/// Register a new infrastructure environment. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct RegisterEnvironmentTool { - /// The namespace. - #[serde(default = "crate::models::default_namespace")] - pub namespace: String, - /// The name of the environment (e.g., 'staging', 'prod'). - pub name: String, - /// The URL or connection string for the environment. - pub url: String, - /// A description of the environment. - pub description: String, - /// Whether a VPN is required to access the environment. - pub requires_vpn: bool, -} - -/// Get details about a registered environment. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct GetEnvironmentDetailsTool { - /// The namespace to retrieve details for. - #[serde(default = "crate::models::default_namespace")] - pub namespace: String, -} - -/// Add an item to the PR checklist. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct AddPrChecklistItemTool { - /// The namespace. - #[serde(default = "crate::models::default_namespace")] - pub namespace: String, - /// The description of the checklist item. - pub description: String, - /// Optional checklist category (e.g. 'Security', 'Testing', 'Formatting'). - pub category: Option, - /// Optional automated verification command or script. - pub automated_check: Option, - /// Optional checked state. - pub is_checked: Option, - /// Optional repository name. - pub repo_name: Option, -} - -/// Get the PR checklist. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct GetPrChecklistTool { - /// The namespace. - #[serde(default = "crate::models::default_namespace")] - pub namespace: String, -} - -/// Clear the PR checklist. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct ClearPrChecklistTool { - /// The namespace. - #[serde(default = "crate::models::default_namespace")] - pub namespace: String, -} - -/// Log a technical debt record. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct LogTechDebtTool { - /// The namespace. - #[serde(default = "crate::models::default_namespace")] - pub namespace: String, - /// A description of the technical debt. - pub description: String, - /// The ideal solution to resolve the debt. - pub ideal_solution: String, - /// The associated git commit hash, if any. - pub git_commit: Option, - /// The associated git branch, if any. - pub git_branch: Option, - /// Optional symbol references (e.g. ['MemoryIndex', 'switchTab']) associated with the tech debt. - pub symbol_references: Option>, - /// Optional line range (e.g. 'L123-L145') associated with the tech debt. - pub line_range: Option, - /// Optional debt severity: 'low', 'medium', 'high', 'critical'. - pub severity: Option, - /// Optional repository name. - pub repo_name: Option, - /// Optional file path where debt exists. - pub file_path: Option, - /// Optional workaround currently in place. - pub workaround: Option, - /// Optional effort estimate to fix (e.g. '1 hour', '2 days'). - pub effort_estimate: Option, -} - -/// Resolve a technical debt record. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct ResolveTechDebtTool { - /// The ID of the technical debt record to resolve. - pub id: String, -} - -/// List technical debt records. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct ListTechDebtTool { - /// The namespace. - #[serde(default = "crate::models::default_namespace")] - pub namespace: String, - /// Whether to include resolved technical debt in the results. - pub include_resolved: bool, - /// Optional summary level: 'compact' (IDs and descriptions), 'detailed', or 'full'. Defaults to 'detailed'. - pub summary_level: Option, - /// Optional token budget cap. Truncates results to stay within max_tokens. - pub max_tokens: Option, -} - -/// Save the current context workspace. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct SaveContextWorkspaceTool { - /// The namespace for the workspace. - #[serde(default = "crate::models::default_namespace")] - pub namespace: String, - /// The name to save the workspace as. - pub name: String, - /// Array of pinned file paths. - pub pinned_files: Vec, - /// Array of active task IDs. - pub active_task_ids: Vec, - /// Optional description of the context workspace purpose. - pub description: Option, - /// Optional git branch associated with this workspace. - pub git_branch: Option, - /// Optional repository name. - pub repo_name: Option, - /// Optional VCS revision or commit hash. - pub vcs_revision: Option, -} - -/// Load a saved context workspace. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct LoadContextWorkspaceTool { - /// The namespace of the workspace. - #[serde(default = "crate::models::default_namespace")] - pub namespace: String, - /// The name of the workspace to load. - pub name: String, -} - -/// List all saved context workspaces. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct ListContextWorkspacesTool { - /// The namespace to list workspaces for. - pub namespace: Option, -} - -/// Delete a saved context workspace. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct DeleteContextWorkspaceTool { - /// The namespace of the workspace. - #[serde(default = "crate::models::default_namespace")] - pub namespace: String, - /// The name of the workspace to delete. - pub name: String, -} - /// Search across all memory stores (Graph, Tasks, Snippets, ADRs, Error Fixes, Tech Debt) using Hybrid Reciprocal Rank Fusion (BM25 + Dense Vectors). /// Highly recommended for discovery. Supports Lucene query syntax. #[derive(Debug, Deserialize, Serialize, JsonSchema)] @@ -905,16 +359,6 @@ pub struct QueryHypothesesTool { /// Optional search query text. pub query: Option, } - -/// Compare two saved context workspaces and return a structured diff of pinned files, active tasks, and metadata. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct DiffContextWorkspacesTool { - /// The name of the first context workspace. - pub workspace_a: String, - /// The name of the second context workspace. - pub workspace_b: String, -} - #[cfg(test)] mod tests { use super::*; @@ -990,20 +434,6 @@ 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, -} - -/// Restore a point-in-time state snapshot of memory stores. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct RestoreSnapshotTool { - pub snapshot_id: String, -} - #[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] #[serde(rename_all = "snake_case")] pub enum SubagentNamespaceAction { @@ -1022,19 +452,6 @@ pub struct ManageSubagentNamespaceTool { /// For 'condense': whether to auto-purge the subagent namespace after promotion. Defaults to true. pub purge_after_promotion: Option, } - -/// 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, -} - /// Find all source locations and AST chunks where a specific symbol (function, struct, method, type) is referenced or called. #[derive(Debug, Deserialize, Serialize, JsonSchema)] pub struct FindSymbolReferencesTool { @@ -1068,7 +485,7 @@ pub struct QueryGitDiffsTool { pub limit: Option, } -/// Promote a transient sticky note or active task observation into a permanent Knowledge Graph entity. +/// Promote a task observation or finding into a permanent Knowledge Graph entity. #[derive(Debug, Deserialize, Serialize, JsonSchema)] pub struct PromoteToEntityTool { /// Note text or content to promote. @@ -1090,17 +507,7 @@ pub struct AnalyzeImpactTool { /// Optional file path. pub file_path: Option, } - -/// Auto-condense a subagent's memory namespace and promote its high-value entities/relations to the global Knowledge Graph. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct CondenseSubagentNamespaceTool { - /// The subagent namespace ID to consolidate. - pub subagent_id: String, - /// Whether to auto-purge the subagent namespace after promotion. Defaults to true. - pub purge_after_promotion: Option, -} - -/// Get a consolidated 1-page executive summary of current active branch, active task, pinned files, open tech debt, and diagnostic hypotheses in 1 turn. +/// Get a consolidated 1-page executive summary of current active branch, active task, open tech debt, and diagnostic hypotheses in 1 turn. #[derive(Debug, Deserialize, Serialize, JsonSchema)] pub struct GetPreflightContextTool { /// Optional namespace. Defaults to 'default'. @@ -1163,18 +570,6 @@ pub struct AutoSessionCheckpointTool { #[serde(default = "crate::models::default_namespace")] pub namespace: String, } - -/// Hybrid BM25 & semantic tag search across stored code snippets, returning ranked code patterns with relevance scores. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct SearchSnippetsHybridTool { - /// Query string (e.g. 'rate limiter', 'websocket client', 'shutdown poll'). - pub query: String, - /// Optional tags to filter by. - pub tags: Option>, - /// Maximum number of snippets to return. Defaults to 10. - pub limit: Option, -} - // Consolidated Smart Management Tools #[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] @@ -1322,124 +717,6 @@ pub struct MilestonesTool { /// Optional repository name. pub repo_name: Option, } - -#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum PinnedFileAction { - #[serde(alias = "pin", alias = "PIN", alias = "Pin")] - Pin, - #[serde(alias = "unpin", alias = "UNPIN", alias = "Unpin")] - Unpin, - #[serde(alias = "list", alias = "LIST", alias = "List")] - List, -} - -/// Pinned working set files management (pin, unpin, list). -/// Actions: -/// - 'pin': Pin file to active focus set. Required: path. Optional: namespace. -/// - 'unpin': Unpin file from focus set. Required: path. Optional: namespace. -/// - 'list': List pinned files. Optional: namespace. -/// Next steps on error: Provide exact file path for pin/unpin operations. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct PinnedFilesTool { - /// Action to perform: 'pin', 'unpin', or 'list'. - pub action: PinnedFileAction, - /// Absolute or workspace relative file path (required for 'pin' and 'unpin'). - pub path: Option, - /// Optional namespace. - pub namespace: Option, - /// Optional reason for pinning this file. - pub pin_reason: Option, - /// Optional symbol focus or line range. - pub symbol_or_line: Option, - /// Optional repository name. - pub repo_name: Option, - /// Optional git branch. - pub git_branch: Option, -} - -#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum ContextWorkspaceAction { - #[serde(alias = "save", alias = "SAVE", alias = "Save")] - Save, - #[serde(alias = "load", alias = "LOAD", alias = "Load")] - Load, - #[serde(alias = "list", alias = "LIST", alias = "List")] - List, - #[serde(alias = "delete", alias = "DELETE", alias = "Delete")] - Delete, - #[serde(alias = "diff", alias = "DIFF", alias = "Diff")] - Diff, -} - -/// Context workspace snapshots management (save, load, list, delete, diff). -/// Actions: -/// - 'save': Save current pinned files & task IDs. Required: name. Optional: pinned_files, active_task_ids, namespace. -/// - 'load': Restore saved workspace state. Required: name. Optional: namespace. -/// - 'list': List saved context workspaces. Optional: namespace. -/// - 'delete': Delete saved workspace. Required: name. Optional: namespace. -/// - 'diff': Compare two context workspaces. Required: name, other_name. Optional: namespace. -/// Next steps on error: Check workspace name or list existing workspaces first. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct ContextWorkspacesTool { - /// Action to perform: 'save', 'load', 'list', 'delete', or 'diff'. - pub action: ContextWorkspaceAction, - /// Workspace name (required for 'save', 'load', 'delete', 'diff'). - pub name: Option, - /// Second workspace name (required for 'diff'). - pub other_name: Option, - /// Pinned file paths (for 'save'). - pub pinned_files: Option>, - /// Active task IDs (for 'save'). - pub active_task_ids: Option>, - /// Optional namespace. - pub namespace: Option, - /// Optional description of the context workspace purpose. - pub description: Option, - /// Optional git branch associated with this workspace. - pub git_branch: Option, - /// Optional repository name. - pub repo_name: Option, - /// Optional VCS revision or commit hash. - pub vcs_revision: Option, -} - -#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum PrChecklistAction { - #[serde(alias = "add", alias = "ADD", alias = "Add")] - Add, - #[serde(alias = "get", alias = "GET", alias = "Get")] - Get, - #[serde(alias = "clear", alias = "CLEAR", alias = "Clear")] - Clear, -} - -/// Pre-commit and Pull Request checklist management (add, get, clear). -/// Actions: -/// - 'add': Add PR checklist item. Required: description. Optional: namespace. -/// - 'get': Get PR checklist items. Optional: namespace. -/// - 'clear': Clear PR checklist. Optional: namespace. -/// Next steps on error: Provide description for 'add' action. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct PrChecklistTool { - /// Action to perform: 'add', 'get', or 'clear'. - pub action: PrChecklistAction, - /// Checklist item description (required for 'add'). - pub description: Option, - /// Optional namespace. - pub namespace: Option, - /// Optional checklist category (e.g. 'Security', 'Testing', 'Formatting'). - pub category: Option, - /// Optional automated verification command or script. - pub automated_check: Option, - /// Optional checked state. - pub is_checked: Option, - /// Optional repository name. - pub repo_name: Option, -} - #[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] #[serde(rename_all = "snake_case")] pub enum SnippetAction { @@ -1535,6 +812,12 @@ pub struct DecisionsTool { pub affected_components: Option>, /// Optional alternative designs or libraries considered. pub alternatives_considered: Option>, + /// Optional superseded ADR ID. + pub supersedes: Option, + /// Limit the number of query results. Defaults to 20. + pub limit: Option, + /// Include the full body of the matched decisions. Defaults to true. + pub include_body: Option, } #[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] @@ -1562,8 +845,10 @@ pub struct TechDebtTool { #[serde(alias = "tech_debt_id", alias = "debt_id")] pub id: Option, /// Tech debt description (required for 'log'). - #[serde(alias = "desc", alias = "title")] + #[serde(alias = "desc")] pub description: Option, + /// Optional title or summary of tech debt. + pub title: Option, /// Ideal solution (optional for 'log'). #[serde(alias = "solution", alias = "fix")] pub ideal_solution: Option, @@ -1591,6 +876,10 @@ pub struct TechDebtTool { pub workaround: Option, /// Optional effort estimate to fix. pub effort_estimate: Option, + /// Summary level for list action: 'compact', 'detailed', or 'full'. + pub summary_level: Option, + /// Optional token budget cap for list output. + pub max_tokens: Option, } #[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] diff --git a/stub/src/main.rs b/stub/src/main.rs index 5f7e630..bac6f71 100644 --- a/stub/src/main.rs +++ b/stub/src/main.rs @@ -114,6 +114,8 @@ fn main() -> Result<(), Box> { }); let mut recv_task = tokio::spawn(async move { + use tokio::io::AsyncWriteExt; + let mut stdout = tokio::io::BufWriter::new(tokio::io::stdout()); while let Some(Ok(msg)) = read.next().await { if let tokio_tungstenite::tungstenite::Message::Text(text) = msg { let log_prefix = logger::extract_log_prefix(&text, true); @@ -124,8 +126,6 @@ fn main() -> Result<(), Box> { &text[..text.floor_char_boundary(1000)], if text.len() > 1000 { "..." } else { "" } ); - use tokio::io::AsyncWriteExt; - let mut stdout = tokio::io::stdout(); let _ = stdout.write_all(text.as_bytes()).await; let _ = stdout.write_all(b"\n").await; let _ = stdout.flush().await;