refactor: eradicate deprecated tools (sticky_notes, pinned_files, context_workspaces, pr_checklist, preferences) and dead code
This commit is contained in:
1 parent
d80915635f
commit
79209da711
31 files changed
+1169
-3859
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-27
@@ -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<String>`, `action_items: Vec<String>`, `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.
|
||||
|
||||
@@ -8,6 +8,31 @@ pub static ACTIVE_SOCKET: LazyLock<Arc<tokio::sync::Mutex<Option<String>>>> =
|
||||
pub static HEADLESS_PROC: LazyLock<Arc<tokio::sync::Mutex<Option<tokio::process::Child>>>> =
|
||||
LazyLock::new(|| Arc::new(tokio::sync::Mutex::new(None)));
|
||||
|
||||
unsafe extern "C" {
|
||||
fn atexit(f: unsafe extern "C" fn()) -> i32;
|
||||
}
|
||||
|
||||
static HEADLESS_PID: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
static ONCE_INIT: std::sync::Once = std::sync::Once::new();
|
||||
|
||||
unsafe extern "C" fn cleanup_on_exit() {
|
||||
let pid = HEADLESS_PID.swap(0, std::sync::atomic::Ordering::SeqCst);
|
||||
if pid != 0 {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = std::process::Command::new("taskkill")
|
||||
.args(["/F", "/T", "/PID", &pid.to_string()])
|
||||
.output();
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let _ = std::process::Command::new("kill")
|
||||
.args(["-9", &pid.to_string()])
|
||||
.output();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn spawn_headless_nvim() -> Result<String, String> {
|
||||
if std::process::Command::new("nvim")
|
||||
.arg("--version")
|
||||
@@ -17,6 +42,10 @@ pub async fn spawn_headless_nvim() -> Result<String, String> {
|
||||
return Err("Error: Neovim is not installed or not in PATH. Cannot execute nvim tool. Fallback to basic terminal tools.".to_string());
|
||||
}
|
||||
|
||||
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<String, String> {
|
||||
.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<String, String> {
|
||||
{
|
||||
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<String, String> {
|
||||
}
|
||||
}
|
||||
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<String, String> {
|
||||
));
|
||||
}
|
||||
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<String, String> {
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -167,6 +167,43 @@ async fn get_socket_path() -> Result<String, String> {
|
||||
}
|
||||
Err("Could not find Neovim socket".to_string())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
async fn read_active_socket_file() -> Result<String, String> {
|
||||
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<String, String> {
|
||||
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<mpsc::Sender<NvimRequest>, 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;
|
||||
|
||||
|
||||
@@ -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"}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,18 +237,14 @@ async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::E
|
||||
let adrs = state_clone.adrs.read_with(|items| items.len());
|
||||
|
||||
let ledger = state_clone.ledger.read_with(|items| items.len());
|
||||
let sticky = state_clone.sticky.read_with(|items| items.len());
|
||||
let error_fixes = state_clone.error_fixes.read_with(|items| items.len());
|
||||
let pinned_files = state_clone.pinned_files.read_with(|items| items.len());
|
||||
let session_summaries = state_clone.session_summaries.read_with(|items| items.len());
|
||||
let handoff_memos = state_clone.handoff_memos.read_with(|items| items.len());
|
||||
let env_fingerprints = state_clone.env_fingerprints.read_with(|items| items.len());
|
||||
let env_requirements = state_clone.env_requirements.read_with(|items| items.len());
|
||||
let milestones = state_clone.milestones.read_with(|items| items.len());
|
||||
let environments = state_clone.environments.read_with(|items| items.len());
|
||||
let pr_checklists = state_clone.pr_checklists.read_with(|items| items.len());
|
||||
let gates = state_clone.gates.read_with(|items| items.len());
|
||||
let context_workspaces = state_clone.context_workspaces.read_with(|items| items.len());
|
||||
|
||||
axum::Json(serde_json::json!({
|
||||
"entities": entities,
|
||||
@@ -258,18 +254,14 @@ async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::E
|
||||
"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
|
||||
}))
|
||||
}
|
||||
}),
|
||||
|
||||
+2
-42
@@ -205,16 +205,7 @@ pub fn create_router(app_state: Arc<AppState>) -> 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<AppState>) -> 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<AppState>) -> 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<AppState>) -> 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<AppState>) -> 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",
|
||||
];
|
||||
|
||||
@@ -127,23 +127,18 @@ pub fn init_redb(base: &Path) -> Arc<Database> {
|
||||
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"),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+58
-37
@@ -81,27 +81,64 @@ impl McpTool for ReadFileSkeletonHandler {
|
||||
return;
|
||||
}
|
||||
let kind = node.kind();
|
||||
let is_container = matches!(
|
||||
kind,
|
||||
"impl_item"
|
||||
| "class_declaration"
|
||||
| "class_definition"
|
||||
| "trait_item"
|
||||
| "interface_declaration"
|
||||
);
|
||||
|
||||
let is_structural = matches!(
|
||||
let is_structural = 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"
|
||||
| "class_declaration"
|
||||
| "class_definition"
|
||||
);
|
||||
|
||||
if is_structural {
|
||||
if is_container {
|
||||
let indent = " ".repeat(depth);
|
||||
let node_text = node.utf8_text(code.as_bytes()).unwrap_or("");
|
||||
let mut header = String::new();
|
||||
for line in node_text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.ends_with('{') || trimmed.ends_with(':') {
|
||||
header.push_str(line);
|
||||
break;
|
||||
} else {
|
||||
header.push_str(line);
|
||||
header.push('\n');
|
||||
}
|
||||
}
|
||||
if header.is_empty() {
|
||||
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::<Vec<serde_json::Value>, 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::<Vec<serde_json::Value>, 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 || {
|
||||
|
||||
+74
-183
@@ -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::<UpdateEnvFingerprintTool>(
|
||||
"update_env_fingerprint",
|
||||
"Update system OS, shell, and installed tool version fingerprints for a workspace namespace.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
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::<ReadEnvFingerprintTool>(
|
||||
"read_env_fingerprint",
|
||||
"Read system OS, shell, and tool version fingerprints for a workspace namespace.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
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::<String, crate::error::AppError>(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::<LogEnvRequirementTool>(
|
||||
"log_env_requirement",
|
||||
"Log required environment variables, configuration keys, or secrets for a namespace.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
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::<RegisterEnvironmentTool>(
|
||||
"register_environment",
|
||||
"Register infrastructure environment details (URL, VPN requirements, notes) for a namespace.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
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::<GetEnvironmentDetailsTool>(
|
||||
"get_environment_details",
|
||||
"Retrieve registered infrastructure environment details for a namespace.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
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::<String, crate::error::AppError>(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::<String, crate::error::AppError>(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::<String, crate::error::AppError>(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;
|
||||
|
||||
|
||||
+103
-76
@@ -459,7 +459,6 @@ impl McpTool for ReadGraphHandler {
|
||||
let offset = req.offset.unwrap_or(0);
|
||||
|
||||
let data = state.read_graph(|full| -> crate::error::Result<String> {
|
||||
let mut result_json = if limit.is_some() || req.offset.is_some() {
|
||||
let filtered_entities: Vec<(&String, &crate::models::Entity)> = full
|
||||
.entities
|
||||
.iter()
|
||||
@@ -467,6 +466,8 @@ impl McpTool for ReadGraphHandler {
|
||||
.collect();
|
||||
|
||||
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 (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());
|
||||
}
|
||||
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
let mut queue = std::collections::VecDeque::new();
|
||||
queue.push_back((root.clone(), 0));
|
||||
queue.push_back((root.as_str(), 0));
|
||||
visited.insert(root.clone());
|
||||
|
||||
while let Some((curr, d)) = queue.pop_front() {
|
||||
if d >= depth {
|
||||
continue;
|
||||
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));
|
||||
}
|
||||
}
|
||||
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 ent: Vec<_> = g.entities.values().filter(|e| visited.contains(&e.name)).cloned().collect();
|
||||
let rel: Vec<_> = g.relations.iter().filter(|r| visited.contains(&r.from) && visited.contains(&r.to)).cloned().collect();
|
||||
let ent: std::collections::HashMap<String, crate::models::Entity> = visited
|
||||
.iter()
|
||||
.filter_map(|name| full.entities.get(name).map(|e| (name.clone(), e.clone())))
|
||||
.collect();
|
||||
let rel: Vec<crate::models::Relation> = 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,45 +1010,12 @@ 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<String> = std::collections::HashSet::new();
|
||||
let mut matched_entities = std::collections::HashMap::new();
|
||||
let mut matched_relations = Vec::new();
|
||||
|
||||
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,
|
||||
@@ -993,10 +1023,7 @@ impl McpTool for GetSubgraphHandler {
|
||||
"relations": matched_relations,
|
||||
});
|
||||
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&result)?)
|
||||
})?;
|
||||
|
||||
Ok(data)
|
||||
Ok(serde_json::to_string_pretty(&result)?)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+252
-794
File diff suppressed because it is too large.
Load diff
@@ -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::<StickyNotesTool>(
|
||||
"sticky_notes",
|
||||
"Ephemeral sticky notes with TTL (add, read, delete, clear)",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
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::<PromoteToEntityTool>(
|
||||
"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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Task> = 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<Task> = None;
|
||||
state.project.tasks.modify(|tasks| {
|
||||
if let Some(task) = tasks.iter_mut().find(|t| t.id == id || t.title == id) {
|
||||
if let Some(ac) = task.acceptance_criteria.iter_mut().next() {
|
||||
@@ -352,6 +363,7 @@ impl McpTool for TasksHandler {
|
||||
ac.is_met = true;
|
||||
success = true;
|
||||
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(),
|
||||
|
||||
+169
-181
@@ -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,29 +8,181 @@ use serde_json::{Value, json};
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct WriteClipboardHandler;
|
||||
|
||||
pub fn get_native_clipboard_text() -> Option<String> {
|
||||
for _ in 0..3 {
|
||||
if let Ok(mut clipboard) = arboard::Clipboard::new() {
|
||||
if let Ok(text) = clipboard.get_text() {
|
||||
if !text.trim().is_empty() {
|
||||
return Some(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn get_windows_png_clipboard_image() -> Option<image::DynamicImage> {
|
||||
use windows_sys::Win32::System::DataExchange::{
|
||||
CloseClipboard, GetClipboardData, IsClipboardFormatAvailable, OpenClipboard,
|
||||
RegisterClipboardFormatW,
|
||||
};
|
||||
use windows_sys::Win32::System::Memory::{GlobalLock, GlobalSize, GlobalUnlock};
|
||||
let format_name: Vec<u16> = "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<image::DynamicImage> {
|
||||
#[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() {
|
||||
if let Some(img) = ImageBuffer::<image::Rgba<u8>, _>::from_raw(
|
||||
image_data.width as u32,
|
||||
image_data.height as u32,
|
||||
image_data.bytes.into_owned(),
|
||||
) {
|
||||
return Some(image::DynamicImage::ImageRgba8(img));
|
||||
}
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub struct ClipboardHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for WriteClipboardHandler {
|
||||
impl McpTool for ClipboardHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"write_clipboard"
|
||||
"clipboard"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<WriteClipboardTool>(
|
||||
"write_clipboard",
|
||||
"Write text, HTML, files, and/or images directly to the OS clipboard.",
|
||||
crate::mcp::tool_def::<ClipboardTool>(
|
||||
"clipboard",
|
||||
"Consolidated OS clipboard management (read, write)",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let tool_args: WriteClipboardTool = serde_json::from_value(args)
|
||||
.map_err(|e| crate::error::AppError::Internal(format!("Invalid args: {}", e)))?;
|
||||
let req: ClipboardTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
match req.action {
|
||||
ClipboardAction::Read => {
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || -> crate::error::Result<serde_json::Value> {
|
||||
let mut out = serde_json::Map::new();
|
||||
|
||||
if let Some(text) = get_native_clipboard_text() {
|
||||
out.insert("text".into(), json!(text));
|
||||
}
|
||||
|
||||
if let Some(dynamic_img) = get_native_clipboard_image() {
|
||||
let mut img = dynamic_img;
|
||||
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::<String, crate::error::AppError>(serde_json::to_string_pretty(&final_obj)?)
|
||||
}
|
||||
ClipboardAction::Write => {
|
||||
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) = &tool_args.text {
|
||||
if let Some(text) = &text_opt {
|
||||
let mut written = false;
|
||||
if let Ok(mut clipboard) = Clipboard::new() {
|
||||
for _ in 0..3 {
|
||||
@@ -46,8 +198,7 @@ impl McpTool for WriteClipboardHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle arboard for image
|
||||
if let Some(image_path) = &tool_args.image_path {
|
||||
if let Some(image_path) = &image_path_opt {
|
||||
match image::open(image_path) {
|
||||
Ok(img) => {
|
||||
let img = if img.width() > 2048 || img.height() > 2048 {
|
||||
@@ -106,173 +257,9 @@ impl McpTool for WriteClipboardHandler {
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_native_clipboard_text() -> Option<String> {
|
||||
for _ in 0..3 {
|
||||
if let Ok(mut clipboard) = arboard::Clipboard::new() {
|
||||
if let Ok(text) = clipboard.get_text() {
|
||||
if !text.trim().is_empty() {
|
||||
return Some(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn get_native_clipboard_image() -> Option<image::DynamicImage> {
|
||||
for _ in 0..3 {
|
||||
if let Ok(mut clipboard) = arboard::Clipboard::new() {
|
||||
if let Ok(image_data) = clipboard.get_image() {
|
||||
if let Some(img) = ImageBuffer::<image::Rgba<u8>, _>::from_raw(
|
||||
image_data.width as u32,
|
||||
image_data.height as u32,
|
||||
image_data.bytes.into_owned(),
|
||||
) {
|
||||
return Some(image::DynamicImage::ImageRgba8(img));
|
||||
}
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
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::<ReadClipboardTool>(
|
||||
"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<MemoryState>) -> crate::error::Result<String> {
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || -> crate::error::Result<serde_json::Value> {
|
||||
let mut out = serde_json::Map::new();
|
||||
|
||||
if let 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::<String, crate::error::AppError>(serde_json::to_string_pretty(&final_obj)?)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
use crate::tools::{ClipboardAction, ClipboardTool};
|
||||
|
||||
pub struct ClipboardHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for ClipboardHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"clipboard"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<ClipboardTool>(
|
||||
"clipboard",
|
||||
"Consolidated OS clipboard management (read, write)",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
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
|
||||
}
|
||||
ClipboardAction::Write => {
|
||||
WriteClipboardHandler.execute(
|
||||
serde_json::json!({
|
||||
"text": req.text,
|
||||
"html": req.html,
|
||||
"files": req.files,
|
||||
"image_path": req.image_path
|
||||
}),
|
||||
state,
|
||||
).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -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();
|
||||
|
||||
+258
-1187
File diff suppressed because it is too large.
Load diff
@@ -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<String>`, `action_items: Vec<String>`, `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.
|
||||
|
||||
+1
-45
@@ -105,9 +105,7 @@ pub async fn ttl_sweeper_worker(state: Arc<MemoryState>) {
|
||||
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<MemoryState>) {
|
||||
.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<String> = 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();
|
||||
|
||||
+5
-71
@@ -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<u64>,
|
||||
#[serde(default = "default_namespace")]
|
||||
pub namespace: String,
|
||||
#[serde(default)]
|
||||
pub category: Option<String>,
|
||||
#[serde(default)]
|
||||
pub author: Option<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
#[serde(default)]
|
||||
pub rationale: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ErrorFix {
|
||||
@@ -434,19 +411,6 @@ pub struct ErrorFix {
|
||||
pub toolchain: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct PinnedFile {
|
||||
pub namespace: String,
|
||||
pub file_path: String,
|
||||
pub timestamp: u64,
|
||||
pub git_branch: Option<String>,
|
||||
#[serde(default)]
|
||||
pub repo_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub symbol_or_line: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pin_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SessionSummary {
|
||||
@@ -554,20 +518,6 @@ pub struct EnvironmentDetail {
|
||||
pub ssh_host: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(default)]
|
||||
pub automated_check: Option<String>,
|
||||
#[serde(default)]
|
||||
pub repo_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct TechDebt {
|
||||
@@ -595,22 +545,6 @@ pub struct TechDebt {
|
||||
pub effort_estimate: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ContextWorkspace {
|
||||
pub name: String,
|
||||
pub namespace: String,
|
||||
pub pinned_files: Vec<String>,
|
||||
pub active_task_ids: Vec<String>,
|
||||
pub saved_at: u64,
|
||||
#[serde(default)]
|
||||
pub repo_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub git_branch: Option<String>,
|
||||
#[serde(default)]
|
||||
pub vcs_revision: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct GateRecord {
|
||||
|
||||
+25
-206
@@ -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<MemoryState>) -> crate::error::Result<String> {
|
||||
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::<String>())
|
||||
.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())
|
||||
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::<Vec<_>>()
|
||||
.join(", ")
|
||||
})
|
||||
.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))
|
||||
.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!({})),
|
||||
|
||||
+26
-19
@@ -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<dyn tantivy::query::Query> = if let Some(ns) = namespace {
|
||||
let ns_term_query: Box<dyn tantivy::query::Query> = Box::new(tantivy::query::TermQuery::new(
|
||||
tantivy::Term::from_field_text(self.namespace_field, ns),
|
||||
tantivy::schema::IndexRecordOption::Basic,
|
||||
));
|
||||
let ns_filter: Box<dyn tantivy::query::Query> = if ns != "global" {
|
||||
let global_term_query = Box::new(tantivy::query::TermQuery::new(
|
||||
tantivy::Term::from_field_text(self.namespace_field, "global"),
|
||||
tantivy::schema::IndexRecordOption::Basic,
|
||||
));
|
||||
Box::new(tantivy::query::BooleanQuery::new(vec![
|
||||
(tantivy::query::Occur::Should, ns_term_query),
|
||||
(tantivy::query::Occur::Should, global_term_query),
|
||||
]))
|
||||
} else {
|
||||
target_limit
|
||||
ns_term_query
|
||||
};
|
||||
|
||||
Box::new(tantivy::query::BooleanQuery::new(vec![
|
||||
(tantivy::query::Occur::Must, q),
|
||||
(tantivy::query::Occur::Must, ns_filter),
|
||||
]))
|
||||
} else {
|
||||
q
|
||||
};
|
||||
|
||||
let top_docs = searcher.search(
|
||||
&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)
|
||||
}
|
||||
|
||||
@@ -15,9 +15,6 @@ pub struct GenericEvent {
|
||||
pub struct ProjectStores {
|
||||
pub tasks: Store<Vec<Task>>,
|
||||
pub milestones: Store<Vec<Milestone>>,
|
||||
pub pr_checklists: Store<Vec<PrChecklistItem>>,
|
||||
pub context_workspaces: Store<Vec<ContextWorkspace>>,
|
||||
pub pinned_files: Store<Vec<PinnedFile>>,
|
||||
pub snapshots: Store<Vec<StateSnapshot>>,
|
||||
}
|
||||
|
||||
@@ -27,7 +24,6 @@ pub struct CodeStores {
|
||||
pub adrs: Store<Vec<Adr>>,
|
||||
pub error_fixes: Store<Vec<ErrorFix>>,
|
||||
pub tech_debts: Store<Vec<TechDebt>>,
|
||||
pub sticky: Store<Vec<StickyNote>>,
|
||||
pub hypotheses: Store<Vec<Hypothesis>>,
|
||||
}
|
||||
|
||||
@@ -36,7 +32,6 @@ pub struct EnvironmentStores {
|
||||
pub env_requirements: Store<Vec<EnvRequirement>>,
|
||||
pub environments: Store<Vec<EnvironmentDetail>>,
|
||||
pub gates: Store<Vec<GateRecord>>,
|
||||
pub prefs: Store<HashMap<String, Preference>>,
|
||||
}
|
||||
|
||||
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::<String>();
|
||||
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 {
|
||||
|
||||
+28
-6
@@ -446,7 +446,10 @@ impl<T: DeserializeOwned + Default + Serialize + Send + Sync + 'static> Store<T>
|
||||
|
||||
// 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::<serde_json::Value>(bytes).ok())
|
||||
.map(|val| Self::extract_granular_entries(&self.key, &val))
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -454,7 +457,10 @@ impl<T: DeserializeOwned + Default + Serialize + Send + Sync + 'static> Store<T>
|
||||
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<T: DeserializeOwned + Default + Serialize + Send + Sync + 'static> Store<T>
|
||||
|
||||
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<T: DeserializeOwned + Default + Serialize + Send + Sync + 'static> Store<T>
|
||||
};
|
||||
|
||||
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::<serde_json::Value>(bytes).ok())
|
||||
.map(|val| Self::extract_granular_entries(&self.key, &val))
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -529,7 +543,10 @@ impl<T: DeserializeOwned + Default + Serialize + Send + Sync + 'static> Store<T>
|
||||
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<T: DeserializeOwned + Default + Serialize + Send + Sync + 'static> Store<T>
|
||||
|
||||
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;
|
||||
|
||||
+15
-726
@@ -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<String>,
|
||||
/// Optional HTML to write to the clipboard (useful for rich text pasting).
|
||||
pub html: Option<String>,
|
||||
/// Optional absolute file paths to write to the clipboard as a Windows File Drop.
|
||||
pub files: Option<Vec<String>>,
|
||||
/// Optional absolute path to a local image to write to the clipboard.
|
||||
pub image_path: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
/// 1-indexed position of the sticky note to delete (required for 'delete').
|
||||
pub index: Option<usize>,
|
||||
/// Optional time-to-live in seconds (for 'add').
|
||||
pub ttl_seconds: Option<u64>,
|
||||
/// Optional flag to auto-expire the note at the end of the current session (for 'add').
|
||||
pub session_only: Option<bool>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Optional parent task ID to create a nested sub-task.
|
||||
pub parent_id: Option<String>,
|
||||
/// Optional list of task IDs this task depends on.
|
||||
pub dependencies: Option<Vec<String>>,
|
||||
/// Optional namespace (defaults to 'default').
|
||||
pub namespace: Option<String>,
|
||||
/// Optional repository name.
|
||||
pub repo_name: Option<String>,
|
||||
/// Optional task priority: 'low', 'medium', 'high', 'urgent'.
|
||||
pub priority: Option<String>,
|
||||
/// Optional assigned subagent role or identifier.
|
||||
pub assigned_agent: Option<String>,
|
||||
/// Optional verification command to validate criteria completion.
|
||||
pub verification_command: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Optional summary level: 'compact' (IDs and titles), 'detailed', or 'full'. Defaults to 'detailed'.
|
||||
pub summary_level: Option<String>,
|
||||
/// Optional token budget cap. Truncates results to stay within max_tokens.
|
||||
pub max_tokens: Option<usize>,
|
||||
}
|
||||
|
||||
/// 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<Vec<String>>,
|
||||
/// Optional project namespace.
|
||||
pub namespace: Option<String>,
|
||||
/// Optional repository name.
|
||||
pub repo_name: Option<String>,
|
||||
/// Optional origin file path where the snippet was extracted from.
|
||||
pub origin_file: Option<String>,
|
||||
/// Optional line range in origin file (e.g. 'L10-L45').
|
||||
pub line_range: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
#[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<SnippetSearchMode>,
|
||||
/// Optional classification tags to filter by.
|
||||
pub tags: Option<Vec<String>>,
|
||||
/// Limit the number of results to avoid context limit overflow. Defaults to 10.
|
||||
pub limit: Option<usize>,
|
||||
/// Include the full code content of the matched items. If false, returns only IDs and descriptions. Defaults to false.
|
||||
pub include_body: Option<bool>,
|
||||
}
|
||||
|
||||
/// 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<usize>,
|
||||
}
|
||||
|
||||
/// 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<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CheckpointAction {
|
||||
@@ -382,66 +205,6 @@ pub struct ManageCheckpointTool {
|
||||
pub namespace: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Optional project namespace.
|
||||
pub namespace: Option<String>,
|
||||
/// Optional repository name.
|
||||
pub repo_name: Option<String>,
|
||||
/// Optional author or architect behind the decision.
|
||||
pub author: Option<String>,
|
||||
/// Optional affected system components or crates.
|
||||
pub affected_components: Option<Vec<String>>,
|
||||
/// Optional alternative designs or libraries considered.
|
||||
pub alternatives_considered: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Query architectural decision records.
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct QueryDecisionsTool {
|
||||
/// Optional search query.
|
||||
pub query: Option<String>,
|
||||
/// Limit the number of results to avoid context limit overflow. Defaults to 5.
|
||||
pub limit: Option<usize>,
|
||||
/// 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<bool>,
|
||||
}
|
||||
|
||||
/// 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<bool>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Optional reason for pinning this file.
|
||||
pub pin_reason: Option<String>,
|
||||
/// Optional symbol focus or line range.
|
||||
pub symbol_or_line: Option<String>,
|
||||
/// Optional repository name.
|
||||
pub repo_name: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Optional git branch to filter by.
|
||||
pub git_branch: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// Clear handoff memos after reading.
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct ClearHandoffMemosTool {
|
||||
/// Array of memo IDs to clear.
|
||||
pub ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<String, String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Optional description of milestone scope.
|
||||
pub description: Option<String>,
|
||||
/// Optional deliverables or task checklist.
|
||||
pub deliverables: Option<Vec<String>>,
|
||||
/// Optional repository name.
|
||||
pub repo_name: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Optional automated verification command or script.
|
||||
pub automated_check: Option<String>,
|
||||
/// Optional checked state.
|
||||
pub is_checked: Option<bool>,
|
||||
/// Optional repository name.
|
||||
pub repo_name: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// The associated git branch, if any.
|
||||
pub git_branch: Option<String>,
|
||||
/// Optional symbol references (e.g. ['MemoryIndex', 'switchTab']) associated with the tech debt.
|
||||
pub symbol_references: Option<Vec<String>>,
|
||||
/// Optional line range (e.g. 'L123-L145') associated with the tech debt.
|
||||
pub line_range: Option<String>,
|
||||
/// Optional debt severity: 'low', 'medium', 'high', 'critical'.
|
||||
pub severity: Option<String>,
|
||||
/// Optional repository name.
|
||||
pub repo_name: Option<String>,
|
||||
/// Optional file path where debt exists.
|
||||
pub file_path: Option<String>,
|
||||
/// Optional workaround currently in place.
|
||||
pub workaround: Option<String>,
|
||||
/// Optional effort estimate to fix (e.g. '1 hour', '2 days').
|
||||
pub effort_estimate: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Optional token budget cap. Truncates results to stay within max_tokens.
|
||||
pub max_tokens: Option<usize>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Array of active task IDs.
|
||||
pub active_task_ids: Vec<String>,
|
||||
/// Optional description of the context workspace purpose.
|
||||
pub description: Option<String>,
|
||||
/// Optional git branch associated with this workspace.
|
||||
pub git_branch: Option<String>,
|
||||
/// Optional repository name.
|
||||
pub repo_name: Option<String>,
|
||||
/// Optional VCS revision or commit hash.
|
||||
pub vcs_revision: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<bool>,
|
||||
}
|
||||
|
||||
/// 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<usize>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<bool>,
|
||||
}
|
||||
|
||||
/// 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<Vec<String>>,
|
||||
/// Maximum number of snippets to return. Defaults to 10.
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
// Consolidated Smart Management Tools
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)]
|
||||
@@ -1322,124 +717,6 @@ pub struct MilestonesTool {
|
||||
/// Optional repository name.
|
||||
pub repo_name: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
/// Optional namespace.
|
||||
pub namespace: Option<String>,
|
||||
/// Optional reason for pinning this file.
|
||||
pub pin_reason: Option<String>,
|
||||
/// Optional symbol focus or line range.
|
||||
pub symbol_or_line: Option<String>,
|
||||
/// Optional repository name.
|
||||
pub repo_name: Option<String>,
|
||||
/// Optional git branch.
|
||||
pub git_branch: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
/// Second workspace name (required for 'diff').
|
||||
pub other_name: Option<String>,
|
||||
/// Pinned file paths (for 'save').
|
||||
pub pinned_files: Option<Vec<String>>,
|
||||
/// Active task IDs (for 'save').
|
||||
pub active_task_ids: Option<Vec<String>>,
|
||||
/// Optional namespace.
|
||||
pub namespace: Option<String>,
|
||||
/// Optional description of the context workspace purpose.
|
||||
pub description: Option<String>,
|
||||
/// Optional git branch associated with this workspace.
|
||||
pub git_branch: Option<String>,
|
||||
/// Optional repository name.
|
||||
pub repo_name: Option<String>,
|
||||
/// Optional VCS revision or commit hash.
|
||||
pub vcs_revision: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
/// Optional namespace.
|
||||
pub namespace: Option<String>,
|
||||
/// Optional checklist category (e.g. 'Security', 'Testing', 'Formatting').
|
||||
pub category: Option<String>,
|
||||
/// Optional automated verification command or script.
|
||||
pub automated_check: Option<String>,
|
||||
/// Optional checked state.
|
||||
pub is_checked: Option<bool>,
|
||||
/// Optional repository name.
|
||||
pub repo_name: Option<String>,
|
||||
}
|
||||
|
||||
#[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<Vec<String>>,
|
||||
/// Optional alternative designs or libraries considered.
|
||||
pub alternatives_considered: Option<Vec<String>>,
|
||||
/// Optional superseded ADR ID.
|
||||
pub supersedes: Option<String>,
|
||||
/// Limit the number of query results. Defaults to 20.
|
||||
pub limit: Option<usize>,
|
||||
/// Include the full body of the matched decisions. Defaults to true.
|
||||
pub include_body: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)]
|
||||
@@ -1562,8 +845,10 @@ pub struct TechDebtTool {
|
||||
#[serde(alias = "tech_debt_id", alias = "debt_id")]
|
||||
pub id: Option<String>,
|
||||
/// Tech debt description (required for 'log').
|
||||
#[serde(alias = "desc", alias = "title")]
|
||||
#[serde(alias = "desc")]
|
||||
pub description: Option<String>,
|
||||
/// Optional title or summary of tech debt.
|
||||
pub title: Option<String>,
|
||||
/// Ideal solution (optional for 'log').
|
||||
#[serde(alias = "solution", alias = "fix")]
|
||||
pub ideal_solution: Option<String>,
|
||||
@@ -1591,6 +876,10 @@ pub struct TechDebtTool {
|
||||
pub workaround: Option<String>,
|
||||
/// Optional effort estimate to fix.
|
||||
pub effort_estimate: Option<String>,
|
||||
/// Summary level for list action: 'compact', 'detailed', or 'full'.
|
||||
pub summary_level: Option<String>,
|
||||
/// Optional token budget cap for list output.
|
||||
pub max_tokens: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)]
|
||||
|
||||
+2
-2
@@ -114,6 +114,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
});
|
||||
|
||||
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<dyn std::error::Error>> {
|
||||
&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;
|
||||
|
||||
Reference in new issue
Block a user