From f4dcf3acbf347b78bbb1e126dc7a086b8a3eef98 Mon Sep 17 00:00:00 2001 From: Riz Ashraf Date: Thu, 8 Oct 2026 10:52:19 +0100 Subject: [PATCH] feat: event-driven parity, Windows shell detection, dirty build timestamps, and gatekeeper integration --- .gitignore | 1 + Cargo.lock | 3 + agent-rules/mcp_memory_workflow.md | 6 + build_template.rs | 41 +- gemini-integration.lua | 89 +++- gemini-ui.lua | 44 ++ justfile | 12 +- nvim-core/Cargo.toml | 3 + nvim-core/build.rs | 23 +- nvim-core/src/lib.rs | 103 +++- scripts/git-pre-push.py | 204 +++++++ server/Cargo.toml | 16 +- server/build.rs | 17 +- server/src/api/events.rs | 85 ++- server/src/api/rest.rs | 23 +- server/src/api/setup.rs | 275 +++++++++- server/src/api/telemetry.rs | 50 +- server/src/api/ws.rs | 35 ++ server/src/dashboard.html | 172 ++++++ server/src/dashboard.js | 520 +++++++++++++++++- server/src/dashboard.ts | 594 ++++++++++++++++++++- server/src/handlers/env.rs | 12 +- server/src/handlers/vision.rs | 819 ++++++++++++++++++++++++++++- server/src/indexer.rs | 7 + server/src/lib.rs | 109 +++- server/src/router.rs | 153 ++++++ server/src/search.rs | 28 + server/src/state.rs | 111 +++- server/src/tools.rs | 29 +- server/src/watcher.rs | 23 +- server/tests/parity_test.rs | 11 +- stub/Cargo.toml | 3 + stub/build.rs | 23 +- 33 files changed, 3501 insertions(+), 143 deletions(-) create mode 100644 scripts/git-pre-push.py diff --git a/.gitignore b/.gitignore index c5f57a8..e5b43f0 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ llvm-cov.zip temp.json *.txt summary.md +__pycache__/ diff --git a/Cargo.lock b/Cargo.lock index 7981bdc..9aa590b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2661,6 +2661,7 @@ dependencies = [ "tree-sitter-rust", "tree-sitter-typescript", "uuid", + "windows", "windows-sys 0.59.0", "winres", ] @@ -2670,6 +2671,7 @@ name = "mcp-memory-stub" version = "1.0.0" dependencies = [ "async-channel", + "chrono", "clap", "dirs 7.0.0", "futures-util", @@ -2988,6 +2990,7 @@ name = "nvim-core" version = "1.0.0" dependencies = [ "bytes", + "chrono", "dashmap", "dirs 7.0.0", "mcp-stdio", diff --git a/agent-rules/mcp_memory_workflow.md b/agent-rules/mcp_memory_workflow.md index aaeff76..3525c23 100644 --- a/agent-rules/mcp_memory_workflow.md +++ b/agent-rules/mcp_memory_workflow.md @@ -42,6 +42,12 @@ Before making expensive active tool calls, read available MCP resources: - **Hypotheses & Root Cause Analysis**: When diagnosing complex bugs or race conditions, call `hypotheses` (action: "log" / "query") to record test evidence and maintain reasoning trails across sessions. - **Inter-Agent Coordination**: Autonomous subagents should call `agent_signals` (action: "broadcast" / "query") to publish events and discover peer agent status. - **Process & Daemon Logs**: Query or tail daemon logs with `process_logs` (action: "get", "watch", "clear") instead of dumping log files into context. +- **Clipboard & Multimodal Screenshots**: + - **User Image & Visual Queries**: When the user says "look at image in clipboard", "see screenshot", "check the clipboard", or refers to an error screenshot, ALWAYS call `clipboard(action: "image")`. + - **Decoupled Screenshot History**: Even if the user got sidetracked and copied text/URLs afterwards, `clipboard(action: "image")` reliably retrieves the last captured screenshot from the event-driven cache with exact metadata (`file_path`, `file_path_wsl`, `age`, `dimensions`, `ocr_text`). + - **WSL Ubuntu Compatibility**: For WSL sessions, use the returned `file_path_wsl` (e.g. `/mnt/c/...`) directly with `view_file`. + - **Text-Only Models & Fast Context**: Use the returned deterministic `ocr_text` to immediately extract error traces, URLs, and code snippets without waiting for multimodal vision tokens. + - **Copying Formatted Tables / Data**: `clipboard(action: "read")` automatically strips Win32 `CF_HTML` headers and converts HTML tables directly into clean GFM Markdown. - **End-of-Session Handoff**: When finishing work or logging off, trigger the `handoff_routine` prompt or call `generate_standup_report` and `handoff_memos` (action: "leave"). ## 3. Delegation diff --git a/build_template.rs b/build_template.rs index 0e0609c..cbee7bf 100644 --- a/build_template.rs +++ b/build_template.rs @@ -1,20 +1,39 @@ use std::process::Command; fn main() { - let git_hash = Command::new("git") - .args(&["rev-parse", "--short", "HEAD"]) - .output() - .ok() - .and_then(|out| String::from_utf8(out.stdout).ok()) - .unwrap_or_else(|| "unknown".to_string()); - - let git_date = Command::new("git") - .args(&["log", "-1", "--format=%cd", "--date=format:%Y.%m.%d"]) + let mut git_hash = Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) .output() .ok() .and_then(|out| String::from_utf8(out.stdout).ok()) .unwrap_or_else(|| "unknown".to_string()); - let version = format!("{} ({} {})", env!("CARGO_PKG_VERSION"), git_date.trim(), git_hash.trim()); - println!("cargo:rustc-env=APP_VERSION={}", version); + git_hash = git_hash.trim().to_string(); + + let is_dirty = Command::new("git") + .args(["status", "--porcelain"]) + .output() + .is_ok_and(|out| !out.stdout.is_empty()); + + if is_dirty { + let dirty_ts = chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string(); + git_hash.push_str(&format!("-dirty-{dirty_ts}")); + } + + let git_date = Command::new("git") + .args(["log", "-1", "--format=%cd", "--date=format:%y.%m.%d.%H%M%S"]) + .output() + .ok() + .and_then(|out| String::from_utf8(out.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string()); + + let version = format!("v{git_date} ({git_hash})"); + println!("cargo:rustc-env=APP_VERSION={version}"); + println!("cargo:rerun-if-changed=../.git/HEAD"); + println!("cargo:rerun-if-changed=../.git/index"); + println!("cargo:rerun-if-changed=src"); + println!("cargo:rerun-if-changed=Cargo.toml"); + println!("cargo:rerun-if-changed=build.rs"); } diff --git a/gemini-integration.lua b/gemini-integration.lua index 2f6c00e..b869464 100644 --- a/gemini-integration.lua +++ b/gemini-integration.lua @@ -1,5 +1,9 @@ -- gemini-integration.lua -- Registers the active Neovim instance for the Antigravity MCP Server + +local last_active_task = nil +local last_active_gate = nil + vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePost"}, { callback = function(ev) -- Only register if Neovim has an active UI attached (ignore headless/embedded instances) @@ -18,7 +22,7 @@ vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePos end end - -- 2. V2 Telemetry Push (WebSockets & Cross-OS Sync via Rust Server) + -- 2. V2 Telemetry Push (HTTP with Knowledge Projection + UDP Fast Mirror) local file = vim.api.nvim_buf_get_name(0) local cursor = vim.api.nvim_win_get_cursor(0) local row, col = cursor[1], cursor[2] @@ -26,6 +30,7 @@ vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePos '{"session_id": "%s", "event": "%s", "file": "%s", "line": %d, "col": %d}', server_name:gsub("\\", "\\\\"), ev.event, file:gsub("\\", "\\\\"), row, col ) + local current_buf = ev.buf vim.fn.jobstart({ "curl", "-s", "-X", "POST", "http://127.0.0.1:3000/nvim/telemetry", @@ -38,11 +43,16 @@ vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePos local raw = table.concat(data, "") if raw ~= "" then local ok, resp = pcall(vim.fn.json_decode, raw) - if ok and resp and (resp.tech_debts or resp.adrs) then + if ok and resp then vim.schedule(function() - if _G.gemini and _G.gemini.project_knowledge then + if (resp.tech_debts or resp.adrs) and _G.gemini and _G.gemini.project_knowledge then _G.gemini.project_knowledge(resp.tech_debts, resp.adrs, current_buf) end + if _G.gemini and _G.gemini.update_statusline then + last_active_task = resp.active_task + last_active_gate = resp.active_gate + _G.gemini.update_statusline(resp.active_task, resp.active_gate) + end end) end end @@ -50,7 +60,6 @@ vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePos end, }) - -- 3. UDP Mirroring (Native fast sync) local uv = vim.uv or vim.loop local client = uv.new_udp() if client then @@ -64,6 +73,78 @@ vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePos desc = "Register active Neovim instance for Gemini CLI", }) +-- 3. Push-driven Server-Sent Events (SSE) subscriber (Zero Polling) +local sse_job_id = nil + +local function start_sse_subscriber() + if sse_job_id then + pcall(vim.fn.jobstop, sse_job_id) + sse_job_id = nil + end + + sse_job_id = vim.fn.jobstart({ + "curl", "-s", "-N", "http://127.0.0.1:3000/api/events/stream" + }, { + on_stdout = function(_, data) + if not data then return end + for _, line in ipairs(data) do + if line:match("^data:%s*") then + local raw_json = line:gsub("^data:%s*", "") + local ok, ev = pcall(vim.fn.json_decode, raw_json) + if ok and ev and ev.payload then + local topic = ev.topic + local payload = ev.payload + local changed = false + + if topic == "task:event" then + if payload.active_task ~= nil then + last_active_task = payload.active_task + changed = true + elseif payload.status == "in_progress" or payload.status == "active" then + last_active_task = payload.title or payload.task_id + changed = true + elseif payload.status == "completed" or payload.status == "expired" or payload.status == "failed" then + if last_active_task == (payload.title or payload.task_id) then + last_active_task = nil + changed = true + end + end + elseif topic == "gate:event" then + if payload.active_gate ~= nil then + last_active_gate = payload.active_gate + changed = true + elseif payload.status then + last_active_gate = payload.status + changed = true + end + end + + if changed and _G.gemini and _G.gemini.update_statusline then + vim.schedule(function() + _G.gemini.update_statusline(last_active_task, last_active_gate) + end) + end + end + end + end + end, + on_exit = function() + sse_job_id = nil + -- Reconnect after server restart without busy looping + vim.defer_fn(function() + if #vim.api.nvim_list_uis() > 0 then + start_sse_subscriber() + end + end, 5000) + end, + }) +end + +-- Initialize SSE push listener when UI is active +if #vim.api.nvim_list_uis() > 0 then + start_sse_subscriber() +end + -- Load UI & Keymaps local is_win = vim.fn.has("win32") == 1 local ui_path = is_win and "C:/Users/reazul.ashraf/workspace/rust/mcp-memory/gemini-ui.lua" or "/mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory/gemini-ui.lua" diff --git a/gemini-ui.lua b/gemini-ui.lua index a11f76b..f66abc4 100644 --- a/gemini-ui.lua +++ b/gemini-ui.lua @@ -348,4 +348,48 @@ function M.send_to_terminal(command) end end +-- ============================================================================ +-- Antigravity Statusline Integration (Push-driven / Zero-polling) +-- ============================================================================ +local statusline_cache = { + text = "" +} + +function M.update_statusline(active_task, active_gate) + local parts = {} + + if active_task and active_task ~= vim.NIL and active_task ~= "" then + local title = tostring(active_task) + if #title > 24 then + title = title:sub(1, 22) .. ".." + end + table.insert(parts, "📋 " .. title) + end + + if active_gate and active_gate ~= vim.NIL and active_gate ~= "" then + local status = tostring(active_gate):lower() + if status == "authorized" then + table.insert(parts, "🟢 PUSH:OK") + elseif status == "blocked" then + table.insert(parts, "🔴 PUSH:BLOCKED") + else + table.insert(parts, "🟡 PUSH:WAIT") + end + end + + if #parts > 0 then + statusline_cache.text = " [AGY: " .. table.concat(parts, " | ") .. "] " + else + statusline_cache.text = "" + end + + vim.schedule(function() + pcall(vim.cmd, "redrawstatus") + end) +end + +function M.statusline() + return statusline_cache.text +end + return M diff --git a/justfile b/justfile index 8da1e06..7045b3c 100644 --- a/justfile +++ b/justfile @@ -28,7 +28,7 @@ deploy-win: deploy-server deploy-stub-win deploy-nvim-win deploy-rules-win # Stop the background server gracefully and verify port release stop port="3000": @Write-Host "Shutting down MCP server gracefully on port {{port}}..." -ForegroundColor Cyan - -& { $token = Get-Content "$env:USERPROFILE\.gemini\mcp_memory\admin.token" -ErrorAction SilentlyContinue; if ($token) { $hdr = @{ Authorization = "Bearer $($token.Trim())" }; $res = Invoke-RestMethod -Uri "http://127.0.0.1:{{port}}/shutdown" -Method Post -Headers $hdr -ErrorAction SilentlyContinue } else { $res = Invoke-RestMethod -Uri "http://127.0.0.1:{{port}}/shutdown" -Method Post -ErrorAction SilentlyContinue }; if ($res) { Write-Host "✅ Shutdown acknowledged with version details:" -ForegroundColor Green; $res | ConvertTo-Json -Depth 5 }; $stopped = $false; for ($i = 1; $i -le 10; $i++) { $conn = Get-NetTCPConnection -LocalPort {{port}} -State Listen -ErrorAction SilentlyContinue; $proc = Get-Process mcp-memory-server -ErrorAction SilentlyContinue; if (-not $conn -and -not $proc) { $stopped = $true; Write-Host '✅ Server process and port {{port}} fully terminated.' -ForegroundColor Green; break }; Start-Sleep -Seconds 1 }; if (-not $stopped) { Write-Host '⚠️ Server process did not exit within 10s. Force killing process...' -ForegroundColor Yellow; Get-Process mcp-memory-server -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue; Get-NetTCPConnection -LocalPort {{port}} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue }; Start-Sleep -Seconds 2 } } + -& { $token = Get-Content "$env:USERPROFILE\.gemini\mcp_memory\admin.token" -ErrorAction SilentlyContinue; if ($token) { $hdr = @{ Authorization = "Bearer $($token.Trim())" }; $res = Invoke-RestMethod -Uri "http://127.0.0.1:{{port}}/shutdown" -Method Post -Headers $hdr -ErrorAction SilentlyContinue } else { $res = Invoke-RestMethod -Uri "http://127.0.0.1:{{port}}/shutdown" -Method Post -ErrorAction SilentlyContinue }; if ($res) { Write-Host "✅ Shutdown acknowledged with version details:" -ForegroundColor Green; $res | ConvertTo-Json -Depth 5 }; $procs = @(Get-Process mcp-memory-server -ErrorAction SilentlyContinue); if ($procs.Count -gt 0) { foreach ($p in $procs) { $exited = $p.WaitForExit(10000); if (-not $exited) { Write-Host "⚠️ Process $($p.Id) did not exit within 10s. Force killing..." -ForegroundColor Yellow; $p | Stop-Process -Force -ErrorAction SilentlyContinue } } }; $conns = Get-NetTCPConnection -LocalPort {{port}} -State Listen -ErrorAction SilentlyContinue; if ($conns) { $conns | Select-Object -ExpandProperty OwningProcess | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue } } } @Write-Host "✅ Shutdown complete. Port {{port}} and process are free." -ForegroundColor Green # Start the server in the background and verify /health @@ -125,12 +125,12 @@ deploy-wsl: deploy-stub deploy-nvim deploy-rules-wsl # Deploy WSL-native stub binary (skips if identical checksum) deploy-stub: @Write-Host "Deploying WSL Stub natively..." -ForegroundColor Cyan - wsl.exe -d Ubuntu -e bash -c 'export PATH="/home/riz/.cargo/bin:$PATH" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && src="target/release/mcp-memory-stub" && dest="/home/riz/.local/bin/mcp-memory-stub" && if [ -f "$dest" ] && cmp -s "$src" "$dest"; then echo -e "\033[33mSkipping $dest (identical checksum).\033[0m"; else cp "$src" "$dest" && echo -e "\033[32mCopied updated $src to $dest\033[0m"; fi' + wsl.exe -d Ubuntu -e bash -c 'export PATH="/home/riz/.cargo/bin:$PATH" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && src="target/release/mcp-memory-stub" && dest="/home/riz/.local/bin/mcp-memory-stub" && if [ -f "$dest" ] && cmp -s "$src" "$dest"; then echo -e "\033[33mSkipping $dest (identical checksum).\033[0m"; else cp --remove-destination "$src" "$dest" && echo -e "\033[32mCopied updated $src to $dest\033[0m"; fi' # Deploy WSL-native Neovim plugin binary (skips if identical checksum) deploy-nvim: @Write-Host "Deploying WSL Nvim natively..." -ForegroundColor Cyan - wsl.exe -d Ubuntu -e bash -c 'export PATH="/home/riz/.cargo/bin:$PATH" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && src="target/release/mcp-memory-nvim" && dest="/home/riz/.local/bin/mcp-memory-nvim" && if [ -f "$dest" ] && cmp -s "$src" "$dest"; then echo -e "\033[33mSkipping $dest (identical checksum).\033[0m"; else cp "$src" "$dest" && echo -e "\033[32mCopied updated $src to $dest\033[0m"; fi' + wsl.exe -d Ubuntu -e bash -c 'export PATH="/home/riz/.cargo/bin:$PATH" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && src="target/release/mcp-memory-nvim" && dest="/home/riz/.local/bin/mcp-memory-nvim" && if [ -f "$dest" ] && cmp -s "$src" "$dest"; then echo -e "\033[33mSkipping $dest (identical checksum).\033[0m"; else cp --remove-destination "$src" "$dest" && echo -e "\033[32mCopied updated $src to $dest\033[0m"; fi' # Deploy global Agent rules to WSL config directory (skips identical files) deploy-rules-wsl: @@ -191,8 +191,8 @@ all-nvim-wsl: build-nvim deploy-nvim # Complete lifecycle: stop, build, deploy, start, and verify WSL server all-server-wsl port="3000": (stop port) build-server deploy-server (start port) (verify port) -# Install git post-commit hook for automatic ADR/Task/TechDebt state reconciliation +# Install git hooks (post-commit reconciliation and pre-push gatekeeper) install-git-hooks: - @Write-Host "Installing git post-commit reconciliation hook..." -ForegroundColor Cyan - -& { $hookContent = "#!/bin/sh`npython3 scripts/git-reconcile.py 2>/dev/null || python scripts/git-reconcile.py 2>/dev/null || true`n"; $hookPath = ".git\hooks\post-commit"; [System.IO.File]::WriteAllText($hookPath, $hookContent); Write-Host "✅ Git post-commit hook installed to $hookPath" -ForegroundColor Green } + @Write-Host "Installing git post-commit and pre-push hooks..." -ForegroundColor Cyan + -& { $postCommitContent = "#!/bin/sh`npython3 scripts/git-reconcile.py 2>/dev/null || python scripts/git-reconcile.py 2>/dev/null || true`n"; [System.IO.File]::WriteAllText(".git\hooks\post-commit", $postCommitContent); $prePushContent = "#!/bin/sh`nif command -v python3 >/dev/null 2>&1; then`n exec python3 scripts/git-pre-push.py `"`$@`"`nelse`n exec python scripts/git-pre-push.py `"`$@`"`nfi`n"; [System.IO.File]::WriteAllText(".git\hooks\pre-push", $prePushContent); Write-Host "✅ Git post-commit and pre-push hooks installed." -ForegroundColor Green } diff --git a/nvim-core/Cargo.toml b/nvim-core/Cargo.toml index 1942a2c..eea6158 100644 --- a/nvim-core/Cargo.toml +++ b/nvim-core/Cargo.toml @@ -17,3 +17,6 @@ mcp-stdio = { version = "1.0.0", path = "../mcp-stdio" } bytes = "1.12.1" dashmap = "6.2.1" +[build-dependencies] +chrono = "0.4.45" + diff --git a/nvim-core/build.rs b/nvim-core/build.rs index bd35f2a..cbee7bf 100644 --- a/nvim-core/build.rs +++ b/nvim-core/build.rs @@ -1,22 +1,39 @@ use std::process::Command; fn main() { - let git_hash = Command::new("git") + let mut git_hash = Command::new("git") .args(["rev-parse", "--short", "HEAD"]) .output() .ok() .and_then(|out| String::from_utf8(out.stdout).ok()) .unwrap_or_else(|| "unknown".to_string()); + git_hash = git_hash.trim().to_string(); + + let is_dirty = Command::new("git") + .args(["status", "--porcelain"]) + .output() + .is_ok_and(|out| !out.stdout.is_empty()); + + if is_dirty { + let dirty_ts = chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string(); + git_hash.push_str(&format!("-dirty-{dirty_ts}")); + } + let git_date = Command::new("git") .args(["log", "-1", "--format=%cd", "--date=format:%y.%m.%d.%H%M%S"]) .output() .ok() .and_then(|out| String::from_utf8(out.stdout).ok()) - .unwrap_or_else(|| "unknown".to_string()); + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string()); - let version = format!("{} ({})", git_date.trim(), git_hash.trim()); + let version = format!("v{git_date} ({git_hash})"); println!("cargo:rustc-env=APP_VERSION={version}"); println!("cargo:rerun-if-changed=../.git/HEAD"); println!("cargo:rerun-if-changed=../.git/index"); + println!("cargo:rerun-if-changed=src"); + println!("cargo:rerun-if-changed=Cargo.toml"); + println!("cargo:rerun-if-changed=build.rs"); } diff --git a/nvim-core/src/lib.rs b/nvim-core/src/lib.rs index 6720288..9adfc05 100644 --- a/nvim-core/src/lib.rs +++ b/nvim-core/src/lib.rs @@ -319,6 +319,7 @@ static PENDING_REQUESTS: LazyLock< pub struct NvimState { pub cursor: String, pub active_buffer_id: u64, + pub active_buffer_name: String, } static NVIM_STATE: LazyLock>> = @@ -341,8 +342,27 @@ fn handle_nvim_notification(params: &[rmpv::Value]) { state.cursor = format!("Line: {}, Column: {}", row, col); } } - "TextChanged" => { - tracing::debug!("Shadow buffer text changed event received"); + "TextChanged" | "BufEnter" => { + if params.len() > 1 { + let buf_id = match ¶ms[1] { + rmpv::Value::Integer(i) => i.as_u64().unwrap_or(0), + _ => 0, + }; + let buf_name = if params.len() > 2 { + match ¶ms[2] { + rmpv::Value::String(s) => s.as_str().unwrap_or("").to_string(), + _ => String::new(), + } + } else { + String::new() + }; + let mut state = NVIM_STATE.lock().unwrap_or_else(|e| e.into_inner()); + state.active_buffer_id = buf_id; + if !buf_name.is_empty() { + state.active_buffer_name = buf_name; + } + } + tracing::debug!("Neovim buffer changed/entered event received"); } _ => {} } @@ -591,7 +611,9 @@ async fn get_nvim_connection() -> Result, String> { vim.api.nvim_create_autocmd({"TextChanged", "TextChangedI", "BufEnter"}, { group = "MCP_Tracking", callback = function() - pcall(vim.rpcnotify, channel, "mcp_event", "TextChanged", vim.api.nvim_get_current_buf()) + local b = vim.api.nvim_get_current_buf() + local n = vim.api.nvim_buf_get_name(b) + pcall(vim.rpcnotify, channel, "mcp_event", "TextChanged", b, n) end }) "#; @@ -1686,7 +1708,7 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) { bufnr = 0 end _G.gemini.preview_diff(diff_id, bufnr, start_line, end_line, lines) - return "Preview added" + return { status = "Preview added", diff_id = diff_id } else error("gemini UI not loaded") end @@ -1708,6 +1730,61 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) { send_error(id, -32602, "Missing arguments: requires file_path, start_line, end_line, and preview_content").await; } } + "prompt" | "prompt_user" => { + let prompt_text = args + .get("prompt") + .or_else(|| args.get("prompt_text")) + .and_then(|v| v.as_str()); + if let Some(prompt) = prompt_text { + use std::time::{SystemTime, UNIX_EPOCH}; + let request_id = args + .get("request_id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .to_string() + }); + + let options_arr = match args.get("options").and_then(|v| v.as_array()) { + Some(arr) => { + let rmp_arr: Vec = arr + .iter() + .filter_map(|item| item.as_str()) + .map(|s| rmpv::Value::String(s.into())) + .collect(); + rmpv::Value::Array(rmp_arr) + } + None => rmpv::Value::Nil, + }; + + let lua_code = r#" + local prompt_text, options, request_id = ... + if _G.gemini and _G.gemini.prompt_user then + _G.gemini.prompt_user(prompt_text, options, request_id) + return { status = "Prompt displayed", request_id = request_id } + else + error("gemini UI not loaded") + end + "#; + handle_lua_result!( + id, + execute_nvim_lua_with_args( + lua_code, + vec![ + rmpv::Value::String(prompt.into()), + options_arr, + rmpv::Value::String(request_id.into()), + ] + ) + ); + } else { + send_error(id, -32602, "Missing 'prompt' or 'prompt_text'").await; + } + } "extmark" => { if let (Some(line), Some(text)) = ( args.get("line").and_then(serde_json::Value::as_i64), @@ -1996,8 +2073,22 @@ mod tests { ]), ]; handle_nvim_notification(¶ms); - let state = NVIM_STATE.lock().unwrap(); - assert_eq!(state.cursor, "Line: 15, Column: 4"); + { + let state = NVIM_STATE.lock().unwrap(); + assert_eq!(state.cursor, "Line: 15, Column: 4"); + } + + let text_params = vec![ + rmpv::Value::String("TextChanged".into()), + rmpv::Value::Integer(157.into()), + rmpv::Value::String("server/src/dashboard.ts".into()), + ]; + handle_nvim_notification(&text_params); + { + let state = NVIM_STATE.lock().unwrap(); + assert_eq!(state.active_buffer_id, 157); + assert_eq!(state.active_buffer_name, "server/src/dashboard.ts"); + } } #[tokio::test] diff --git a/scripts/git-pre-push.py b/scripts/git-pre-push.py new file mode 100644 index 0000000..cd67916 --- /dev/null +++ b/scripts/git-pre-push.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +""" +Pre-Push Gatekeeper for Git and MCP Memory. +Enforces workspace quality gates: +1. Allows tag pushes and branch deletions without verification. +2. Bypasses tests for docs/config-only modifications (.md, .txt, .png, justfile, etc.). +3. Queries mcp-memory gate API (/gate/verify?action=push&target=). +4. If no pre-authorized gate is found, runs project tests locally to verify 100% pass before allowing push. +""" + +import os +import sys +import json +import subprocess +import urllib.request +import urllib.error + +DOC_EXTENSIONS = { + ".md", ".txt", ".png", ".jpg", ".jpeg", ".pdf", ".svg", + ".drawio", ".gif", ".ico", ".csv" +} + +DOC_FILENAMES = { + "justfile", "jenkinsfile", "dockerfile", "license", + "changelog", ".gitignore", ".gitattributes" +} + +def is_doc_or_config(filepath: str) -> bool: + base = os.path.basename(filepath).lower() + if base in DOC_FILENAMES: + return True + ext = os.path.splitext(base)[1].lower() + return ext in DOC_EXTENSIONS + +def run_git(cmd): + try: + res = subprocess.run(["git"] + cmd, capture_output=True, text=True, check=True) + return res.stdout.strip() + except Exception: + return "" + +def check_mcp_gate(port: str, branch: str): + """ + Returns (status_code, reason) + status_code: 200 (Authorized), 403 (Blocked), 404 (Not found), 0 (Server unreachable) + """ + url = f"http://127.0.0.1:{port}/gate/verify?action=push&target={branch}" + try: + req = urllib.request.Request(url, method="GET") + with urllib.request.urlopen(req, timeout=1.5) as resp: + return resp.status, "Authorized" + except urllib.error.HTTPError as e: + body = "" + try: + body = e.read().decode("utf-8") + except Exception: + pass + return e.code, body + except Exception: + return 0, "Server unreachable" + +def set_mcp_gate(port: str, branch: str, authorize: bool, reason: str): + url = f"http://127.0.0.1:{port}/gate/set" + payload = { + "action": "push", + "target": branch, + "authorize": authorize, + "reason": reason + } + try: + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=1.5): + pass + except Exception: + pass + +def post_mcp_event(port: str, topic: str, payload: dict): + url = f"http://127.0.0.1:{port}/api/events/post" + try: + data = json.dumps({"topic": topic, "payload": payload}).encode("utf-8") + req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=1.0): + pass + except Exception: + pass + +def wait_for_mcp_decision(port: str, topic: str, timeout_secs: int = 2): + """Waits for an operator or auditor decision on the event bus.""" + url = f"http://127.0.0.1:{port}/events/wait?topic={topic}&timeout={timeout_secs}" + try: + req = urllib.request.Request(url, method="GET") + with urllib.request.urlopen(req, timeout=timeout_secs + 0.5) as resp: + data = json.loads(resp.read().decode("utf-8")) + payload = data.get("payload", {}) + status = str(payload.get("status", "")).lower() + reason = payload.get("reason", "Decision received via event bus") + if status in ("authorized", "approved", "ok", "true"): + return True, reason + elif status in ("blocked", "denied", "rejected", "false"): + return False, reason + except Exception: + pass + return None, "" + +def run_local_tests() -> bool: + # 1. Rust workspace + if os.path.exists("Cargo.toml"): + print("\033[36m[Pre-Push Gatekeeper]\033[0m Running cargo test...") + cmd = ["cargo", "test", "--workspace"] + res = subprocess.run(cmd) + return res.returncode == 0 + # 2. Python workspace (uv / pytest) + elif os.path.exists("pyproject.toml") or os.path.exists("setup.py"): + print("\033[36m[Pre-Push Gatekeeper]\033[0m Running python tests...") + cmd = ["uv", "run", "pytest"] if os.system("uv --version >nul 2>&1") == 0 else ["pytest"] + res = subprocess.run(cmd) + return res.returncode == 0 + return True + +def main(): + # Read stdin passed by git pre-push: + lines = sys.stdin.read().splitlines() + if not lines: + sys.exit(0) + + port = os.environ.get("MCP_PORT", "3000") + for line in lines: + parts = line.strip().split() + if len(parts) < 4: + continue + local_ref, local_sha, remote_ref, remote_sha = parts[:4] + + # 1. Skip deletions + if local_sha == "0000000000000000000000000000000000000000" or local_ref == "(delete)": + continue + + # 2. Skip tags + if local_ref.startswith("refs/tags/"): + print(f"\033[33m[Pre-Push Gatekeeper]\033[0m Tag push detected ({local_ref}). Quality gate bypassed.") + continue + + # Extract branch name + branch = local_ref.replace("refs/heads/", "") + + # 3. Check for documentation / config-only changes + zero_sha = "0000000000000000000000000000000000000000" + diff_range = local_sha if remote_sha == zero_sha else f"{remote_sha}..{local_sha}" + changed_files_raw = run_git(["diff", "--name-only", diff_range]) + changed_files = [f.strip() for f in changed_files_raw.splitlines() if f.strip()] + + if changed_files and all(is_doc_or_config(f) for f in changed_files): + print(f"\033[33m[Pre-Push Gatekeeper]\033[0m All modified files are docs/config-only. Quality gate bypassed.") + continue + + # 4. Query MCP Memory gate status + status, reason = check_mcp_gate(port, branch) + if status == 200: + print(f"\033[32m[Pre-Push Gatekeeper]\033[0m \u2714 Push AUTHORIZED by MCP Memory gate for branch '{branch}'.") + continue + elif status == 403: + print(f"\033[31m[Pre-Push Gatekeeper]\033[0m \u274c Push BLOCKED by MCP Memory gate for branch '{branch}'!") + if reason: + print(f" Reason: {reason}") + sys.exit(1) + + # 5. No gate found (404 or server offline) -> Request interactive approval or verify unit tests + if status != 0: + post_mcp_event(port, "gate:approval_requested", { + "branch": branch, + "local_sha": local_sha, + "remote_sha": remote_sha, + "changed_files_count": len(changed_files) + }) + # Fast check: Did dashboard / operator / auditor approve or reject via event bus? + decision, dec_reason = wait_for_mcp_decision(port, f"gate:decision:{branch}", timeout_secs=1) + if decision is True: + print(f"\033[32m[Pre-Push Gatekeeper]\033[0m \u2714 Push INTERACTIVELY AUTHORIZED via event bus: {dec_reason}") + set_mcp_gate(port, branch, True, dec_reason) + post_mcp_event(port, "gate:event", {"status": "authorized", "active_gate": "authorized", "branch": branch}) + continue + elif decision is False: + print(f"\033[31m[Pre-Push Gatekeeper]\033[0m \u274c Push BLOCKED by operator: {dec_reason}") + set_mcp_gate(port, branch, False, dec_reason) + post_mcp_event(port, "gate:event", {"status": "blocked", "active_gate": "blocked", "branch": branch}) + sys.exit(1) + + print(f"\033[33m[Pre-Push Gatekeeper]\033[0m No pre-authorized gate record for '{branch}'. Verifying unit tests...") + if run_local_tests(): + print(f"\033[32m[Pre-Push Gatekeeper]\033[0m \u2714 Unit tests passed 100%. Push authorized.") + if status != 0: + set_mcp_gate(port, branch, True, "Unit tests passed locally via pre-push gatekeeper") + post_mcp_event(port, "gate:event", {"status": "authorized", "active_gate": "authorized", "branch": branch}) + else: + print(f"\033[31m[Pre-Push Gatekeeper]\033[0m \u274c Push BLOCKED! Unit tests failed locally. Fix failing tests before pushing.") + if status != 0: + set_mcp_gate(port, branch, False, "Unit tests failed during pre-push gatekeeper check") + post_mcp_event(port, "gate:event", {"status": "blocked", "active_gate": "blocked", "branch": branch}) + sys.exit(1) + + sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/server/Cargo.toml b/server/Cargo.toml index e9bbd2a..2b4b51e 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -48,6 +48,7 @@ ignore = "0.4.33" notify = "7" blake3 = "1.8" tokio-stream = { version = "0.1", features = ["sync"] } +chrono = { version = "0.4.45", features = ["serde"] } [build-dependencies] chrono = "0.4.45" @@ -58,4 +59,17 @@ tempfile = "3.27.0" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.59.0", features = ["Win32_System_DataExchange", "Win32_System_Memory"] } +windows-sys = { version = "0.59.0", features = [ + "Win32_System_DataExchange", + "Win32_System_Memory", + "Win32_UI_WindowsAndMessaging", + "Win32_Foundation", + "Win32_System_Com", +] } +windows = { version = "0.62.2", features = [ + "Foundation", + "Foundation_Collections", + "Media_Ocr", + "Graphics_Imaging", + "Storage_Streams", +] } diff --git a/server/build.rs b/server/build.rs index 770e681..89b2475 100644 --- a/server/build.rs +++ b/server/build.rs @@ -16,15 +16,26 @@ fn main() { .is_ok_and(|out| !out.stdout.is_empty()); if is_dirty { - git_hash.push_str("-dirty"); + let dirty_ts = chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string(); + git_hash.push_str(&format!("-dirty-{dirty_ts}")); } - let build_date = chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string(); + let git_date = Command::new("git") + .args(["log", "-1", "--format=%cd", "--date=format:%y.%m.%d.%H%M%S"]) + .output() + .ok() + .and_then(|out| String::from_utf8(out.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string()); - let version = format!("{build_date} ({git_hash})"); + let version = format!("v{git_date} ({git_hash})"); println!("cargo:rustc-env=APP_VERSION={version}"); println!("cargo:rerun-if-changed=../.git/HEAD"); println!("cargo:rerun-if-changed=../.git/index"); + println!("cargo:rerun-if-changed=src"); + println!("cargo:rerun-if-changed=Cargo.toml"); + println!("cargo:rerun-if-changed=build.rs"); if std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default() == "windows" { let mut res = winres::WindowsResource::new(); diff --git a/server/src/api/events.rs b/server/src/api/events.rs index fe218d0..70a166e 100644 --- a/server/src/api/events.rs +++ b/server/src/api/events.rs @@ -14,33 +14,80 @@ pub async fn wait_for_event_handler( ) -> impl IntoResponse { let topic = params.get("topic").cloned(); let session_id = params.get("session_id").cloned(); + let timeout_duration = params + .get("timeout_ms") + .and_then(|ms| ms.parse::().ok()) + .map(tokio::time::Duration::from_millis) + .or_else(|| { + params + .get("timeout") + .and_then(|secs| secs.parse::().ok()) + .map(tokio::time::Duration::from_secs) + }); let mut rx = state.handler.state.event_bus_tx.subscribe(); - loop { - match rx.recv().await { - Ok(event) => { - let topic_matches = topic.as_ref().is_none_or(|t| t == &event.topic); - let session_matches = session_id - .as_ref() - .is_none_or(|s| Some(s) == event.session_id.as_ref()); + let wait_future = async { + loop { + match rx.recv().await { + Ok(event) => { + let topic_matches = topic.as_ref().is_none_or(|t| t == &event.topic); + let session_matches = session_id + .as_ref() + .is_none_or(|s| Some(s) == event.session_id.as_ref()); - if topic_matches && session_matches { - return axum::Json(event); + if topic_matches && session_matches { + return Ok(event); + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + tracing::warn!("Event bus receiver lagged by {} messages; continuing wait.", skipped); + continue; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + return Err("Event bus closed"); } } - Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { - tracing::warn!("Event bus receiver lagged by {} messages; continuing wait.", skipped); - continue; - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - return axum::Json(GenericEvent { + } + }; + + match timeout_duration { + Some(dur) => match tokio::time::timeout(dur, wait_future).await { + Ok(Ok(event)) => (axum::http::StatusCode::OK, axum::Json(event)).into_response(), + Ok(Err(err)) => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(GenericEvent { topic: "error".to_string(), session_id: None, - payload: serde_json::json!({"error": "Event bus closed"}), - }); - } - } + payload: serde_json::json!({ "error": err }), + }), + ) + .into_response(), + Err(_) => ( + axum::http::StatusCode::REQUEST_TIMEOUT, + axum::Json(GenericEvent { + topic: "timeout".to_string(), + session_id: None, + payload: serde_json::json!({ + "error": "timed out waiting for event", + "topic": topic + }), + }), + ) + .into_response(), + }, + None => match wait_future.await { + Ok(event) => (axum::http::StatusCode::OK, axum::Json(event)).into_response(), + Err(err) => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(GenericEvent { + topic: "error".to_string(), + session_id: None, + payload: serde_json::json!({ "error": err }), + }), + ) + .into_response(), + }, } } diff --git a/server/src/api/rest.rs b/server/src/api/rest.rs index 0ea52d2..9e74393 100644 --- a/server/src/api/rest.rs +++ b/server/src/api/rest.rs @@ -104,9 +104,30 @@ pub async fn gate_set_handler( }; app_state.handler.state.env.gates.modify(|gates| { gates.retain(|g| !(g.action == record.action && g.target == record.target)); - gates.push(record); + gates.push(record.clone()); }); + let summary = format!("Push gate {} set to {}", record.target, record.status); + app_state + .handler + .state + .record_activity("gate", &summary, record.reason.as_deref()); + let _ = app_state + .handler + .state + .event_bus_tx + .send(crate::state::GenericEvent { + topic: "gate:event".to_string(), + session_id: None, + payload: serde_json::json!({ + "action": record.action, + "target": record.target, + "status": record.status, + "reason": record.reason, + "active_gate": record.status, + }), + }); + Ok((axum::http::StatusCode::OK, "Gate state updated.")) } diff --git a/server/src/api/setup.rs b/server/src/api/setup.rs index fdea875..13417e7 100644 --- a/server/src/api/setup.rs +++ b/server/src/api/setup.rs @@ -6,7 +6,7 @@ use axum::{ Router, extract::State, response::IntoResponse, - routing::{get, post}, + routing::{delete, get, post}, }; use std::sync::Arc; use tokio_stream::StreamExt; @@ -123,20 +123,8 @@ pub fn create_router(app_state: Arc) -> Router { } tracing::info!( - "Received shutdown request via /shutdown endpoint. Creating session checkpoint and initiating graceful shutdown." + "Received shutdown request via /shutdown endpoint. Initiating graceful shutdown." ); - let mem_state = state.handler.state.clone(); - let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(); - let memo = crate::models::HandoffMemo { - id: format!("chk_shutdown_{}", now), - author: "ShutdownHook".to_string(), - content: format!("Automatic session checkpoint triggered on graceful server shutdown at epoch {}", now), - expires_at: None, - namespace: "global".to_string(), - timestamp: now, - ..Default::default() - }; - mem_state.telemetry.handoff_memos.modify(|m| m.push(memo)); if let Some(tx) = state.shutdown_tx.lock().unwrap().take() { let _ = tx.send(()); @@ -154,8 +142,7 @@ pub fn create_router(app_state: Arc) -> Router { "app_version": env!("APP_VERSION"), "cargo_pkg_version": env!("CARGO_PKG_VERSION"), "target_os": std::env::consts::OS, - "target_arch": std::env::consts::ARCH, - "shutdown_checkpoint": format!("chk_shutdown_{}", now) + "target_arch": std::env::consts::ARCH } })).into_response() }, @@ -309,6 +296,30 @@ pub fn create_router(app_state: Arc) -> Router { } }), ) + .route( + "/api/tech_debts/{id}/resolve", + post({ + let state_clone = app_state.handler.state.clone(); + move |axum::extract::Path(id): axum::extract::Path| async move { + let mut found = false; + state_clone.code.tech_debts.modify(|debts| { + for d in debts.iter_mut() { + if d.id == id { + d.is_resolved = true; + found = true; + break; + } + } + }); + if found { + state_clone.record_activity("tech_debt", &format!("Resolved tech debt: {}", id), Some("resolved")); + (axum::http::StatusCode::OK, axum::Json(serde_json::json!({ "status": "success", "message": format!("Tech debt {} resolved", id) }))) + } else { + (axum::http::StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "status": "error", "message": "Tech debt not found" }))) + } + } + }), + ) .route( "/api/adrs", get({ @@ -398,6 +409,139 @@ pub fn create_router(app_state: Arc) -> Router { } }), ) + .route( + "/api/env_fingerprints", + get({ + let state_clone = app_state.handler.state.clone(); + move || async move { + let json = state_clone.env.env_fingerprints.read_with(|items| { + let values: Vec<_> = items.values().cloned().collect(); + serde_json::to_string(&values).unwrap_or_else(|_| "[]".to_string()) + }); + ([(axum::http::header::CONTENT_TYPE, "application/json")], json) + } + }), + ) + .route( + "/api/env_requirements", + get({ + let state_clone = app_state.handler.state.clone(); + move || async move { + let json = state_clone.env.env_requirements.read_with(|items| { + serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string()) + }); + ([(axum::http::header::CONTENT_TYPE, "application/json")], json) + } + }), + ) + .route( + "/api/environments", + get({ + let state_clone = app_state.handler.state.clone(); + move || async move { + let json = state_clone.env.environments.read_with(|items| { + serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string()) + }); + ([(axum::http::header::CONTENT_TYPE, "application/json")], json) + } + }), + ) + .route( + "/api/gates", + get({ + let state_clone = app_state.handler.state.clone(); + move || async move { + let json = state_clone.env.gates.read_with(|items| { + serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string()) + }); + ([(axum::http::header::CONTENT_TYPE, "application/json")], json) + } + }), + ) + .route( + "/api/gates/{id}/authorize", + post({ + let state_clone = app_state.handler.state.clone(); + move |axum::extract::Path(id): axum::extract::Path| async move { + let mut found = false; + state_clone.env.gates.modify(|gates| { + for g in gates.iter_mut() { + if g.id == id { + g.status = "authorized".to_string(); + g.reason = Some("Manually authorized via dashboard".to_string()); + g.timestamp = crate::handlers::utils::now_secs(); + found = true; + break; + } + } + }); + if found { + state_clone.record_activity("gate", &format!("Authorized push gate: {}", id), Some("authorized")); + let _ = state_clone.event_bus_tx.send(crate::state::GenericEvent { + topic: "gate:event".to_string(), + session_id: None, + payload: serde_json::json!({ "id": id, "status": "authorized", "active_gate": "authorized" }), + }); + (axum::http::StatusCode::OK, axum::Json(serde_json::json!({ "status": "success" }))) + } else { + (axum::http::StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "status": "error", "message": "Gate not found" }))) + } + } + }), + ) + .route( + "/api/gates/{id}/block", + post({ + let state_clone = app_state.handler.state.clone(); + move |axum::extract::Path(id): axum::extract::Path| async move { + let mut found = false; + state_clone.env.gates.modify(|gates| { + for g in gates.iter_mut() { + if g.id == id { + g.status = "blocked".to_string(); + g.reason = Some("Manually blocked via dashboard".to_string()); + g.timestamp = crate::handlers::utils::now_secs(); + found = true; + break; + } + } + }); + if found { + state_clone.record_activity("gate", &format!("Blocked push gate: {}", id), Some("blocked")); + let _ = state_clone.event_bus_tx.send(crate::state::GenericEvent { + topic: "gate:event".to_string(), + session_id: None, + payload: serde_json::json!({ "id": id, "status": "blocked", "active_gate": "blocked" }), + }); + (axum::http::StatusCode::OK, axum::Json(serde_json::json!({ "status": "success" }))) + } else { + (axum::http::StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "status": "error", "message": "Gate not found" }))) + } + } + }), + ) + .route( + "/api/gates/{id}", + delete({ + let state_clone = app_state.handler.state.clone(); + move |axum::extract::Path(id): axum::extract::Path| async move { + let mut found = false; + state_clone.env.gates.modify(|gates| { + let initial_len = gates.len(); + gates.retain(|g| g.id != id); + found = gates.len() < initial_len; + }); + if found { + state_clone.record_activity("gate", &format!("Deleted push gate: {}", id), Some("deleted")); + let active_gate = state_clone.env.gates.read_with(|gates| gates.iter().find(|g| g.action == "push" || g.gate_type.as_deref() == Some("quality_gate")).map(|g| g.status.clone())); + let _ = state_clone.event_bus_tx.send(crate::state::GenericEvent { topic: "gate:event".to_string(), session_id: None, payload: serde_json::json!({ "id": id, "status": "deleted", "active_gate": active_gate }) }); + (axum::http::StatusCode::OK, axum::Json(serde_json::json!({ "status": "success" }))) + } else { + (axum::http::StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "status": "error", "message": "Gate not found" }))) + } + } + }), + ) .route( "/api/stats", get({ @@ -967,7 +1111,8 @@ mod tests { let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); - let summaries: Vec = serde_json::from_slice(&body_bytes).unwrap(); + let summaries: Vec = + serde_json::from_slice(&body_bytes).unwrap(); assert_eq!(summaries.len(), 1); assert_eq!(summaries[0].summary, "Session 2 summary"); } @@ -1002,9 +1147,103 @@ mod tests { let response = app.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); - let adr = app_state.handler.state.code.adrs.read_with(|adrs| adrs[0].clone()); + let adr = app_state + .handler + .state + .code + .adrs + .read_with(|adrs| adrs[0].clone()); assert_eq!(adr.status, "implemented"); assert_eq!(adr.git_commit.as_deref(), Some("git-hash-999")); assert!(adr.resolved_at.is_some()); } + + #[tokio::test] + async fn test_environment_endpoints() { + let (app, app_state, _dir) = setup_app().await; + + app_state.handler.state.env.environments.modify(|envs| { + envs.push(crate::models::EnvironmentDetail { + namespace: "default".to_string(), + name: "staging-k8s".to_string(), + url: "https://staging.internal.net".to_string(), + description: "Staging cluster".to_string(), + requires_vpn: true, + updated_at: 12345, + ..Default::default() + }); + }); + + let req = Request::builder() + .uri("/api/environments") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let envs: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(envs.len(), 1); + assert_eq!(envs[0].name, "staging-k8s"); + } + + #[tokio::test] + async fn test_interactive_endpoints() { + let (app, app_state, _dir) = setup_app().await; + + // 1. Tech Debt resolve + app_state.handler.state.code.tech_debts.modify(|debts| { + debts.push(crate::models::TechDebt { + id: "TD-001".to_string(), + description: "Fix memory leak".to_string(), + is_resolved: false, + ..Default::default() + }); + }); + + let req = Request::builder() + .method("POST") + .uri("/api/tech_debts/TD-001/resolve") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let debt = app_state + .handler + .state + .code + .tech_debts + .read_with(|d| d[0].clone()); + assert!(debt.is_resolved); + + // 2. Gate authorize + app_state.handler.state.env.gates.modify(|gates| { + gates.push(crate::models::GateRecord { + id: "gate-123".to_string(), + action: "push".to_string(), + target: "master".to_string(), + status: "blocked".to_string(), + ..Default::default() + }); + }); + + let app2 = create_router(app_state.clone()); + let req2 = Request::builder() + .method("POST") + .uri("/api/gates/gate-123/authorize") + .body(Body::empty()) + .unwrap(); + let resp2 = app2.oneshot(req2).await.unwrap(); + assert_eq!(resp2.status(), StatusCode::OK); + + let gate = app_state + .handler + .state + .env + .gates + .read_with(|g| g[0].clone()); + assert_eq!(gate.status, "authorized"); + } } diff --git a/server/src/api/telemetry.rs b/server/src/api/telemetry.rs index 2ee9309..d9eae17 100644 --- a/server/src/api/telemetry.rs +++ b/server/src/api/telemetry.rs @@ -187,6 +187,36 @@ pub async fn nvim_telemetry_handler( let _ = state.handler.state.event_bus_tx.send(event); } + if payload.event.starts_with("agent_") || payload.event.starts_with("diff_") { + let payload_val = serde_json::json!(&payload); + let _ = state.handler.state.event_bus_tx.send(crate::state::GenericEvent { + topic: format!("nvim:ui:{}", payload.event), + session_id: Some(payload.session_id.clone()), + payload: payload_val.clone(), + }); + + if let Some(ref req_id) = payload.request_id { + let _ = state.handler.state.event_bus_tx.send(crate::state::GenericEvent { + topic: format!("nvim:ui:{}:{}", payload.event, req_id), + session_id: Some(payload.session_id.clone()), + payload: payload_val.clone(), + }); + } + + if let Some(ref diff_id) = payload.diff_id { + let _ = state.handler.state.event_bus_tx.send(crate::state::GenericEvent { + topic: format!("nvim:ui:{}:{}", payload.event, diff_id), + session_id: Some(payload.session_id.clone()), + payload: payload_val.clone(), + }); + let _ = state.handler.state.event_bus_tx.send(crate::state::GenericEvent { + topic: format!("nvim:ui:diff_decision:{}", diff_id), + session_id: Some(payload.session_id.clone()), + payload: payload_val.clone(), + }); + } + } + if let Some(ref f) = payload.file { state.handler.state.record_activity( "editor", @@ -195,10 +225,28 @@ pub async fn nvim_telemetry_handler( ); } + // 4. Extract active task & push gate for zero-polling statusline + let active_task = state.handler.state.project.tasks.read_with(|tasks| { + tasks + .iter() + .find(|t| t.is_active()) + .or_else(|| tasks.iter().find(|t| t.status == "pending" && t.parent_id.is_none())) + .map(|t| t.title.clone()) + }); + + let active_gate = state.handler.state.env.gates.read_with(|gates| { + gates + .iter() + .find(|g| g.action == "push" || g.gate_type.as_deref() == Some("quality_gate")) + .map(|g| g.status.clone()) + }); + axum::Json(serde_json::json!({ "status": "ok", "tech_debts": tech_debts, - "adrs": adrs + "adrs": adrs, + "active_task": active_task, + "active_gate": active_gate })) } diff --git a/server/src/api/ws.rs b/server/src/api/ws.rs index 2900eb9..ebcbfed 100644 --- a/server/src/api/ws.rs +++ b/server/src/api/ws.rs @@ -29,6 +29,7 @@ pub struct SessionCleanup { pub state: Arc, pub send_task: tokio::task::JoinHandle<()>, pub recv_task: tokio::task::JoinHandle<()>, + pub event_task: tokio::task::JoinHandle<()>, } impl Drop for SessionCleanup { @@ -41,6 +42,7 @@ impl Drop for SessionCleanup { .remove(&self.session_id); self.send_task.abort(); self.recv_task.abort(); + self.event_task.abort(); } } @@ -147,11 +149,42 @@ pub async fn handle_socket(socket: WebSocket, state: Arc, _client_type } }); + let event_tx = tx.clone(); + let mut event_bus_rx = state.handler.state.event_bus_tx.subscribe(); + let event_task = tokio::spawn(async move { + while let Ok(event) = event_bus_rx.recv().await { + if event.topic == "resource:updated" { + if let Some(uri) = event.payload.get("uri").and_then(|u| u.as_str()) { + let notif = serde_json::json!({ + "jsonrpc": "2.0", + "method": "notifications/resources/updated", + "params": { + "uri": uri + } + }); + if event_tx.send(notif.to_string()).await.is_err() { + break; + } + } + } else if event.topic == "task:event" { + let notif = serde_json::json!({ + "jsonrpc": "2.0", + "method": "notifications/task/updated", + "params": event.payload + }); + if event_tx.send(notif.to_string()).await.is_err() { + break; + } + } + } + }); + let mut cleanup = SessionCleanup { session_id: session_id.clone(), state: Arc::clone(&state), send_task, recv_task, + event_task, }; tokio::select! { @@ -194,6 +227,7 @@ mod tests { let send_task = tokio::spawn(async {}); let recv_task = tokio::spawn(async {}); + let event_task = tokio::spawn(async {}); { let _cleanup = SessionCleanup { @@ -201,6 +235,7 @@ mod tests { state: app_state.clone(), send_task, recv_task, + event_task, }; } // Drop happens here diff --git a/server/src/dashboard.html b/server/src/dashboard.html index 66f0768..5ae5771 100644 --- a/server/src/dashboard.html +++ b/server/src/dashboard.html @@ -642,6 +642,141 @@ flex-direction: column; gap: 12px; } + + /* Command Palette Modal */ + #cmd-palette-backdrop { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.65); + backdrop-filter: blur(4px); + z-index: 10000; + justify-content: center; + align-items: flex-start; + padding-top: 12vh; + box-sizing: border-box; + } + #cmd-palette-modal { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + width: 90%; + max-width: 680px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.45); + display: flex; + flex-direction: column; + overflow: hidden; + animation: paletteFadeIn 0.15s ease-out; + } + @keyframes paletteFadeIn { + from { opacity: 0; transform: translateY(-10px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } + } + .palette-header { + display: flex; + align-items: center; + padding: 14px 18px; + border-bottom: 1px solid var(--border-color); + background: var(--canvas-bg); + gap: 12px; + } + .palette-search-icon { + font-size: 1.1em; + color: var(--text-secondary); + } + .palette-input { + flex: 1; + background: transparent; + border: none; + outline: none; + font-size: 1.05em; + color: var(--text-primary); + font-family: inherit; + } + .palette-kbd-badge { + font-size: 0.72em; + padding: 2px 6px; + border-radius: 4px; + background: var(--card-bg); + border: 1px solid var(--border-color); + color: var(--text-secondary); + font-family: monospace; + } + .palette-results { + max-height: 380px; + overflow-y: auto; + padding: 8px 0; + margin: 0; + list-style: none; + } + .palette-item { + padding: 10px 18px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + cursor: pointer; + transition: background 0.1s; + } + .palette-item:hover, .palette-item.selected { + background: rgba(52, 152, 219, 0.12); + } + .palette-item-left { + display: flex; + align-items: center; + gap: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .palette-item-icon { + font-size: 1em; + flex-shrink: 0; + } + .palette-item-text { + display: flex; + flex-direction: column; + gap: 2px; + overflow: hidden; + } + .palette-item-title { + font-weight: 600; + font-size: 0.92em; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .palette-item-sub { + font-size: 0.78em; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .palette-item-badge { + font-size: 0.72em; + padding: 2px 6px; + border-radius: 4px; + border: 1px solid var(--border-color); + background: var(--canvas-bg); + color: var(--text-secondary); + flex-shrink: 0; + text-transform: uppercase; + } + .palette-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 18px; + background: var(--canvas-bg); + border-top: 1px solid var(--border-color); + font-size: 0.75em; + color: var(--text-secondary); + } @@ -651,12 +786,35 @@
+
System Online
+ +
+
+
+ 🔍 + + ESC +
+
+ +
+ +
+
+ +
+
+

Target Environments & Infrastructure

+
+

Tool & System Fingerprints

+
+

Environment Requirements & Secrets

+
+

Quality & Authorization Gates

+
+
+
+
diff --git a/server/src/dashboard.js b/server/src/dashboard.js index 33dc734..db785dd 100644 --- a/server/src/dashboard.js +++ b/server/src/dashboard.js @@ -42,6 +42,9 @@ function switchTab(tabId, btn) { case "ledger-tab": loadLedger(); break; + case "env-tab": + loadEnvironment(); + break; } } function toggleTheme() { @@ -291,10 +294,20 @@ async function loadGraph() { } async function completeTask(id) { try { - await fetch(`/api/tasks/${id}/complete`, { method: "POST" }); + if (activeWS && activeWS.readyState === WebSocket.OPEN) { + await sendWsRpc("tasks/complete", { id }); + } else { + await fetch(`/api/tasks/${encodeURIComponent(id)}/complete`, { method: "POST" }); + } loadTasks(); } catch (e) { - console.error("Failed to complete task", e); + console.error("Failed to complete task via WS, falling back to HTTP", e); + try { + await fetch(`/api/tasks/${encodeURIComponent(id)}/complete`, { method: "POST" }); + loadTasks(); + } catch (err) { + console.error("Failed to complete task via HTTP fallback", err); + } } } function buildTaskTreeHTML(tasks, parentId, depth = 0) { @@ -683,6 +696,34 @@ async function loadActivityHistory() { } var wsConnected = false; var activeSSE = null; +var activeWS = null; +var wsRpcId = 1; +var pendingWsRequests = new Map; +function sendWsRpc(method, params = {}, timeoutMs = 5000) { + return new Promise((resolve, reject) => { + if (!activeWS || activeWS.readyState !== WebSocket.OPEN) { + return reject(new Error("WebSocket not open")); + } + const id = ++wsRpcId; + const timer = setTimeout(() => { + pendingWsRequests.delete(id); + reject(new Error(`WebSocket RPC timeout for ${method}`)); + }, timeoutMs); + pendingWsRequests.set(id, { resolve, reject, timer }); + try { + activeWS.send(JSON.stringify({ + jsonrpc: "2.0", + id, + method, + params + })); + } catch (e) { + clearTimeout(timer); + pendingWsRequests.delete(id); + reject(e); + } + }); +} var tabRefreshDebounceTimers = {}; function requestDomainRefresh(domain) { const tabMap = { @@ -693,7 +734,9 @@ function requestDomainRefresh(domain) { snippets: "snippets-tab", terminal: "terminal-tab", ledger: "ledger-tab", - memos: "memos-tab" + memos: "memos-tab", + env: "env-tab", + gates: "env-tab" }; const targetTab = tabMap[domain]; if (!targetTab || targetTab !== currentTabId) { @@ -729,6 +772,10 @@ function requestDomainRefresh(domain) { case "memos": loadMemos(); break; + case "env": + case "gates": + loadEnvironment(); + break; } }, 250); } @@ -750,6 +797,9 @@ function handleIncomingActivity(payload) { requestDomainRefresh("graph"); } else if (method === "notifications/task/completed" || category.startsWith("TASK")) { requestDomainRefresh("task"); + } else if (category === "GATE" || category === "QUALITY_GATE" || category === "ENVIRONMENT" || category === "ENV") { + requestDomainRefresh("gates"); + requestDomainRefresh("env"); } else if (category === "TECH_DEBT" || category === "ERROR_FIX" || category === "CHECKPOINT") { requestDomainRefresh("techdebt"); } else if (category === "SNIPPET") { @@ -790,6 +840,7 @@ function setupWS() { } ws.onopen = function() { wsConnected = true; + activeWS = ws; if (activeSSE) { activeSSE.close(); activeSSE = null; @@ -798,7 +849,18 @@ function setupWS() { ws.onmessage = function(event) { try { const data = JSON.parse(event.data); - if (data.type === "activity" || data.method === "notifications/activity" || data.method === "notifications/task/completed" || data.method === "notifications/resources/updated") { + if (data.id !== undefined && pendingWsRequests.has(data.id)) { + const handler = pendingWsRequests.get(data.id); + pendingWsRequests.delete(data.id); + clearTimeout(handler.timer); + if (data.error) { + handler.reject(new Error(data.error.message || JSON.stringify(data.error))); + } else { + handler.resolve(data.result); + } + return; + } + if (data.type === "activity" || data.type === "gate" || data.type === "task" || data.type === "tech_debt" || data.type === "nvim_telemetry" || data.method === "notifications/activity" || data.method === "notifications/task/completed" || data.method === "notifications/resources/updated") { const payload = data.params || data.data || data; handleIncomingActivity(payload); } @@ -808,12 +870,24 @@ function setupWS() { }; ws.onclose = function() { wsConnected = false; + activeWS = null; + pendingWsRequests.forEach((handler) => { + clearTimeout(handler.timer); + handler.reject(new Error("WebSocket disconnected")); + }); + pendingWsRequests.clear(); console.log("WebSocket closed, falling back to SSE and retrying WS in 5s..."); setupSSE(); setTimeout(setupWS, 5000); }; ws.onerror = function() { wsConnected = false; + activeWS = null; + pendingWsRequests.forEach((handler) => { + clearTimeout(handler.timer); + handler.reject(new Error("WebSocket error")); + }); + pendingWsRequests.clear(); setupSSE(); }; } @@ -1328,7 +1402,10 @@ function loadTechDebt() { ${escapeHtml(item.severity || "medium")} ${item.effort_estimate ? `⏳ ${escapeHtml(item.effort_estimate)}` : ""}
-
${statusBadge}
+
+
${statusBadge}
+ ${!item.is_resolved ? `` : ""} +
${fileLoc ? `
\uD83D\uDCC4 ${escapeHtml(fileLoc)}
` : ""}
Description: ${escapeHtml(item.description || "")}
@@ -1704,6 +1781,121 @@ function loadSnippets() { ] }); } +function loadEnvironment() { + loadGenericList("/api/environments", "environments-container", (item) => { + const isVpn = item.requires_vpn; + const envType = (item.env_type || "dev").toLowerCase(); + let typeColor = "#3498db"; + if (envType === "prod") + typeColor = "#e74c3c"; + else if (envType === "staging") + typeColor = "#f39c12"; + else if (envType === "qa") + typeColor = "#9b59b6"; + return ` +
+
+ ${escapeHtml(item.name || "Environment")} + ${escapeHtml(envType)} + ${isVpn ? '\uD83D\uDD12 VPN Required' : ""} +
+ ${escapeHtml(item.namespace || "default")} +
+ ${item.url ? `` : ""} + ${item.description ? `
${escapeHtml(item.description)}
` : ""} +
+ ${item.ssh_host ? `SSH: ${escapeHtml(item.ssh_host)}` : ""} + ${item.healthcheck_endpoint ? `Health: ${escapeHtml(item.healthcheck_endpoint)}` : ""} + ${item.repo_name ? `\uD83D\uDCE6 ${escapeHtml(item.repo_name)}` : ""} +
+ `; + }, { + itemNoun: "Environments", + cardBorderColor: "#3498db", + defaultSort: "desc", + timestampExtractor: (item) => item.updated_at, + searchFields: (item) => [item.name, item.url, item.description, item.env_type, item.ssh_host, item.namespace] + }); + loadGenericList("/api/env_fingerprints", "fingerprints-container", (item) => { + const toolVersions = item.tool_versions || {}; + const tools = Object.entries(toolVersions).map(([k, v]) => `${escapeHtml(k)}: ${escapeHtml(String(v))}`).join(" "); + return ` +
+
+ \uD83D\uDCBB ${escapeHtml(item.os || "Unknown OS")} (${escapeHtml(item.shell || "shell")}) +
+ ${escapeHtml(item.namespace || "default")} +
+ ${tools ? `
${tools}
` : '
No tool versions registered
'} +
+ ${item.repo_name ? `\uD83D\uDCE6 ${escapeHtml(item.repo_name)}` : ""} +
+ `; + }, { + itemNoun: "Fingerprints", + cardBorderColor: "#1abc9c", + defaultSort: "desc", + timestampExtractor: (item) => item.updated_at, + searchFields: (item) => [item.namespace, item.os, item.shell, item.repo_name, JSON.stringify(item.tool_versions || {})] + }); + loadGenericList("/api/env_requirements", "requirements-container", (item) => { + const isSecret = item.is_secret; + return ` +
+
+ ${escapeHtml(item.key)} + ${isSecret ? '\uD83D\uDD12 Secret' : 'Public'} +
+ ${escapeHtml(item.namespace || "default")} +
+ ${item.description ? `
${escapeHtml(item.description)}
` : ""} +
+ ${item.default_value ? `Default: ${escapeHtml(item.default_value)}` : ""} + ${item.validation_regex ? `Regex: ${escapeHtml(item.validation_regex)}` : ""} + ${item.repo_name ? `\uD83D\uDCE6 ${escapeHtml(item.repo_name)}` : ""} +
+ `; + }, { + itemNoun: "Requirements", + cardBorderColor: "#e67e22", + defaultSort: "desc", + searchFields: (item) => [item.key, item.description, item.default_value, item.namespace, item.repo_name] + }); + loadGenericList("/api/gates", "gates-container", (item) => { + const status = (item.status || "pending").toLowerCase(); + let statusColor = "var(--accent-color)"; + if (status === "authorized") + statusColor = "var(--success-color)"; + else if (status === "blocked") + statusColor = "var(--error-color)"; + return ` +
+
+ ${escapeHtml(item.action)} → ${escapeHtml(item.target)} + ${escapeHtml(status)} +
+
+ ${escapeHtml(item.namespace || "global")} + ${status !== "authorized" ? `` : ""} + ${status !== "blocked" ? `` : ""} + +
+
+ ${item.reason ? `
${escapeHtml(item.reason)}
` : ""} +
+ ${item.gate_type ? `Type: ${escapeHtml(item.gate_type)}` : ""} + ${item.enforcer ? `Enforcer: ${escapeHtml(item.enforcer)}` : ""} + ${item.repo_name ? `\uD83D\uDCE6 ${escapeHtml(item.repo_name)}` : ""} +
+ `; + }, { + itemNoun: "Gates", + cardBorderColor: "#e74c3c", + defaultSort: "desc", + timestampExtractor: (item) => item.timestamp, + searchFields: (item) => [item.action, item.target, item.status, item.reason, item.enforcer] + }); +} function loadAllExtras() { loadTerminal(); loadTechDebt(); @@ -1772,21 +1964,308 @@ function setupSSE() { console.error("SSE initialization error", e); } } +async function resolveTechDebt(id) { + try { + if (activeWS && activeWS.readyState === WebSocket.OPEN) { + await sendWsRpc("tech_debts/resolve", { id }); + } else { + const res = await fetch(`/api/tech_debts/${encodeURIComponent(id)}/resolve`, { method: "POST" }); + if (!res.ok) + console.error("Failed to resolve tech debt", await res.text()); + } + loadTechDebt(); + } catch (err) { + console.error("Error resolving tech debt via WS, trying HTTP fallback", err); + try { + const res = await fetch(`/api/tech_debts/${encodeURIComponent(id)}/resolve`, { method: "POST" }); + if (!res.ok) + console.error("Failed to resolve tech debt", await res.text()); + loadTechDebt(); + } catch (fallbackErr) { + console.error("Error resolving tech debt", fallbackErr); + } + } +} +async function authorizeGate(id) { + try { + if (activeWS && activeWS.readyState === WebSocket.OPEN) { + await sendWsRpc("gates/authorize", { id }); + } else { + const res = await fetch(`/api/gates/${encodeURIComponent(id)}/authorize`, { method: "POST" }); + if (!res.ok) + console.error("Failed to authorize gate", await res.text()); + } + loadEnvironment(); + } catch (err) { + console.error("Error authorizing gate via WS, trying HTTP fallback", err); + try { + const res = await fetch(`/api/gates/${encodeURIComponent(id)}/authorize`, { method: "POST" }); + if (!res.ok) + console.error("Failed to authorize gate", await res.text()); + loadEnvironment(); + } catch (fallbackErr) { + console.error("Error authorizing gate", fallbackErr); + } + } +} +async function blockGate(id) { + try { + if (activeWS && activeWS.readyState === WebSocket.OPEN) { + await sendWsRpc("gates/block", { id }); + } else { + const res = await fetch(`/api/gates/${encodeURIComponent(id)}/block`, { method: "POST" }); + if (!res.ok) + console.error("Failed to block gate", await res.text()); + } + loadEnvironment(); + } catch (err) { + console.error("Error blocking gate via WS, trying HTTP fallback", err); + try { + const res = await fetch(`/api/gates/${encodeURIComponent(id)}/block`, { method: "POST" }); + if (!res.ok) + console.error("Failed to block gate", await res.text()); + loadEnvironment(); + } catch (fallbackErr) { + console.error("Error blocking gate", fallbackErr); + } + } +} +async function deleteGate(id) { + try { + if (activeWS && activeWS.readyState === WebSocket.OPEN) { + await sendWsRpc("gates/delete", { id }); + } else { + const res = await fetch(`/api/gates/${encodeURIComponent(id)}`, { method: "DELETE" }); + if (!res.ok) + console.error("Failed to delete gate", await res.text()); + } + loadEnvironment(); + } catch (err) { + console.error("Error deleting gate via WS, trying HTTP fallback", err); + try { + const res = await fetch(`/api/gates/${encodeURIComponent(id)}`, { method: "DELETE" }); + if (!res.ok) + console.error("Failed to delete gate", await res.text()); + loadEnvironment(); + } catch (fallbackErr) { + console.error("Error deleting gate", fallbackErr); + } + } +} +var paletteItems = []; +var filteredPaletteItems = []; +var paletteSelectedIndex = 0; +var paletteDataLoaded = false; +async function preloadPaletteData() { + try { + const [tasksRes, debtsRes, adrsRes, envsRes, gatesRes] = await Promise.all([ + fetch("/api/tasks", { cache: "no-store" }).then((r) => r.json()).catch(() => []), + fetch("/api/tech_debts", { cache: "no-store" }).then((r) => r.json()).catch(() => []), + fetch("/api/adrs", { cache: "no-store" }).then((r) => r.json()).catch(() => []), + fetch("/api/environments", { cache: "no-store" }).then((r) => r.json()).catch(() => []), + fetch("/api/gates", { cache: "no-store" }).then((r) => r.json()).catch(() => []) + ]); + const items = [ + { id: "nav-activity", category: "nav", icon: "⚡", title: "Live Activity Feed", sub: "Inspect real-time agent signals & SSE", badge: "VIEW", tabId: "activity-tab" }, + { id: "nav-ledger", category: "nav", icon: "\uD83D\uDCD6", title: "Code Ledger", sub: "View logged code changes & commits", badge: "VIEW", tabId: "ledger-tab" }, + { id: "nav-graph", category: "nav", icon: "\uD83D\uDD78️", title: "Knowledge Graph Explorer", sub: "Explore nodes, relationships & dependencies", badge: "VIEW", tabId: "graph-tab" }, + { id: "nav-search", category: "nav", icon: "\uD83D\uDD0D", title: "Omni-Search", sub: "Full-text cross-entity search", badge: "VIEW", tabId: "search-tab" }, + { id: "nav-tasks", category: "nav", icon: "\uD83D\uDCCB", title: "Task Board", sub: "HTN / DAG task hierarchies", badge: "VIEW", tabId: "task-tab" }, + { id: "nav-techdebt", category: "nav", icon: "⚡", title: "Tech Debt & Error Fixes", sub: "Known tech debt and error fixes", badge: "VIEW", tabId: "techdebt-tab" }, + { id: "nav-adrs", category: "nav", icon: "\uD83D\uDCDC", title: "Architectural Decision Records", sub: "ADRs and design choices", badge: "VIEW", tabId: "adrs-tab" }, + { id: "nav-memos", category: "nav", icon: "\uD83D\uDCDD", title: "Handoff Memos", sub: "Agent handoffs & session summaries", badge: "VIEW", tabId: "memos-tab" }, + { id: "nav-snippets", category: "nav", icon: "\uD83D\uDCCE", title: "Code Snippets", sub: "Indexed snippets & patterns", badge: "VIEW", tabId: "snippets-tab" }, + { id: "nav-terminal", category: "nav", icon: "\uD83D\uDCBB", title: "Terminal History", sub: "Command execution log & exit codes", badge: "VIEW", tabId: "terminal-tab" }, + { id: "nav-env", category: "nav", icon: "\uD83C\uDF10", title: "Environment & Quality Gates", sub: "Deploy targets, fingerprints, requirements & gates", badge: "VIEW", tabId: "env-tab" } + ]; + if (Array.isArray(tasksRes)) { + tasksRes.forEach((t) => { + items.push({ + id: t.id, + category: "task", + icon: t.status === "completed" ? "✓" : "\uD83D\uDCCB", + title: t.title || t.id, + sub: t.description || `Task (${t.status || "pending"})`, + badge: t.status || "TASK", + tabId: "task-tab" + }); + }); + } + if (Array.isArray(debtsRes)) { + debtsRes.forEach((d) => { + items.push({ + id: d.id, + category: "techdebt", + icon: "⚡", + title: `${d.id}: ${d.description || ""}`.trim(), + sub: d.file_path ? `${d.file_path} - Severity: ${d.severity || "medium"}` : `Severity: ${d.severity || "medium"}`, + badge: d.is_resolved ? "RESOLVED" : d.severity || "DEBT", + tabId: "techdebt-tab" + }); + }); + } + if (Array.isArray(adrsRes)) { + adrsRes.forEach((a) => { + items.push({ + id: a.id, + category: "adr", + icon: "\uD83D\uDCDC", + title: `${a.id}: ${a.title || ""}`.trim(), + sub: a.decision || `Status: ${a.status || "accepted"}`, + badge: a.status || "ADR", + tabId: "adrs-tab" + }); + }); + } + if (Array.isArray(envsRes)) { + envsRes.forEach((e) => { + items.push({ + id: e.name || e.url, + category: "env", + icon: "\uD83C\uDF10", + title: e.name || "Target Environment", + sub: `${e.url || ""} ${e.description ? "• " + e.description : ""}`.trim(), + badge: e.env_type || "ENV", + tabId: "env-tab" + }); + }); + } + if (Array.isArray(gatesRes)) { + gatesRes.forEach((g) => { + items.push({ + id: g.id, + category: "gate", + icon: g.status === "authorized" ? "\uD83D\uDFE2" : g.status === "blocked" ? "\uD83D\uDD34" : "\uD83D\uDFE1", + title: `${g.action} → ${g.target}`, + sub: g.reason || `Enforcer: ${g.enforcer || "gate"}`, + badge: g.status || "GATE", + tabId: "env-tab" + }); + }); + } + paletteItems = items; + paletteDataLoaded = true; + } catch (err) { + console.error("Failed to preload palette data", err); + } +} +function openCommandPalette() { + const backdrop = document.getElementById("cmd-palette-backdrop"); + const input = document.getElementById("palette-input"); + if (!backdrop || !input) + return; + backdrop.style.display = "flex"; + input.value = ""; + paletteSelectedIndex = 0; + preloadPaletteData().then(() => { + onPaletteSearch(""); + input.focus(); + }); +} +function closeCommandPalette() { + const backdrop = document.getElementById("cmd-palette-backdrop"); + if (backdrop) + backdrop.style.display = "none"; +} +function onPaletteBackdropClick(e) { + if (e.target === document.getElementById("cmd-palette-backdrop")) { + closeCommandPalette(); + } +} +function onPaletteSearch(query) { + const q = query.toLowerCase().trim(); + if (!q) { + filteredPaletteItems = paletteItems.slice(0, 30); + } else { + filteredPaletteItems = paletteItems.filter((item) => item.title.toLowerCase().includes(q) || item.sub.toLowerCase().includes(q) || item.badge.toLowerCase().includes(q) || item.category.toLowerCase().includes(q)).slice(0, 40); + } + paletteSelectedIndex = 0; + renderPaletteResults(); +} +function renderPaletteResults() { + const container = document.getElementById("palette-results"); + const countEl = document.getElementById("palette-count"); + if (!container) + return; + if (countEl) + countEl.innerText = `${filteredPaletteItems.length} results`; + if (filteredPaletteItems.length === 0) { + container.innerHTML = '
  • No matching items found.
  • '; + return; + } + container.innerHTML = filteredPaletteItems.map((item, idx) => { + const isSel = idx === paletteSelectedIndex; + return ` +
  • +
    + ${escapeHtml(item.icon)} +
    + ${escapeHtml(item.title)} + ${escapeHtml(item.sub)} +
    +
    + ${escapeHtml(item.badge)} +
  • + `; + }).join(""); + const selectedEl = container.querySelector(`.palette-item[data-index="${paletteSelectedIndex}"]`); + if (selectedEl) { + selectedEl.scrollIntoView({ block: "nearest" }); + } +} +function selectPaletteItem(idx) { + const item = filteredPaletteItems[idx]; + if (!item) + return; + closeCommandPalette(); + const tabBtn = document.querySelector(`button[onclick*="${item.tabId}"]`); + if (tabBtn) { + switchTab(item.tabId, tabBtn); + } +} +function onPaletteKeyDown(e) { + if (e.key === "ArrowDown") { + e.preventDefault(); + if (filteredPaletteItems.length > 0) { + paletteSelectedIndex = (paletteSelectedIndex + 1) % filteredPaletteItems.length; + renderPaletteResults(); + } + } else if (e.key === "ArrowUp") { + e.preventDefault(); + if (filteredPaletteItems.length > 0) { + paletteSelectedIndex = (paletteSelectedIndex - 1 + filteredPaletteItems.length) % filteredPaletteItems.length; + renderPaletteResults(); + } + } else if (e.key === "Enter") { + e.preventDefault(); + selectPaletteItem(paletteSelectedIndex); + } else if (e.key === "Escape") { + e.preventDefault(); + closeCommandPalette(); + } +} document.addEventListener("keydown", function(e) { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { e.preventDefault(); - const searchTabBtn = document.querySelector('button[onclick*="search-tab"]'); - if (searchTabBtn) { - switchTab("search-tab", searchTabBtn); - } - const searchInput = document.getElementById("search-box"); - if (searchInput) { - searchInput.focus(); - searchInput.select(); + const backdrop = document.getElementById("cmd-palette-backdrop"); + if (backdrop && backdrop.style.display === "flex") { + closeCommandPalette(); + } else { + openCommandPalette(); } return; } + if (e.key === "Escape") { + const backdrop = document.getElementById("cmd-palette-backdrop"); + if (backdrop && backdrop.style.display === "flex") { + closeCommandPalette(); + return; + } + } if (e.key === "/" && document.activeElement?.tagName !== "INPUT" && document.activeElement?.tagName !== "TEXTAREA") { + const backdrop = document.getElementById("cmd-palette-backdrop"); + if (backdrop && backdrop.style.display === "flex") + return; const activeSearch = document.querySelector(".tab-content.active .enterprise-search-input, .tab-content.active #task-filter-input"); if (activeSearch) { e.preventDefault(); @@ -1821,6 +2300,9 @@ function refreshActiveTab() { case "ledger-tab": loadLedger(); break; + case "env-tab": + loadEnvironment(); + break; case "activity-tab": loadActivityHistory(); break; @@ -1851,6 +2333,7 @@ Object.assign(window, { requestDomainRefresh, handleIncomingActivity, setupWS, + sendWsRpc, loadGenericList, onListSearchInput, onListClearSearch, @@ -1866,6 +2349,17 @@ Object.assign(window, { loadMemos, loadSnippets, loadAllExtras, + loadEnvironment, + resolveTechDebt, + authorizeGate, + blockGate, + deleteGate, + openCommandPalette, + closeCommandPalette, + onPaletteBackdropClick, + onPaletteSearch, + onPaletteKeyDown, + selectPaletteItem, testClipboard, loadVersion, setupSSE, diff --git a/server/src/dashboard.ts b/server/src/dashboard.ts index d3bde5e..9f45825 100644 --- a/server/src/dashboard.ts +++ b/server/src/dashboard.ts @@ -150,6 +150,9 @@ function switchTab(tabId: string, btn?: HTMLElement | null): void { case "ledger-tab": loadLedger(); break; + case "env-tab": + loadEnvironment(); + break; } } @@ -464,10 +467,20 @@ async function loadGraph(): Promise { // --- Task Tree (HTN/DAG) --- async function completeTask(id: string): Promise { try { - await fetch(`/api/tasks/${id}/complete`, { method: "POST" }); + if (activeWS && activeWS.readyState === WebSocket.OPEN) { + await sendWsRpc("tasks/complete", { id }); + } else { + await fetch(`/api/tasks/${encodeURIComponent(id)}/complete`, { method: "POST" }); + } loadTasks(); } catch (e) { - console.error("Failed to complete task", e); + console.error("Failed to complete task via WS, falling back to HTTP", e); + try { + await fetch(`/api/tasks/${encodeURIComponent(id)}/complete`, { method: "POST" }); + loadTasks(); + } catch (err) { + console.error("Failed to complete task via HTTP fallback", err); + } } } @@ -927,6 +940,36 @@ async function loadActivityHistory(): Promise { let wsConnected = false; let activeSSE: EventSource | null = null; +let activeWS: WebSocket | null = null; +let wsRpcId = 1; +const pendingWsRequests = new Map void; reject: (err: any) => void; timer: any }>(); + +function sendWsRpc(method: string, params: any = {}, timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + if (!activeWS || activeWS.readyState !== WebSocket.OPEN) { + return reject(new Error("WebSocket not open")); + } + const id = ++wsRpcId; + const timer = setTimeout(() => { + pendingWsRequests.delete(id); + reject(new Error(`WebSocket RPC timeout for ${method}`)); + }, timeoutMs); + + pendingWsRequests.set(id, { resolve, reject, timer }); + try { + activeWS.send(JSON.stringify({ + jsonrpc: "2.0", + id, + method, + params, + })); + } catch (e) { + clearTimeout(timer); + pendingWsRequests.delete(id); + reject(e); + } + }); +} const tabRefreshDebounceTimers: Record = {}; function requestDomainRefresh(domain: string): void { @@ -939,6 +982,8 @@ function requestDomainRefresh(domain: string): void { terminal: "terminal-tab", ledger: "ledger-tab", memos: "memos-tab", + env: "env-tab", + gates: "env-tab", }; const targetTab = tabMap[domain]; @@ -977,6 +1022,10 @@ function requestDomainRefresh(domain: string): void { case "memos": loadMemos(); break; + case "env": + case "gates": + loadEnvironment(); + break; } }, 250); } @@ -1007,6 +1056,14 @@ function handleIncomingActivity(payload: any): void { category.startsWith("TASK") ) { requestDomainRefresh("task"); + } else if ( + category === "GATE" || + category === "QUALITY_GATE" || + category === "ENVIRONMENT" || + category === "ENV" + ) { + requestDomainRefresh("gates"); + requestDomainRefresh("env"); } else if ( category === "TECH_DEBT" || category === "ERROR_FIX" || @@ -1056,6 +1113,7 @@ function setupWS(): void { ws.onopen = function () { wsConnected = true; + activeWS = ws; if (activeSSE) { activeSSE.close(); activeSSE = null; @@ -1065,8 +1123,23 @@ function setupWS(): void { ws.onmessage = function (event: MessageEvent) { try { const data = JSON.parse(event.data); + if (data.id !== undefined && pendingWsRequests.has(data.id)) { + const handler = pendingWsRequests.get(data.id)!; + pendingWsRequests.delete(data.id); + clearTimeout(handler.timer); + if (data.error) { + handler.reject(new Error(data.error.message || JSON.stringify(data.error))); + } else { + handler.resolve(data.result); + } + return; + } if ( data.type === "activity" || + data.type === "gate" || + data.type === "task" || + data.type === "tech_debt" || + data.type === "nvim_telemetry" || data.method === "notifications/activity" || data.method === "notifications/task/completed" || data.method === "notifications/resources/updated" @@ -1081,6 +1154,12 @@ function setupWS(): void { ws.onclose = function () { wsConnected = false; + activeWS = null; + pendingWsRequests.forEach((handler) => { + clearTimeout(handler.timer); + handler.reject(new Error("WebSocket disconnected")); + }); + pendingWsRequests.clear(); console.log( "WebSocket closed, falling back to SSE and retrying WS in 5s...", ); @@ -1090,6 +1169,12 @@ function setupWS(): void { ws.onerror = function () { wsConnected = false; + activeWS = null; + pendingWsRequests.forEach((handler) => { + clearTimeout(handler.timer); + handler.reject(new Error("WebSocket error")); + }); + pendingWsRequests.clear(); setupSSE(); }; } @@ -1777,7 +1862,10 @@ function loadTechDebt(): void { ${escapeHtml(item.severity || "medium")} ${item.effort_estimate ? `⏳ ${escapeHtml(item.effort_estimate)}` : ""} -
    ${statusBadge}
    +
    +
    ${statusBadge}
    + ${!item.is_resolved ? `` : ""} +
    ${fileLoc ? `
    📄 ${escapeHtml(fileLoc)}
    ` : ""}
    Description: ${escapeHtml(item.description || "")}
    @@ -2270,6 +2358,149 @@ function loadSnippets(): void { }, ); } +function loadEnvironment(): void { + // 1. Target Environments & Infrastructure + loadGenericList( + "/api/environments", + "environments-container", + (item) => { + const isVpn = item.requires_vpn; + const envType = (item.env_type || "dev").toLowerCase(); + let typeColor = "#3498db"; + if (envType === "prod") typeColor = "#e74c3c"; + else if (envType === "staging") typeColor = "#f39c12"; + else if (envType === "qa") typeColor = "#9b59b6"; + + return ` +
    +
    + ${escapeHtml(item.name || "Environment")} + ${escapeHtml(envType)} + ${isVpn ? '🔒 VPN Required' : ""} +
    + ${escapeHtml(item.namespace || "default")} +
    + ${item.url ? `` : ""} + ${item.description ? `
    ${escapeHtml(item.description)}
    ` : ""} +
    + ${item.ssh_host ? `SSH: ${escapeHtml(item.ssh_host)}` : ""} + ${item.healthcheck_endpoint ? `Health: ${escapeHtml(item.healthcheck_endpoint)}` : ""} + ${item.repo_name ? `📦 ${escapeHtml(item.repo_name)}` : ""} +
    + `; + }, + { + itemNoun: "Environments", + cardBorderColor: "#3498db", + defaultSort: "desc", + timestampExtractor: (item: any) => item.updated_at, + searchFields: (item: any) => [item.name, item.url, item.description, item.env_type, item.ssh_host, item.namespace], + } + ); + + // 2. Tool Fingerprints + loadGenericList( + "/api/env_fingerprints", + "fingerprints-container", + (item) => { + const toolVersions = item.tool_versions || {}; + const tools = Object.entries(toolVersions).map( + ([k, v]) => `${escapeHtml(k)}: ${escapeHtml(String(v))}` + ).join(" "); + + return ` +
    +
    + 💻 ${escapeHtml(item.os || "Unknown OS")} (${escapeHtml(item.shell || "shell")}) +
    + ${escapeHtml(item.namespace || "default")} +
    + ${tools ? `
    ${tools}
    ` : '
    No tool versions registered
    '} +
    + ${item.repo_name ? `📦 ${escapeHtml(item.repo_name)}` : ""} +
    + `; + }, + { + itemNoun: "Fingerprints", + cardBorderColor: "#1abc9c", + defaultSort: "desc", + timestampExtractor: (item: any) => item.updated_at, + searchFields: (item: any) => [item.namespace, item.os, item.shell, item.repo_name, JSON.stringify(item.tool_versions || {})], + } + ); + + // 3. Environment Requirements + loadGenericList( + "/api/env_requirements", + "requirements-container", + (item) => { + const isSecret = item.is_secret; + return ` +
    +
    + ${escapeHtml(item.key)} + ${isSecret ? '🔒 Secret' : 'Public'} +
    + ${escapeHtml(item.namespace || "default")} +
    + ${item.description ? `
    ${escapeHtml(item.description)}
    ` : ""} +
    + ${item.default_value ? `Default: ${escapeHtml(item.default_value)}` : ""} + ${item.validation_regex ? `Regex: ${escapeHtml(item.validation_regex)}` : ""} + ${item.repo_name ? `📦 ${escapeHtml(item.repo_name)}` : ""} +
    + `; + }, + { + itemNoun: "Requirements", + cardBorderColor: "#e67e22", + defaultSort: "desc", + searchFields: (item: any) => [item.key, item.description, item.default_value, item.namespace, item.repo_name], + } + ); + + // 4. Quality & Authorization Gates + loadGenericList( + "/api/gates", + "gates-container", + (item) => { + const status = (item.status || "pending").toLowerCase(); + let statusColor = "var(--accent-color)"; + if (status === "authorized") statusColor = "var(--success-color)"; + else if (status === "blocked") statusColor = "var(--error-color)"; + + return ` +
    +
    + ${escapeHtml(item.action)} → ${escapeHtml(item.target)} + ${escapeHtml(status)} +
    +
    + ${escapeHtml(item.namespace || "global")} + ${status !== "authorized" ? `` : ""} + ${status !== "blocked" ? `` : ""} + +
    +
    + ${item.reason ? `
    ${escapeHtml(item.reason)}
    ` : ""} +
    + ${item.gate_type ? `Type: ${escapeHtml(item.gate_type)}` : ""} + ${item.enforcer ? `Enforcer: ${escapeHtml(item.enforcer)}` : ""} + ${item.repo_name ? `📦 ${escapeHtml(item.repo_name)}` : ""} +
    + `; + }, + { + itemNoun: "Gates", + cardBorderColor: "#e74c3c", + defaultSort: "desc", + timestampExtractor: (item: any) => item.timestamp, + searchFields: (item: any) => [item.action, item.target, item.status, item.reason, item.enforcer], + } + ); +} + function loadAllExtras(): void { loadTerminal(); loadTechDebt(); @@ -2343,31 +2574,349 @@ function setupSSE(): void { } } +// --- Interactive Dashboard Controls --- +async function resolveTechDebt(id: string): Promise { + try { + if (activeWS && activeWS.readyState === WebSocket.OPEN) { + await sendWsRpc("tech_debts/resolve", { id }); + } else { + const res = await fetch(`/api/tech_debts/${encodeURIComponent(id)}/resolve`, { method: "POST" }); + if (!res.ok) console.error("Failed to resolve tech debt", await res.text()); + } + loadTechDebt(); + } catch (err) { + console.error("Error resolving tech debt via WS, trying HTTP fallback", err); + try { + const res = await fetch(`/api/tech_debts/${encodeURIComponent(id)}/resolve`, { method: "POST" }); + if (!res.ok) console.error("Failed to resolve tech debt", await res.text()); + loadTechDebt(); + } catch (fallbackErr) { + console.error("Error resolving tech debt", fallbackErr); + } + } +} + +async function authorizeGate(id: string): Promise { + try { + if (activeWS && activeWS.readyState === WebSocket.OPEN) { + await sendWsRpc("gates/authorize", { id }); + } else { + const res = await fetch(`/api/gates/${encodeURIComponent(id)}/authorize`, { method: "POST" }); + if (!res.ok) console.error("Failed to authorize gate", await res.text()); + } + loadEnvironment(); + } catch (err) { + console.error("Error authorizing gate via WS, trying HTTP fallback", err); + try { + const res = await fetch(`/api/gates/${encodeURIComponent(id)}/authorize`, { method: "POST" }); + if (!res.ok) console.error("Failed to authorize gate", await res.text()); + loadEnvironment(); + } catch (fallbackErr) { + console.error("Error authorizing gate", fallbackErr); + } + } +} + +async function blockGate(id: string): Promise { + try { + if (activeWS && activeWS.readyState === WebSocket.OPEN) { + await sendWsRpc("gates/block", { id }); + } else { + const res = await fetch(`/api/gates/${encodeURIComponent(id)}/block`, { method: "POST" }); + if (!res.ok) console.error("Failed to block gate", await res.text()); + } + loadEnvironment(); + } catch (err) { + console.error("Error blocking gate via WS, trying HTTP fallback", err); + try { + const res = await fetch(`/api/gates/${encodeURIComponent(id)}/block`, { method: "POST" }); + if (!res.ok) console.error("Failed to block gate", await res.text()); + loadEnvironment(); + } catch (fallbackErr) { + console.error("Error blocking gate", fallbackErr); + } + } +} + +async function deleteGate(id: string): Promise { + try { + if (activeWS && activeWS.readyState === WebSocket.OPEN) { + await sendWsRpc("gates/delete", { id }); + } else { + const res = await fetch(`/api/gates/${encodeURIComponent(id)}`, { method: "DELETE" }); + if (!res.ok) console.error("Failed to delete gate", await res.text()); + } + loadEnvironment(); + } catch (err) { + console.error("Error deleting gate via WS, trying HTTP fallback", err); + try { + const res = await fetch(`/api/gates/${encodeURIComponent(id)}`, { method: "DELETE" }); + if (!res.ok) console.error("Failed to delete gate", await res.text()); + loadEnvironment(); + } catch (fallbackErr) { + console.error("Error deleting gate", fallbackErr); + } + } +} + +// --- Command Palette (Ctrl+K) --- +interface PaletteItem { + id: string; + category: "task" | "techdebt" | "adr" | "env" | "gate" | "nav"; + icon: string; + title: string; + sub: string; + badge: string; + tabId: string; +} + +let paletteItems: PaletteItem[] = []; +let filteredPaletteItems: PaletteItem[] = []; +let paletteSelectedIndex = 0; +let paletteDataLoaded = false; + +async function preloadPaletteData(): Promise { + try { + const [tasksRes, debtsRes, adrsRes, envsRes, gatesRes] = await Promise.all([ + fetch("/api/tasks", { cache: "no-store" }).then((r) => r.json()).catch(() => []), + fetch("/api/tech_debts", { cache: "no-store" }).then((r) => r.json()).catch(() => []), + fetch("/api/adrs", { cache: "no-store" }).then((r) => r.json()).catch(() => []), + fetch("/api/environments", { cache: "no-store" }).then((r) => r.json()).catch(() => []), + fetch("/api/gates", { cache: "no-store" }).then((r) => r.json()).catch(() => []), + ]); + + const items: PaletteItem[] = [ + { id: "nav-activity", category: "nav", icon: "⚡", title: "Live Activity Feed", sub: "Inspect real-time agent signals & SSE", badge: "VIEW", tabId: "activity-tab" }, + { id: "nav-ledger", category: "nav", icon: "📖", title: "Code Ledger", sub: "View logged code changes & commits", badge: "VIEW", tabId: "ledger-tab" }, + { id: "nav-graph", category: "nav", icon: "🕸️", title: "Knowledge Graph Explorer", sub: "Explore nodes, relationships & dependencies", badge: "VIEW", tabId: "graph-tab" }, + { id: "nav-search", category: "nav", icon: "🔍", title: "Omni-Search", sub: "Full-text cross-entity search", badge: "VIEW", tabId: "search-tab" }, + { id: "nav-tasks", category: "nav", icon: "📋", title: "Task Board", sub: "HTN / DAG task hierarchies", badge: "VIEW", tabId: "task-tab" }, + { id: "nav-techdebt", category: "nav", icon: "⚡", title: "Tech Debt & Error Fixes", sub: "Known tech debt and error fixes", badge: "VIEW", tabId: "techdebt-tab" }, + { id: "nav-adrs", category: "nav", icon: "📜", title: "Architectural Decision Records", sub: "ADRs and design choices", badge: "VIEW", tabId: "adrs-tab" }, + { id: "nav-memos", category: "nav", icon: "📝", title: "Handoff Memos", sub: "Agent handoffs & session summaries", badge: "VIEW", tabId: "memos-tab" }, + { id: "nav-snippets", category: "nav", icon: "📎", title: "Code Snippets", sub: "Indexed snippets & patterns", badge: "VIEW", tabId: "snippets-tab" }, + { id: "nav-terminal", category: "nav", icon: "💻", title: "Terminal History", sub: "Command execution log & exit codes", badge: "VIEW", tabId: "terminal-tab" }, + { id: "nav-env", category: "nav", icon: "🌐", title: "Environment & Quality Gates", sub: "Deploy targets, fingerprints, requirements & gates", badge: "VIEW", tabId: "env-tab" }, + ]; + + if (Array.isArray(tasksRes)) { + tasksRes.forEach((t: any) => { + items.push({ + id: t.id, + category: "task", + icon: t.status === "completed" ? "✓" : "📋", + title: t.title || t.id, + sub: t.description || `Task (${t.status || "pending"})`, + badge: t.status || "TASK", + tabId: "task-tab", + }); + }); + } + + if (Array.isArray(debtsRes)) { + debtsRes.forEach((d: any) => { + items.push({ + id: d.id, + category: "techdebt", + icon: "⚡", + title: `${d.id}: ${d.description || ""}`.trim(), + sub: d.file_path ? `${d.file_path} - Severity: ${d.severity || "medium"}` : `Severity: ${d.severity || "medium"}`, + badge: d.is_resolved ? "RESOLVED" : (d.severity || "DEBT"), + tabId: "techdebt-tab", + }); + }); + } + + if (Array.isArray(adrsRes)) { + adrsRes.forEach((a: any) => { + items.push({ + id: a.id, + category: "adr", + icon: "📜", + title: `${a.id}: ${a.title || ""}`.trim(), + sub: a.decision || `Status: ${a.status || "accepted"}`, + badge: a.status || "ADR", + tabId: "adrs-tab", + }); + }); + } + + if (Array.isArray(envsRes)) { + envsRes.forEach((e: any) => { + items.push({ + id: e.name || e.url, + category: "env", + icon: "🌐", + title: e.name || "Target Environment", + sub: `${e.url || ""} ${e.description ? "• " + e.description : ""}`.trim(), + badge: e.env_type || "ENV", + tabId: "env-tab", + }); + }); + } + + if (Array.isArray(gatesRes)) { + gatesRes.forEach((g: any) => { + items.push({ + id: g.id, + category: "gate", + icon: g.status === "authorized" ? "🟢" : (g.status === "blocked" ? "🔴" : "🟡"), + title: `${g.action} → ${g.target}`, + sub: g.reason || `Enforcer: ${g.enforcer || "gate"}`, + badge: g.status || "GATE", + tabId: "env-tab", + }); + }); + } + + paletteItems = items; + paletteDataLoaded = true; + } catch (err) { + console.error("Failed to preload palette data", err); + } +} + +function openCommandPalette(): void { + const backdrop = document.getElementById("cmd-palette-backdrop"); + const input = document.getElementById("palette-input") as HTMLInputElement | null; + if (!backdrop || !input) return; + + backdrop.style.display = "flex"; + input.value = ""; + paletteSelectedIndex = 0; + preloadPaletteData().then(() => { + onPaletteSearch(""); + input.focus(); + }); +} + +function closeCommandPalette(): void { + const backdrop = document.getElementById("cmd-palette-backdrop"); + if (backdrop) backdrop.style.display = "none"; +} + +function onPaletteBackdropClick(e: MouseEvent): void { + if (e.target === document.getElementById("cmd-palette-backdrop")) { + closeCommandPalette(); + } +} + +function onPaletteSearch(query: string): void { + const q = query.toLowerCase().trim(); + if (!q) { + filteredPaletteItems = paletteItems.slice(0, 30); + } else { + filteredPaletteItems = paletteItems + .filter((item) => + item.title.toLowerCase().includes(q) || + item.sub.toLowerCase().includes(q) || + item.badge.toLowerCase().includes(q) || + item.category.toLowerCase().includes(q) + ) + .slice(0, 40); + } + + paletteSelectedIndex = 0; + renderPaletteResults(); +} + +function renderPaletteResults(): void { + const container = document.getElementById("palette-results"); + const countEl = document.getElementById("palette-count"); + if (!container) return; + + if (countEl) countEl.innerText = `${filteredPaletteItems.length} results`; + + if (filteredPaletteItems.length === 0) { + container.innerHTML = '
  • No matching items found.
  • '; + return; + } + + container.innerHTML = filteredPaletteItems + .map((item, idx) => { + const isSel = idx === paletteSelectedIndex; + return ` +
  • +
    + ${escapeHtml(item.icon)} +
    + ${escapeHtml(item.title)} + ${escapeHtml(item.sub)} +
    +
    + ${escapeHtml(item.badge)} +
  • + `; + }) + .join(""); + + const selectedEl = container.querySelector(`.palette-item[data-index="${paletteSelectedIndex}"]`) as HTMLElement | null; + if (selectedEl) { + selectedEl.scrollIntoView({ block: "nearest" }); + } +} + +function selectPaletteItem(idx: number): void { + const item = filteredPaletteItems[idx]; + if (!item) return; + + closeCommandPalette(); + const tabBtn = document.querySelector(`button[onclick*="${item.tabId}"]`) as HTMLElement | null; + if (tabBtn) { + switchTab(item.tabId, tabBtn); + } +} + +function onPaletteKeyDown(e: KeyboardEvent): void { + if (e.key === "ArrowDown") { + e.preventDefault(); + if (filteredPaletteItems.length > 0) { + paletteSelectedIndex = (paletteSelectedIndex + 1) % filteredPaletteItems.length; + renderPaletteResults(); + } + } else if (e.key === "ArrowUp") { + e.preventDefault(); + if (filteredPaletteItems.length > 0) { + paletteSelectedIndex = (paletteSelectedIndex - 1 + filteredPaletteItems.length) % filteredPaletteItems.length; + renderPaletteResults(); + } + } else if (e.key === "Enter") { + e.preventDefault(); + selectPaletteItem(paletteSelectedIndex); + } else if (e.key === "Escape") { + e.preventDefault(); + closeCommandPalette(); + } +} + document.addEventListener("keydown", function (e: KeyboardEvent) { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { e.preventDefault(); - const searchTabBtn = document.querySelector( - 'button[onclick*="search-tab"]', - ) as HTMLElement | null; - if (searchTabBtn) { - switchTab("search-tab", searchTabBtn); - } - const searchInput = document.getElementById( - "search-box", - ) as HTMLInputElement | null; - if (searchInput) { - searchInput.focus(); - searchInput.select(); + const backdrop = document.getElementById("cmd-palette-backdrop"); + if (backdrop && backdrop.style.display === "flex") { + closeCommandPalette(); + } else { + openCommandPalette(); } return; } + if (e.key === "Escape") { + const backdrop = document.getElementById("cmd-palette-backdrop"); + if (backdrop && backdrop.style.display === "flex") { + closeCommandPalette(); + return; + } + } + // Pressing '/' focuses the current active list search input if not already typing if ( e.key === "/" && document.activeElement?.tagName !== "INPUT" && document.activeElement?.tagName !== "TEXTAREA" ) { + const backdrop = document.getElementById("cmd-palette-backdrop"); + if (backdrop && backdrop.style.display === "flex") return; const activeSearch = document.querySelector( ".tab-content.active .enterprise-search-input, .tab-content.active #task-filter-input", ) as HTMLInputElement | null; @@ -2405,6 +2954,9 @@ function refreshActiveTab(): void { case "ledger-tab": loadLedger(); break; + case "env-tab": + loadEnvironment(); + break; case "activity-tab": loadActivityHistory(); break; @@ -2437,6 +2989,7 @@ Object.assign(window, { requestDomainRefresh, handleIncomingActivity, setupWS, + sendWsRpc, loadGenericList, onListSearchInput, onListClearSearch, @@ -2452,6 +3005,17 @@ Object.assign(window, { loadMemos, loadSnippets, loadAllExtras, + loadEnvironment, + resolveTechDebt, + authorizeGate, + blockGate, + deleteGate, + openCommandPalette, + closeCommandPalette, + onPaletteBackdropClick, + onPaletteSearch, + onPaletteKeyDown, + selectPaletteItem, testClipboard, loadVersion, setupSSE, diff --git a/server/src/handlers/env.rs b/server/src/handlers/env.rs index 0aae4bf..3e4bcfd 100644 --- a/server/src/handlers/env.rs +++ b/server/src/handlers/env.rs @@ -26,13 +26,21 @@ impl McpTool for EnvironmentHandler { match req.action { EnvAction::UpdateFingerprint => { let tool_versions = req.tool_versions.unwrap_or_default(); + let detected_os = req.os.unwrap_or_else(|| std::env::consts::OS.to_string()); + let detected_shell = req.shell + .or_else(|| std::env::var("SHELL").ok()) + .or_else(|| std::env::var("STARSHIP_SHELL").ok()) + .or_else(|| if std::env::var("PSModulePath").is_ok() { Some("pwsh".to_string()) } else { None }) + .or_else(|| if std::env::var("COMSPEC").is_ok() { Some("cmd".to_string()) } else { None }) + .unwrap_or_else(|| "unknown".to_string()); + state.env.env_fingerprints.modify(|fps| { 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()), + os: detected_os, + shell: detected_shell, tool_versions, updated_at: crate::handlers::utils::now_secs(), ..Default::default() diff --git a/server/src/handlers/vision.rs b/server/src/handlers/vision.rs index af9832e..2613e30 100644 --- a/server/src/handlers/vision.rs +++ b/server/src/handlers/vision.rs @@ -8,9 +8,229 @@ use serde_json::{Value, json}; use std::borrow::Cow; use std::sync::Arc; - static CLIPBOARD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +pub fn to_wsl_path(win_path: &str) -> String { + let p = win_path.trim(); + if let Some(rest) = p.strip_prefix("C:\\").or_else(|| p.strip_prefix("c:\\")) { + format!("/mnt/c/{}", rest.replace('\\', "/")) + } else if let Some(rest) = p.strip_prefix("D:\\").or_else(|| p.strip_prefix("d:\\")) { + format!("/mnt/d/{}", rest.replace('\\', "/")) + } else { + p.replace('\\', "/") + } +} + +pub fn extract_html_fragment(raw: &str) -> &str { + if let Some(start_idx) = raw.find("") { + let after_start = start_idx + "".len(); + if let Some(end_idx) = raw[after_start..].find("") { + return &raw[after_start..after_start + end_idx]; + } + return &raw[after_start..]; + } + raw +} + +pub fn html_to_markdown(html: &str) -> String { + let mut out = String::new(); + let mut in_table = false; + let mut table_rows: Vec> = Vec::new(); + let mut current_row: Vec = Vec::new(); + let mut current_cell = String::new(); + let mut in_cell = false; + + let mut chars = html.chars().peekable(); + while let Some(c) = chars.next() { + if c == '<' { + let mut tag = String::new(); + while let Some(&next_c) = chars.peek() { + chars.next(); + if next_c == '>' { + break; + } + tag.push(next_c); + } + let tag_lower = tag.trim().to_lowercase(); + let tag_name = tag_lower.split_whitespace().next().unwrap_or(""); + + match tag_name { + "table" => { + in_table = true; + table_rows.clear(); + } + "/table" => { + if in_table { + in_table = false; + if !table_rows.is_empty() { + out.push('\n'); + let num_cols = table_rows.iter().map(|r| r.len()).max().unwrap_or(0); + for (row_idx, row) in table_rows.iter().enumerate() { + out.push('|'); + for col_idx in 0..num_cols { + let cell_val = row.get(col_idx).map(|s| s.trim()).unwrap_or(""); + out.push(' '); + out.push_str(cell_val); + out.push_str(" |"); + } + out.push('\n'); + if row_idx == 0 { + out.push('|'); + for _ in 0..num_cols { + out.push_str(" --- |"); + } + out.push('\n'); + } + } + out.push('\n'); + } + } + } + "tr" => { + if in_table { + current_row.clear(); + } + } + "/tr" => { + if in_table { + if !current_row.is_empty() { + table_rows.push(current_row.clone()); + } + current_row.clear(); + } + } + "th" | "td" => { + if in_table { + in_cell = true; + current_cell.clear(); + } + } + "/th" | "/td" => { + if in_table && in_cell { + in_cell = false; + current_row.push(current_cell.trim().to_string()); + current_cell.clear(); + } + } + "br" | "br/" => { + if in_cell { + current_cell.push(' '); + } else { + out.push('\n'); + } + } + "p" | "div" => { + if !in_cell && !out.ends_with("\n\n") && !out.is_empty() { + out.push('\n'); + } + } + "/p" | "/div" => { + if !in_cell && !out.ends_with("\n\n") { + out.push('\n'); + } + } + "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => { + if !in_cell { + if !out.ends_with('\n') && !out.is_empty() { + out.push('\n'); + } + let level = tag_name[1..].parse::().unwrap_or(1); + out.push_str(&"#".repeat(level)); + out.push(' '); + } + } + "/h1" | "/h2" | "/h3" | "/h4" | "/h5" | "/h6" => { + if !in_cell { + out.push('\n'); + } + } + "b" | "strong" => { + if in_cell { + current_cell.push_str("**"); + } else { + out.push_str("**"); + } + } + "/b" | "/strong" => { + if in_cell { + current_cell.push_str("**"); + } else { + out.push_str("**"); + } + } + "i" | "em" => { + if in_cell { + current_cell.push('*'); + } else { + out.push('*'); + } + } + "/i" | "/em" => { + if in_cell { + current_cell.push('*'); + } else { + out.push('*'); + } + } + "code" => { + if in_cell { + current_cell.push('`'); + } else { + out.push('`'); + } + } + "/code" => { + if in_cell { + current_cell.push('`'); + } else { + out.push('`'); + } + } + "li" => { + if !in_cell { + out.push_str("* "); + } + } + "/li" => { + if !in_cell { + out.push('\n'); + } + } + _ => {} + } + } else if c == '&' { + let mut entity = String::new(); + while let Some(&next_c) = chars.peek() { + chars.next(); + if next_c == ';' { + break; + } + entity.push(next_c); + } + let decoded = match entity.as_str() { + "nbsp" => " ", + "amp" => "&", + "lt" => "<", + "gt" => ">", + "quot" => "\"", + "apos" | "#39" => "'", + _ => " ", + }; + if in_cell { + current_cell.push_str(decoded); + } else if !in_table { + out.push_str(decoded); + } + } else if in_cell { + current_cell.push(c); + } else if !in_table { + out.push(c); + } + } + + out.trim().to_string() +} + pub fn get_native_clipboard_text() -> Option { let _guard = CLIPBOARD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); for _ in 0..3 { @@ -25,6 +245,83 @@ pub fn get_native_clipboard_text() -> Option { None } +#[cfg(target_os = "windows")] +pub fn get_windows_html_clipboard() -> Option { + use windows_sys::Win32::System::DataExchange::{ + CloseClipboard, GetClipboardData, IsClipboardFormatAvailable, OpenClipboard, + RegisterClipboardFormatW, + }; + use windows_sys::Win32::System::Memory::{GlobalLock, GlobalSize, GlobalUnlock}; + let format_name: Vec = "HTML Format\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 html_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); + if let Ok(s) = std::str::from_utf8(slice) { + html_opt = Some(s.to_string()); + } + GlobalUnlock(handle as _); + } + } + } + } + CloseClipboard(); + if html_opt.is_some() { + return html_opt; + } + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + } + None +} + +#[cfg(target_os = "windows")] +pub fn perform_windows_ocr(img: &image::DynamicImage) -> Option { + use windows::Graphics::Imaging::BitmapDecoder; + use windows::Media::Ocr::OcrEngine; + use windows::Storage::Streams::{DataWriter, InMemoryRandomAccessStream}; + + let mut png_bytes = std::io::Cursor::new(Vec::new()); + img.write_to(&mut png_bytes, image::ImageFormat::Png).ok()?; + let bytes = png_bytes.into_inner(); + + let stream = InMemoryRandomAccessStream::new().ok()?; + let writer = DataWriter::CreateDataWriter(&stream).ok()?; + writer.WriteBytes(&bytes).ok()?; + writer.StoreAsync().ok()?.join().ok()?; + writer.FlushAsync().ok()?.join().ok()?; + stream.Seek(0).ok()?; + + let decoder = BitmapDecoder::CreateAsync(&stream).ok()?.join().ok()?; + let software_bitmap = decoder.GetSoftwareBitmapAsync().ok()?.join().ok()?; + + let engine = OcrEngine::TryCreateFromUserProfileLanguages().ok()?; + let result = engine.RecognizeAsync(&software_bitmap).ok()?.join().ok()?; + let text = result.Text().ok()?; + let s = text.to_string(); + let trimmed = s.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + #[cfg(target_os = "windows")] fn get_windows_png_clipboard_image() -> Option { use windows_sys::Win32::System::DataExchange::{ @@ -67,6 +364,7 @@ fn get_windows_png_clipboard_image() -> Option { } None } + pub fn get_native_clipboard_image() -> Option { let _guard = CLIPBOARD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); #[cfg(target_os = "windows")] @@ -89,6 +387,242 @@ pub fn get_native_clipboard_image() -> Option { None } +pub async fn process_clipboard_snapshot(state: Arc) { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + // 1. Try to read image first + let img_opt = tokio::task::spawn_blocking(get_native_clipboard_image) + .await + .ok() + .flatten(); + + if let Some(mut img) = img_opt { + let max_dim = 1440; + if img.width() > max_dim || img.height() > max_dim { + img = img.resize(max_dim, max_dim, FilterType::Lanczos3); + } + let (w, h) = (img.width(), img.height()); + let rgb_img = img.to_rgb8(); + + let mut jpeg_bytes = std::io::Cursor::new(Vec::new()); + let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg_bytes, 88); + if encoder + .encode(&rgb_img, w, h, image::ExtendedColorType::Rgb8) + .is_ok() + { + let bytes = jpeg_bytes.into_inner(); + let size_bytes = bytes.len(); + + 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_image.jpg"); + let _ = std::fs::write(&file_path, &bytes); + + let path_str = file_path.to_string_lossy().to_string(); + let wsl_path = to_wsl_path(&path_str); + + // Deterministic OCR + let img_clone = img.clone(); + let ocr_text_opt = tokio::task::spawn_blocking(move || { + #[cfg(target_os = "windows")] + { + perform_windows_ocr(&img_clone) + } + #[cfg(not(target_os = "windows"))] + { + None + } + }) + .await + .ok() + .flatten(); + + // Index OCR text into Tantivy for omni_search + if let Some(ref text) = ocr_text_opt { + let doc_id = format!("clipboard_{}", now_ms); + let _ = state + .search_index + .read() + .await + .index_clipboard_image(&doc_id, &path_str, text); + } + + let cached = crate::state::CachedClipboardImage { + file_path: path_str, + file_path_wsl: wsl_path, + captured_at_epoch_ms: now_ms, + age: "0s ago".to_string(), + width: w, + height: h, + size_bytes, + ocr_text: ocr_text_opt, + }; + + let mut cache = state.clipboard_cache.write().await; + cache.last_image = Some(cached.clone()); + cache + .history + .push_front(crate::state::ClipboardHistoryItem::Image(cached)); + if cache.history.len() > 20 { + cache.history.pop_back(); + } + return; + } + } + + // 2. Try text / HTML + let text_opt = tokio::task::spawn_blocking(|| { + #[cfg(target_os = "windows")] + if let Some(raw_html) = get_windows_html_clipboard() { + let frag = extract_html_fragment(&raw_html); + let md = html_to_markdown(frag); + if !md.trim().is_empty() { + return Some(md); + } + } + get_native_clipboard_text() + }) + .await + .ok() + .flatten(); + + if let Some(text) = text_opt { + let text_trim = text.trim(); + if text_trim.is_empty() { + return; + } + let char_count = text_trim.chars().count(); + let cached = crate::state::CachedClipboardText { + text: text_trim.to_string(), + captured_at_epoch_ms: now_ms, + age: "0s ago".to_string(), + char_count, + }; + + let mut cache = state.clipboard_cache.write().await; + if let Some(ref prev) = cache.last_text { + if prev.text == cached.text { + return; + } + } + cache.last_text = Some(cached.clone()); + cache + .history + .push_front(crate::state::ClipboardHistoryItem::Text(cached)); + if cache.history.len() > 20 { + cache.history.pop_back(); + } + } +} + +#[cfg(target_os = "windows")] +static CLIPBOARD_NOTIFY_TX: std::sync::OnceLock> = + std::sync::OnceLock::new(); + +#[cfg(target_os = "windows")] +pub fn spawn_clipboard_listener(state: Arc) { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<()>(); + let _ = CLIPBOARD_NOTIFY_TX.set(tx); + + std::thread::Builder::new() + .name("win32-clipboard-listener".to_string()) + .spawn(move || { + use windows_sys::Win32::Foundation::*; + use windows_sys::Win32::System::DataExchange::*; + use windows_sys::Win32::UI::WindowsAndMessaging::*; + + unsafe extern "system" fn wnd_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, + ) -> LRESULT { + if msg == WM_CLIPBOARDUPDATE { + if let Some(tx) = CLIPBOARD_NOTIFY_TX.get() { + let _ = tx.send(()); + } + return 0; + } + unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) } + } + + unsafe { + let class_name: Vec = + "McpMemoryClipboardWatcherClass\0".encode_utf16().collect(); + let wnd_class = WNDCLASSEXW { + cbSize: std::mem::size_of::() as u32, + style: 0, + lpfnWndProc: Some(wnd_proc), + cbClsExtra: 0, + cbWndExtra: 0, + hInstance: 0 as _, + hIcon: 0 as _, + hCursor: 0 as _, + hbrBackground: 0 as _, + lpszMenuName: std::ptr::null(), + lpszClassName: class_name.as_ptr(), + hIconSm: 0 as _, + }; + RegisterClassExW(&wnd_class); + + let hwnd = CreateWindowExW( + 0, + class_name.as_ptr(), + std::ptr::null(), + 0, + 0, + 0, + 0, + 0, + HWND_MESSAGE, + 0 as _, + 0 as _, + std::ptr::null(), + ); + if hwnd == 0 as _ { + tracing::error!("Failed to create Win32 clipboard message window"); + return; + } + + if AddClipboardFormatListener(hwnd) == 0 { + tracing::error!("Failed to AddClipboardFormatListener"); + DestroyWindow(hwnd); + return; + } + tracing::info!("Win32 clipboard listener registered successfully on HWND_MESSAGE."); + + let mut msg: MSG = std::mem::zeroed(); + while GetMessageW(&mut msg, 0 as _, 0, 0) > 0 { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + + RemoveClipboardFormatListener(hwnd); + DestroyWindow(hwnd); + } + }) + .expect("Failed to spawn win32-clipboard-listener thread"); + + tokio::spawn(async move { + while let Some(()) = rx.recv().await { + tokio::time::sleep(tokio::time::Duration::from_millis(150)).await; + while let Ok(_) = rx.try_recv() {} + + process_clipboard_snapshot(Arc::clone(&state)).await; + } + }); +} + +#[cfg(not(target_os = "windows"))] +pub fn spawn_clipboard_listener(_state: Arc) { + tracing::info!("Clipboard listener: running in headless/Linux mode (no Win32 listener)."); +} + pub struct ClipboardHandler; #[async_trait] @@ -100,13 +634,154 @@ impl McpTool for ClipboardHandler { fn schema(&self) -> Value { crate::mcp::tool_def::( "clipboard", - "Consolidated OS clipboard management (read, write)", + "Smart OS clipboard management: Retrieve screenshots ('image') with overwrite immunity and verbatim OCR, normalized text ('text'), recent history ('history'), clear, read, or write.", ) } async fn execute(&self, args: Value, state: Arc) -> crate::error::Result { let req: ClipboardTool = serde_json::from_value(args).map_err(|e| e.to_string())?; match req.action { + ClipboardAction::Image => { + let cached_opt = { + let cache = state.clipboard_cache.read().await; + cache.last_image.clone() + }; + + let item = if let Some(mut img) = cached_opt { + img.age = crate::state::format_age(img.captured_at_epoch_ms); + Some(img) + } else { + process_clipboard_snapshot(Arc::clone(&state)).await; + let cache = state.clipboard_cache.read().await; + cache.last_image.clone().map(|mut img| { + img.age = crate::state::format_age(img.captured_at_epoch_ms); + img + }) + }; + + let item = if item.is_none() { + let cache_dir = dirs::home_dir() + .unwrap_or_default() + .join(".gemini/mcp_memory/clipboard"); + let file_path = cache_dir.join("clipboard_latest_image.jpg"); + if file_path.exists() { + let path_str = file_path.to_string_lossy().to_string(); + let wsl_path = to_wsl_path(&path_str); + let meta = std::fs::metadata(&file_path).ok(); + let size_bytes = meta.as_ref().map(|m| m.len() as usize).unwrap_or(0); + let epoch_ms = meta + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as u64) + .unwrap_or_default(); + Some(crate::state::CachedClipboardImage { + file_path: path_str, + file_path_wsl: wsl_path, + captured_at_epoch_ms: epoch_ms, + age: crate::state::format_age(epoch_ms), + width: 0, + height: 0, + size_bytes, + ocr_text: None, + }) + } else { + None + } + } else { + item + }; + + state.record_activity( + "clipboard", + "Retrieved screenshot image from clipboard cache", + None, + ); + + if let Some(img) = item { + Ok(serde_json::to_string_pretty(&img)?) + } else { + Ok(json!({ + "status": "no_image_in_clipboard", + "message": "No screenshot or image found in clipboard cache. Take a screenshot (Win+Shift+S on Windows) and call clipboard(action: \"image\") again." + }).to_string()) + } + } + ClipboardAction::Text => { + let cached_opt = { + let cache = state.clipboard_cache.read().await; + cache.last_text.clone() + }; + + let item = if let Some(mut txt) = cached_opt { + txt.age = crate::state::format_age(txt.captured_at_epoch_ms); + Some(txt) + } else { + process_clipboard_snapshot(Arc::clone(&state)).await; + let cache = state.clipboard_cache.read().await; + cache.last_text.clone().map(|mut txt| { + txt.age = crate::state::format_age(txt.captured_at_epoch_ms); + txt + }) + }; + + state.record_activity("clipboard", "Retrieved text from clipboard cache", None); + if let Some(txt) = item { + Ok(serde_json::to_string_pretty(&txt)?) + } else { + Ok(json!({ + "text": "", + "status": "empty", + "message": "Clipboard does not currently contain text." + }) + .to_string()) + } + } + ClipboardAction::History => { + let history = { + let cache = state.clipboard_cache.read().await; + cache + .history + .iter() + .map(|item| match item { + crate::state::ClipboardHistoryItem::Image(img) => { + let mut img = img.clone(); + img.age = crate::state::format_age(img.captured_at_epoch_ms); + crate::state::ClipboardHistoryItem::Image(img) + } + crate::state::ClipboardHistoryItem::Text(txt) => { + let mut txt = txt.clone(); + txt.age = crate::state::format_age(txt.captured_at_epoch_ms); + crate::state::ClipboardHistoryItem::Text(txt) + } + }) + .collect::>() + }; + + state.record_activity("clipboard", "Viewed clipboard history ring buffer", None); + Ok(serde_json::to_string_pretty(&history)?) + } + ClipboardAction::Clear => { + tokio::task::spawn_blocking(|| { + let _guard = CLIPBOARD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + if let Ok(mut c) = Clipboard::new() { + let _ = c.clear(); + } + }) + .await + .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; + + let mut cache = state.clipboard_cache.write().await; + cache.last_image = None; + cache.last_text = None; + cache.history.clear(); + + state.record_activity("clipboard", "Cleared OS clipboard and memory cache", None); + Ok(json!({ + "status": "cleared", + "message": "OS clipboard and memory cache successfully cleared." + }) + .to_string()) + } ClipboardAction::Read => { let (mut out, b64_opt) = tokio::task::spawn_blocking(move || -> crate::error::Result<(serde_json::Map, Option)> { @@ -119,14 +794,15 @@ impl McpTool for ClipboardHandler { if let Some(dynamic_img) = get_native_clipboard_image() { let mut img = dynamic_img; - let max_dim = 1024; + let max_dim = 1440; 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 mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg_bytes, 88); + if encoder.encode(&rgb_img, rgb_img.width(), rgb_img.height(), image::ExtendedColorType::Rgb8).is_ok() { let bytes = jpeg_bytes.into_inner(); use base64::Engine; let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); @@ -136,18 +812,12 @@ impl McpTool for ClipboardHandler { .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"); + let file_path = cache_dir.join("clipboard_latest_image.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)); - } + let wsl_path = to_wsl_path(&path_str); + out.insert("image_path_wsl".into(), json!(wsl_path)); } } } @@ -171,7 +841,9 @@ impl McpTool for ClipboardHandler { } state.record_activity("clipboard", "Read contents from OS clipboard", None); - Ok::(serde_json::to_string_pretty(&Value::Object(out))?) + Ok::(serde_json::to_string_pretty(&Value::Object( + out, + ))?) } ClipboardAction::Write => { let text_opt = req.text; @@ -258,7 +930,6 @@ impl McpTool for ClipboardHandler { } } - #[cfg(test)] mod tests { use super::*; @@ -266,7 +937,6 @@ mod tests { use std::sync::Arc; use tempfile::tempdir; - #[tokio::test] async fn test_write_clipboard() { let dir = tempdir().unwrap(); @@ -351,6 +1021,119 @@ mod tests { "Regression detected: vision.rs must not invoke xclip!" ); } + + #[test] + fn test_wsl_path_translation() { + assert_eq!( + to_wsl_path(r"C:\Users\test\image.jpg"), + "/mnt/c/Users/test/image.jpg" + ); + assert_eq!( + to_wsl_path(r"c:\projects\code\doc.txt"), + "/mnt/c/projects/code/doc.txt" + ); + assert_eq!( + to_wsl_path(r"D:\Data\archive.tar"), + "/mnt/d/Data/archive.tar" + ); + assert_eq!(to_wsl_path("/tmp/scratch.png"), "/tmp/scratch.png"); + } + + #[test] + fn test_html_to_markdown_conversion() { + let html_snippet = "Version:0.9\r\nStartHTML:00000001\r\n
    Header 1Header 2
    Row 1 Cell 1Row 1 Cell 2
    "; + let fragment = extract_html_fragment(html_snippet); + let markdown = html_to_markdown(fragment); + assert!(markdown.contains("| Header 1 | Header 2 |")); + assert!(markdown.contains("| --- | --- |")); + assert!(markdown.contains("| Row 1 Cell 1 | Row 1 Cell 2 |")); + } + + #[tokio::test] + async fn test_interleaved_hotchpotch_clipboard_cache() { + let dir = tempdir().unwrap(); + let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + let handler = ClipboardHandler; + + // Step 1: Push Image A into cache + { + let mut cache = state.clipboard_cache.write().await; + cache.push_image(crate::state::CachedClipboardImage { + file_path: "C:\\tmp\\image_a.jpg".into(), + file_path_wsl: "/mnt/c/tmp/image_a.jpg".into(), + captured_at_epoch_ms: 1000, + age: "0s ago".into(), + width: 800, + height: 600, + size_bytes: 1024, + ocr_text: Some("Error: Failed to connect to server".into()), + }); + } + + // Step 2: Push Text 1 into cache + { + let mut cache = state.clipboard_cache.write().await; + cache.push_text(crate::state::CachedClipboardText { + text: "curl http://localhost:8080".into(), + captured_at_epoch_ms: 2000, + age: "0s ago".into(), + char_count: 26, + }); + } + + // Step 3: Push Image B into cache + { + let mut cache = state.clipboard_cache.write().await; + cache.push_image(crate::state::CachedClipboardImage { + file_path: "C:\\tmp\\image_b.jpg".into(), + file_path_wsl: "/mnt/c/tmp/image_b.jpg".into(), + captured_at_epoch_ms: 3000, + age: "0s ago".into(), + width: 1200, + height: 800, + size_bytes: 2048, + ocr_text: Some("git commit -m 'feat: smart clipboard'".into()), + }); + } + + // Step 4: Push Text 2 into cache (User got sidetracked and copied text!) + { + let mut cache = state.clipboard_cache.write().await; + cache.push_text(crate::state::CachedClipboardText { + text: "Some random stackoverflow URL".into(), + captured_at_epoch_ms: 4000, + age: "0s ago".into(), + char_count: 29, + }); + } + // Now verify: Action 'image' MUST still retrieve Image B, not be shadowed by Text 2! + let img_res_str = handler + .execute(json!({ "action": "image" }), Arc::clone(&state)) + .await + .unwrap(); + let img_val: Value = serde_json::from_str(&img_res_str).unwrap(); + assert_eq!(img_val["file_path"], "C:\\tmp\\image_b.jpg"); + assert_eq!(img_val["file_path_wsl"], "/mnt/c/tmp/image_b.jpg"); + assert_eq!(img_val["width"], 1200); + assert_eq!(img_val["height"], 800); + assert_eq!(img_val["ocr_text"], "git commit -m 'feat: smart clipboard'"); + assert!(img_val.get("age").is_some()); + + // Action 'text' MUST retrieve Text 2 + let text_res_str = handler + .execute(json!({ "action": "text" }), Arc::clone(&state)) + .await + .unwrap(); + let text_val: Value = serde_json::from_str(&text_res_str).unwrap(); + assert_eq!(text_val["text"], "Some random stackoverflow URL"); + + // Action 'history' MUST show both items + let hist_res_str = handler + .execute(json!({ "action": "history" }), Arc::clone(&state)) + .await + .unwrap(); + let hist_val: Value = serde_json::from_str(&hist_res_str).unwrap(); + let history = hist_val.as_array().unwrap(); + assert!(history.len() >= 4); + } } - - diff --git a/server/src/indexer.rs b/server/src/indexer.rs index 58e5c69..a49d4c9 100644 --- a/server/src/indexer.rs +++ b/server/src/indexer.rs @@ -131,6 +131,13 @@ pub async fn start_background_indexer(state: Arc) { snippets.drain(0..overflow); } }); + let threshold: usize = std::env::var("MCP_MEMORY_CONDENSE_THRESHOLD") + .unwrap_or_else(|_| "100".to_string()) + .parse() + .unwrap_or(100); + if state.code.snippets.read_with(|s| s.len()) > threshold { + state.condense_notify.notify_one(); + } for snippet in &snippets_to_index { let _ = idx.index_snippet(snippet).await; diff --git a/server/src/lib.rs b/server/src/lib.rs index 41aa6fe..db6be44 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -96,7 +96,62 @@ pub struct AppState { pub async fn ttl_sweeper_worker(state: Arc) { loop { - state.ttl_notify.notified().await; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let mut next_expiry: Option = None; + + state.project.tasks.read_with(|tasks| { + for t in tasks.iter() { + if let Some(exp) = t.expires_at { + if t.is_active() { + next_expiry = Some(next_expiry.map_or(exp, |curr| curr.min(exp))); + } + } + } + }); + + state.telemetry.handoff_memos.read_with(|memos| { + for m in memos.iter() { + if let Some(exp) = m.expires_at { + next_expiry = Some(next_expiry.map_or(exp, |curr| curr.min(exp))); + } + } + }); + + state.telemetry.session_summaries.read_with(|summaries| { + for s in summaries.iter() { + if let Some(exp) = s.expires_at { + next_expiry = Some(next_expiry.map_or(exp, |curr| curr.min(exp))); + } + } + }); + + state.env.gates.read_with(|gates| { + for g in gates.iter() { + if let Some(exp) = g.expires_at { + next_expiry = Some(next_expiry.map_or(exp, |curr| curr.min(exp))); + } + } + }); + + let sleep_duration = match next_expiry { + Some(exp) if exp > now => { + let diff = exp - now; + std::time::Duration::from_secs(diff.min(60).max(1)) + } + Some(_) => std::time::Duration::from_millis(50), + None => std::time::Duration::from_secs(60), + }; + + tokio::select! { + _ = state.shutdown_notify.notified() => break, + _ = state.ttl_notify.notified() => {}, + _ = tokio::time::sleep(sleep_duration) => {}, + } + let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -139,12 +194,18 @@ pub async fn ttl_sweeper_worker(state: Arc) { state.telemetry.session_summaries.modify(|summaries| { summaries.retain(|s| s.expires_at.is_none_or(|exp| exp > now)); }); + state.env.gates.modify(|gates| { + gates.retain(|g| g.expires_at.is_none_or(|exp| exp > now)); + }); } } pub async fn index_committer_worker(state: Arc) { loop { - state.index_commit_notify.notified().await; + tokio::select! { + _ = state.shutdown_notify.notified() => break, + _ = state.index_commit_notify.notified() => {}, + } let idx = state.search_index.read().await.clone(); let _ = idx.commit().await; } @@ -152,7 +213,10 @@ pub async fn index_committer_worker(state: Arc) { pub async fn condense_graph_worker(state: Arc) { loop { - state.condense_notify.notified().await; + tokio::select! { + _ = state.shutdown_notify.notified() => break, + _ = state.condense_notify.notified() => {}, + } let threshold: usize = std::env::var("MCP_MEMORY_CONDENSE_THRESHOLD") .unwrap_or_else(|_| "100".to_string()) @@ -224,6 +288,7 @@ pub async fn run_server(state: Arc) -> Result<(), Box) -> Result<(), Box { + let params = req.get("params").unwrap_or(&serde_json::Value::Null); + let task_id = params.get("id").and_then(|v| v.as_str()).unwrap_or(""); + if task_id.is_empty() { + return Some(crate::mcp::error(id, -32602, "Missing task id parameter")); + } + self.state.project.tasks.modify(|tasks| { + for t in tasks.iter_mut() { + if t.id == task_id { + t.status = "completed".to_string(); + t.updated_at = crate::handlers::utils::now_secs(); + break; + } + } + }); + let unblocked = crate::handlers::reconciliation::cascade_unblock_tasks(&self.state); + let milestones = crate::handlers::reconciliation::recalculate_milestones(&self.state); + self.state.record_activity("task", &format!("Completed task via WebSocket RPC: {}", task_id), Some("completed")); + let _ = self.state.event_bus_tx.send(crate::state::GenericEvent { + topic: "task:event".to_string(), + session_id: None, + payload: serde_json::json!({ + "task_id": task_id, + "status": "completed", + "unblocked_tasks": unblocked, + "updated_milestones": milestones, + }), + }); + Some(crate::mcp::success(id, serde_json::json!({ + "status": "success", + "unblocked_tasks": unblocked, + "updated_milestones": milestones + }))) + } + "tech_debts/resolve" => { + let params = req.get("params").unwrap_or(&serde_json::Value::Null); + let debt_id = params.get("id").and_then(|v| v.as_str()).unwrap_or(""); + if debt_id.is_empty() { + return Some(crate::mcp::error(id, -32602, "Missing debt id parameter")); + } + let mut found = false; + self.state.code.tech_debts.modify(|debts| { + for d in debts.iter_mut() { + if d.id == debt_id { + d.is_resolved = true; + found = true; + break; + } + } + }); + if found { + self.state.record_activity("tech_debt", &format!("Resolved tech debt via WebSocket RPC: {}", debt_id), Some("resolved")); + let _ = self.state.event_bus_tx.send(crate::state::GenericEvent { + topic: "tech_debt:event".to_string(), + session_id: None, + payload: serde_json::json!({ + "id": debt_id, + "status": "resolved" + }), + }); + Some(crate::mcp::success(id, serde_json::json!({ "status": "success", "message": format!("Tech debt {} resolved", debt_id) }))) + } else { + Some(crate::mcp::error(id, -32602, "Tech debt not found")) + } + } + "gates/authorize" => { + let params = req.get("params").unwrap_or(&serde_json::Value::Null); + let gate_id = params.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let mut found = false; + self.state.env.gates.modify(|gates| { + for g in gates.iter_mut() { + if g.id == gate_id { + g.status = "authorized".to_string(); + g.reason = Some("Manually authorized via WebSocket RPC".to_string()); + g.timestamp = crate::handlers::utils::now_secs(); + found = true; + break; + } + } + }); + if found { + self.state.record_activity("gate", &format!("Authorized push gate via WebSocket: {}", gate_id), Some("authorized")); + let _ = self.state.event_bus_tx.send(crate::state::GenericEvent { + topic: "gate:event".to_string(), + session_id: None, + payload: serde_json::json!({ "id": gate_id, "status": "authorized", "active_gate": "authorized" }), + }); + Some(crate::mcp::success(id, serde_json::json!({ "status": "success" }))) + } else { + Some(crate::mcp::error(id, -32602, "Gate not found")) + } + } + "gates/block" => { + let params = req.get("params").unwrap_or(&serde_json::Value::Null); + let gate_id = params.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let mut found = false; + self.state.env.gates.modify(|gates| { + for g in gates.iter_mut() { + if g.id == gate_id { + g.status = "blocked".to_string(); + g.reason = Some("Manually blocked via WebSocket RPC".to_string()); + g.timestamp = crate::handlers::utils::now_secs(); + found = true; + break; + } + } + }); + if found { + self.state.record_activity("gate", &format!("Blocked push gate via WebSocket: {}", gate_id), Some("blocked")); + let _ = self.state.event_bus_tx.send(crate::state::GenericEvent { + topic: "gate:event".to_string(), + session_id: None, + payload: serde_json::json!({ "id": gate_id, "status": "blocked", "active_gate": "blocked" }), + }); + Some(crate::mcp::success(id, serde_json::json!({ "status": "success" }))) + } else { + Some(crate::mcp::error(id, -32602, "Gate not found")) + } + } + "gates/delete" => { + let params = req.get("params").unwrap_or(&serde_json::Value::Null); + let gate_id = params.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let mut removed = false; + self.state.env.gates.modify(|gates| { + if let Some(pos) = gates.iter().position(|g| g.id == gate_id) { + gates.remove(pos); + removed = true; + } + }); + if removed { + self.state.record_activity("gate", &format!("Deleted push gate via WebSocket: {}", gate_id), Some("deleted")); + let _ = self.state.event_bus_tx.send(crate::state::GenericEvent { + topic: "gate:event".to_string(), + session_id: None, + payload: serde_json::json!({ "id": gate_id, "status": "deleted" }), + }); + Some(crate::mcp::success(id, serde_json::json!({ "status": "success" }))) + } else { + Some(crate::mcp::error(id, -32602, "Gate not found")) + } + } + "resources/subscribe" => { + let params = req.get("params").unwrap_or(&serde_json::Value::Null); + let uri = params.get("uri").and_then(|u| u.as_str()).unwrap_or(""); + tracing::info!("Client subscribed to MCP resource: {}", uri); + Some(crate::mcp::success(id, serde_json::json!({}))) + } + "resources/unsubscribe" => { + let params = req.get("params").unwrap_or(&serde_json::Value::Null); + let uri = params.get("uri").and_then(|u| u.as_str()).unwrap_or(""); + tracing::info!("Client unsubscribed from MCP resource: {}", uri); + Some(crate::mcp::success(id, serde_json::json!({}))) + } m if m.starts_with("notifications/") => None, "ping" => Some(crate::mcp::success(id, serde_json::json!({}))), _ => { diff --git a/server/src/search.rs b/server/src/search.rs index fe879f5..5b05287 100644 --- a/server/src/search.rs +++ b/server/src/search.rs @@ -179,6 +179,34 @@ impl MemoryIndex { }) } + pub fn index_clipboard_image( + &self, + id: &str, + file_path: &str, + ocr_text: &str, + ) -> tokio::task::JoinHandle> { + let writer = Arc::clone(&self.writer); + let id_field = self.id_field; + let id_val = id.to_string(); + let needs_commit = Arc::clone(&self.needs_commit); + + let doc = doc!( + self.id_field => id, + self.title_field => format!("Clipboard Screenshot: {}", file_path), + self.body_field => ocr_text, + self.type_field => "clipboard_image", + self.namespace_field => "global" + ); + + tokio::task::spawn_blocking(move || { + let writer = writer.lock().unwrap_or_else(|e| e.into_inner()); + writer.delete_term(tantivy::Term::from_field_text(id_field, &id_val)); + writer.add_document(doc)?; + needs_commit.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) + }) + } + pub fn delete_document(&self, id: &str) -> tokio::task::JoinHandle> { let writer = Arc::clone(&self.writer); let id_field = self.id_field; diff --git a/server/src/state.rs b/server/src/state.rs index cabfdae..a22e87c 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -42,6 +42,78 @@ pub struct TelemetryStores { pub agent_signals: Store>, } +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct CachedClipboardImage { + pub file_path: String, + pub file_path_wsl: String, + pub captured_at_epoch_ms: u64, + pub age: String, + pub width: u32, + pub height: u32, + pub size_bytes: usize, + pub ocr_text: Option, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct CachedClipboardText { + pub text: String, + pub captured_at_epoch_ms: u64, + pub age: String, + pub char_count: usize, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ClipboardHistoryItem { + Image(CachedClipboardImage), + Text(CachedClipboardText), +} + +#[derive(Default)] +pub struct ClipboardCacheState { + pub last_image: Option, + pub last_text: Option, + pub history: std::collections::VecDeque, +} + +impl ClipboardCacheState { + pub fn push_image(&mut self, image: CachedClipboardImage) { + self.last_image = Some(image.clone()); + self.history.push_front(ClipboardHistoryItem::Image(image)); + if self.history.len() > 20 { + self.history.pop_back(); + } + } + + pub fn push_text(&mut self, text: CachedClipboardText) { + if let Some(ref prev) = self.last_text { + if prev.text == text.text { + return; + } + } + self.last_text = Some(text.clone()); + self.history.push_front(ClipboardHistoryItem::Text(text)); + if self.history.len() > 20 { + self.history.pop_back(); + } + } +} + +pub fn format_age(epoch_ms: u64) -> String { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let diff_secs = now_ms.saturating_sub(epoch_ms) / 1000; + if diff_secs < 60 { + format!("{}s ago", diff_secs) + } else if diff_secs < 3600 { + format!("{}m ago", diff_secs / 60) + } else { + format!("{}h ago", diff_secs / 3600) + } +} + pub struct MemoryState { pub base_dir: PathBuf, pub index_commit_notify: Arc, @@ -59,6 +131,7 @@ pub struct MemoryState { pub activity_tx: tokio::sync::broadcast::Sender, pub event_bus_tx: tokio::sync::broadcast::Sender, pub ollama: Arc, + pub clipboard_cache: Arc>, } impl MemoryState { @@ -133,6 +206,7 @@ impl MemoryState { activity_tx: tokio::sync::broadcast::channel(100).0, event_bus_tx: tokio::sync::broadcast::channel(1000).0, + clipboard_cache: Arc::new(tokio::sync::RwLock::new(ClipboardCacheState::default())), }; // Normalize pre-existing graph entity and relation types @@ -162,6 +236,19 @@ impl MemoryState { pub fn modify_graph(&self, update_fn: F) { self.graph.modify(update_fn); + let threshold: usize = std::env::var("MCP_MEMORY_CONDENSE_THRESHOLD") + .unwrap_or_else(|_| "100".to_string()) + .parse() + .unwrap_or(100); + let count = self.graph.read_with(|g| g.entities.len()); + if count >= threshold { + self.condense_notify.notify_one(); + } + let _ = self.event_bus_tx.send(GenericEvent { + topic: "resource:updated".to_string(), + session_id: None, + payload: serde_json::json!({ "uri": "memory://graph" }), + }); } pub async fn get_search_index(&self) -> MemoryIndex { @@ -283,7 +370,24 @@ impl MemoryState { } pub fn broadcast_task_event(&self, event: TaskEvent) { - let payload_val = serde_json::to_value(&event).unwrap_or_default(); + let mut payload_val = serde_json::to_value(&event).unwrap_or_default(); + if let Some(obj) = payload_val.as_object_mut() { + let active_task = self.project.tasks.read_with(|tasks| { + tasks + .iter() + .find(|t| t.is_active()) + .or_else(|| { + tasks + .iter() + .find(|t| t.status == "pending" && t.parent_id.is_none()) + }) + .map(|t| t.title.clone()) + }); + obj.insert( + "active_task".to_string(), + serde_json::to_value(active_task).unwrap_or_default(), + ); + } let summary_str = format!("Task {} -> {}", event.task_id, event.status); let details_str = payload_val.to_string(); @@ -315,6 +419,11 @@ impl MemoryState { payload: payload_val, }; let _ = self.event_bus_tx.send(generic_ev); + let _ = self.event_bus_tx.send(GenericEvent { + topic: "resource:updated".to_string(), + session_id: None, + payload: serde_json::json!({ "uri": "memory://tasks/active" }), + }); let ws_notification = serde_json::json!({ "jsonrpc": "2.0", diff --git a/server/src/tools.rs b/server/src/tools.rs index ced298c..6c7762d 100644 --- a/server/src/tools.rs +++ b/server/src/tools.rs @@ -984,24 +984,43 @@ pub struct EnvironmentTool { pub requires_vpn: Option, /// Optional namespace. pub namespace: Option, + /// Optional operating system name (e.g. 'windows', 'linux', 'darwin'). + pub os: Option, + /// Optional shell name (e.g. 'pwsh', 'bash', 'zsh', 'cmd'). + pub shell: Option, } -#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] +#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq)] #[serde(rename_all = "snake_case")] pub enum ClipboardAction { + #[serde(alias = "image", alias = "IMAGE", alias = "Image", alias = "screenshot", alias = "SCREENSHOT")] + Image, + #[serde(alias = "text", alias = "TEXT", alias = "Text")] + Text, + #[serde(alias = "history", alias = "HISTORY", alias = "History")] + History, + #[serde(alias = "clear", alias = "CLEAR", alias = "Clear")] + Clear, #[serde(alias = "read", alias = "READ", alias = "Read")] Read, #[serde(alias = "write", alias = "WRITE", alias = "Write")] Write, } -/// OS Clipboard management (read, write). +/// Smart OS Clipboard management with overwrite-immune screenshot caching and OCR. /// Actions: -/// - 'read': Read current clipboard contents. +/// - 'image': Get latest screenshot image path (Windows + WSL) and extracted verbatim OCR text. +/// Returns cached screenshot even if text was copied afterwards! +/// - 'text': Get latest clipboard text (or normalized Markdown if HTML was copied). +/// - 'history': View recent clipboard history ring buffer (images and text with timestamps). +/// - 'clear': Clear OS clipboard and memory cache. +/// - 'read': Read current clipboard contents (legacy alias). /// - 'write': Write content to OS clipboard. Optional: text, html, files, image_path. -/// Next steps on error: Provide text/html/files/image_path when calling 'write'. +/// +/// Triggers: Call 'image' immediately when user says "look at image in clipboard", "see screenshot", +/// "look at clipboard", "what I copied", or shares terminal error captures. #[derive(Debug, Deserialize, Serialize, JsonSchema)] pub struct ClipboardTool { - /// Action to perform: 'read' or 'write'. + /// Action to perform: 'image', 'text', 'history', 'clear', 'read', or 'write'. pub action: ClipboardAction, /// Plain text content (for 'write'). pub text: Option, diff --git a/server/src/watcher.rs b/server/src/watcher.rs index 3ad8ef7..45517d9 100644 --- a/server/src/watcher.rs +++ b/server/src/watcher.rs @@ -116,6 +116,8 @@ async fn trigger_autonomous_review(path: &Path, state: Arc) { let file_str = path.to_string_lossy().to_string(); let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(); + let chunks_count = chunks.len(); + let file_str_clone = file_str.clone(); state.modify_graph(|g| { // Ensure File entity exists g.entities.entry(file_str.clone()).or_insert_with(|| { @@ -147,7 +149,8 @@ async fn trigger_autonomous_review(path: &Path, state: Arc) { created_at: Some(now), updated_at: Some(now), ..Default::default() - }); + } + ); let rel = crate::models::Relation { from: file_str.clone(), @@ -161,6 +164,24 @@ async fn trigger_autonomous_review(path: &Path, state: Arc) { } } }); + + let _ = state.event_bus_tx.send(crate::state::GenericEvent { + topic: "ast:symbol_updated".to_string(), + session_id: None, + payload: serde_json::json!({ + "file": file_str_clone, + "extension": ext, + "symbols_count": chunks_count, + }), + }); + + let _ = state.event_bus_tx.send(crate::state::GenericEvent { + topic: "resource:updated".to_string(), + session_id: None, + payload: serde_json::json!({ + "uri": "memory://graph" + }), + }); } } } diff --git a/server/tests/parity_test.rs b/server/tests/parity_test.rs index c6797e5..fd22720 100644 --- a/server/tests/parity_test.rs +++ b/server/tests/parity_test.rs @@ -129,8 +129,15 @@ fn test_dashboard_route_parity() { } else { full_call }; - let normalized = clean.replace("${id}", "{id}"); - endpoint = normalized.to_string(); + let mut normalized = clean.to_string(); + while let Some(start_var) = normalized.find("${") { + if let Some(end_var) = normalized[start_var..].find('}') { + normalized.replace_range(start_var..=start_var + end_var, "{id}"); + } else { + break; + } + } + endpoint = normalized; } } } diff --git a/stub/Cargo.toml b/stub/Cargo.toml index 43ad319..f0fdc74 100644 --- a/stub/Cargo.toml +++ b/stub/Cargo.toml @@ -17,3 +17,6 @@ serde_json = "1.0.151" mcp-stdio = { version = "1.0.0", path = "../mcp-stdio" } regex = "1.13.1" async-channel = "2.5.0" + +[build-dependencies] +chrono = "0.4.45" diff --git a/stub/build.rs b/stub/build.rs index bd35f2a..cbee7bf 100644 --- a/stub/build.rs +++ b/stub/build.rs @@ -1,22 +1,39 @@ use std::process::Command; fn main() { - let git_hash = Command::new("git") + let mut git_hash = Command::new("git") .args(["rev-parse", "--short", "HEAD"]) .output() .ok() .and_then(|out| String::from_utf8(out.stdout).ok()) .unwrap_or_else(|| "unknown".to_string()); + git_hash = git_hash.trim().to_string(); + + let is_dirty = Command::new("git") + .args(["status", "--porcelain"]) + .output() + .is_ok_and(|out| !out.stdout.is_empty()); + + if is_dirty { + let dirty_ts = chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string(); + git_hash.push_str(&format!("-dirty-{dirty_ts}")); + } + let git_date = Command::new("git") .args(["log", "-1", "--format=%cd", "--date=format:%y.%m.%d.%H%M%S"]) .output() .ok() .and_then(|out| String::from_utf8(out.stdout).ok()) - .unwrap_or_else(|| "unknown".to_string()); + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string()); - let version = format!("{} ({})", git_date.trim(), git_hash.trim()); + let version = format!("v{git_date} ({git_hash})"); println!("cargo:rustc-env=APP_VERSION={version}"); println!("cargo:rerun-if-changed=../.git/HEAD"); println!("cargo:rerun-if-changed=../.git/index"); + println!("cargo:rerun-if-changed=src"); + println!("cargo:rerun-if-changed=Cargo.toml"); + println!("cargo:rerun-if-changed=build.rs"); }