Compare commits

..
2 Commits
Author SHA1 Message Date
Riz Ashraf 8bc8c97504 feat: use UDP rendezvous for git pre-push hook
Replaces the Python-based pre-push hook and long-polling HTTP logic with a native shell script using udp-send --wait to block on an ephemeral UDP port in the kernel. mcp-memory-server acts as the broker and replies to subscribers.
2026-10-08 11:33:13 +01:00
Riz Ashraf f4dcf3acbf feat: event-driven parity, Windows shell detection, dirty build timestamps, and gatekeeper integration 2026-10-08 10:52:19 +01:00
34 changed files with 3597 additions and 160 deletions

No files matched your search

+1
View File
@@ -9,3 +9,4 @@ llvm-cov.zip
temp.json temp.json
*.txt *.txt
summary.md summary.md
__pycache__/
Generated
+3
View File
@@ -2661,6 +2661,7 @@ dependencies = [
"tree-sitter-rust", "tree-sitter-rust",
"tree-sitter-typescript", "tree-sitter-typescript",
"uuid", "uuid",
"windows",
"windows-sys 0.59.0", "windows-sys 0.59.0",
"winres", "winres",
] ]
@@ -2670,6 +2671,7 @@ name = "mcp-memory-stub"
version = "1.0.0" version = "1.0.0"
dependencies = [ dependencies = [
"async-channel", "async-channel",
"chrono",
"clap", "clap",
"dirs 7.0.0", "dirs 7.0.0",
"futures-util", "futures-util",
@@ -2988,6 +2990,7 @@ name = "nvim-core"
version = "1.0.0" version = "1.0.0"
dependencies = [ dependencies = [
"bytes", "bytes",
"chrono",
"dashmap", "dashmap",
"dirs 7.0.0", "dirs 7.0.0",
"mcp-stdio", "mcp-stdio",
+6
View File
@@ -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. - **Hypotheses & Root Cause Analysis**: When diagnosing complex bugs or race conditions, call `hypotheses` (action: "log" / "query") to record test evidence and maintain reasoning trails across sessions.
- **Inter-Agent Coordination**: Autonomous subagents should call `agent_signals` (action: "broadcast" / "query") to publish events and discover peer agent status. - **Inter-Agent Coordination**: Autonomous subagents should call `agent_signals` (action: "broadcast" / "query") to publish events and discover peer agent status.
- **Process & Daemon Logs**: Query or tail daemon logs with `process_logs` (action: "get", "watch", "clear") instead of dumping log files into context. - **Process & Daemon Logs**: Query or tail daemon logs with `process_logs` (action: "get", "watch", "clear") instead of dumping log files into context.
- **Clipboard & Multimodal Screenshots**:
- **User Image & Visual Queries**: When the user says "look at image in clipboard", "see screenshot", "check the clipboard", or refers to an error screenshot, ALWAYS call `clipboard(action: "image")`.
- **Decoupled Screenshot History**: Even if the user got sidetracked and copied text/URLs afterwards, `clipboard(action: "image")` reliably retrieves the last captured screenshot from the event-driven cache with exact metadata (`file_path`, `file_path_wsl`, `age`, `dimensions`, `ocr_text`).
- **WSL Ubuntu Compatibility**: For WSL sessions, use the returned `file_path_wsl` (e.g. `/mnt/c/...`) directly with `view_file`.
- **Text-Only Models & Fast Context**: Use the returned deterministic `ocr_text` to immediately extract error traces, URLs, and code snippets without waiting for multimodal vision tokens.
- **Copying Formatted Tables / Data**: `clipboard(action: "read")` automatically strips Win32 `CF_HTML` headers and converts HTML tables directly into clean GFM Markdown.
- **End-of-Session Handoff**: When finishing work or logging off, trigger the `handoff_routine` prompt or call `generate_standup_report` and `handoff_memos` (action: "leave"). - **End-of-Session Handoff**: When finishing work or logging off, trigger the `handoff_routine` prompt or call `generate_standup_report` and `handoff_memos` (action: "leave").
## 3. Delegation ## 3. Delegation
+25 -6
View File
@@ -1,20 +1,39 @@
use std::process::Command; use std::process::Command;
fn main() { fn main() {
let git_hash = Command::new("git") let mut git_hash = Command::new("git")
.args(&["rev-parse", "--short", "HEAD"]) .args(["rev-parse", "--short", "HEAD"])
.output() .output()
.ok() .ok()
.and_then(|out| String::from_utf8(out.stdout).ok()) .and_then(|out| String::from_utf8(out.stdout).ok())
.unwrap_or_else(|| "unknown".to_string()); .unwrap_or_else(|| "unknown".to_string());
git_hash = git_hash.trim().to_string();
let is_dirty = Command::new("git")
.args(["status", "--porcelain"])
.output()
.is_ok_and(|out| !out.stdout.is_empty());
if is_dirty {
let dirty_ts = chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string();
git_hash.push_str(&format!("-dirty-{dirty_ts}"));
}
let git_date = Command::new("git") let git_date = Command::new("git")
.args(&["log", "-1", "--format=%cd", "--date=format:%Y.%m.%d"]) .args(["log", "-1", "--format=%cd", "--date=format:%y.%m.%d.%H%M%S"])
.output() .output()
.ok() .ok()
.and_then(|out| String::from_utf8(out.stdout).ok()) .and_then(|out| String::from_utf8(out.stdout).ok())
.unwrap_or_else(|| "unknown".to_string()); .map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string());
let version = format!("{} ({} {})", env!("CARGO_PKG_VERSION"), git_date.trim(), git_hash.trim()); let version = format!("v{git_date} ({git_hash})");
println!("cargo:rustc-env=APP_VERSION={}", version); println!("cargo:rustc-env=APP_VERSION={version}");
println!("cargo:rerun-if-changed=../.git/HEAD");
println!("cargo:rerun-if-changed=../.git/index");
println!("cargo:rerun-if-changed=src");
println!("cargo:rerun-if-changed=Cargo.toml");
println!("cargo:rerun-if-changed=build.rs");
} }
+85 -4
View File
@@ -1,5 +1,9 @@
-- gemini-integration.lua -- gemini-integration.lua
-- Registers the active Neovim instance for the Antigravity MCP Server -- Registers the active Neovim instance for the Antigravity MCP Server
local last_active_task = nil
local last_active_gate = nil
vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePost"}, { vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePost"}, {
callback = function(ev) callback = function(ev)
-- Only register if Neovim has an active UI attached (ignore headless/embedded instances) -- Only register if Neovim has an active UI attached (ignore headless/embedded instances)
@@ -18,7 +22,7 @@ vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePos
end end
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 file = vim.api.nvim_buf_get_name(0)
local cursor = vim.api.nvim_win_get_cursor(0) local cursor = vim.api.nvim_win_get_cursor(0)
local row, col = cursor[1], cursor[2] local row, col = cursor[1], cursor[2]
@@ -26,6 +30,7 @@ vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePos
'{"session_id": "%s", "event": "%s", "file": "%s", "line": %d, "col": %d}', '{"session_id": "%s", "event": "%s", "file": "%s", "line": %d, "col": %d}',
server_name:gsub("\\", "\\\\"), ev.event, file:gsub("\\", "\\\\"), row, col server_name:gsub("\\", "\\\\"), ev.event, file:gsub("\\", "\\\\"), row, col
) )
local current_buf = ev.buf local current_buf = ev.buf
vim.fn.jobstart({ vim.fn.jobstart({
"curl", "-s", "-X", "POST", "http://127.0.0.1:3000/nvim/telemetry", "curl", "-s", "-X", "POST", "http://127.0.0.1:3000/nvim/telemetry",
@@ -38,11 +43,16 @@ vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePos
local raw = table.concat(data, "") local raw = table.concat(data, "")
if raw ~= "" then if raw ~= "" then
local ok, resp = pcall(vim.fn.json_decode, raw) 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() 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) _G.gemini.project_knowledge(resp.tech_debts, resp.adrs, current_buf)
end end
if _G.gemini and _G.gemini.update_statusline then
last_active_task = resp.active_task
last_active_gate = resp.active_gate
_G.gemini.update_statusline(resp.active_task, resp.active_gate)
end
end) end)
end end
end end
@@ -50,7 +60,6 @@ vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePos
end, end,
}) })
-- 3. UDP Mirroring (Native fast sync)
local uv = vim.uv or vim.loop local uv = vim.uv or vim.loop
local client = uv.new_udp() local client = uv.new_udp()
if client then if client then
@@ -64,6 +73,78 @@ vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePos
desc = "Register active Neovim instance for Gemini CLI", desc = "Register active Neovim instance for Gemini CLI",
}) })
-- 3. Push-driven Server-Sent Events (SSE) subscriber (Zero Polling)
local sse_job_id = nil
local function start_sse_subscriber()
if sse_job_id then
pcall(vim.fn.jobstop, sse_job_id)
sse_job_id = nil
end
sse_job_id = vim.fn.jobstart({
"curl", "-s", "-N", "http://127.0.0.1:3000/api/events/stream"
}, {
on_stdout = function(_, data)
if not data then return end
for _, line in ipairs(data) do
if line:match("^data:%s*") then
local raw_json = line:gsub("^data:%s*", "")
local ok, ev = pcall(vim.fn.json_decode, raw_json)
if ok and ev and ev.payload then
local topic = ev.topic
local payload = ev.payload
local changed = false
if topic == "task:event" then
if payload.active_task ~= nil then
last_active_task = payload.active_task
changed = true
elseif payload.status == "in_progress" or payload.status == "active" then
last_active_task = payload.title or payload.task_id
changed = true
elseif payload.status == "completed" or payload.status == "expired" or payload.status == "failed" then
if last_active_task == (payload.title or payload.task_id) then
last_active_task = nil
changed = true
end
end
elseif topic == "gate:event" then
if payload.active_gate ~= nil then
last_active_gate = payload.active_gate
changed = true
elseif payload.status then
last_active_gate = payload.status
changed = true
end
end
if changed and _G.gemini and _G.gemini.update_statusline then
vim.schedule(function()
_G.gemini.update_statusline(last_active_task, last_active_gate)
end)
end
end
end
end
end,
on_exit = function()
sse_job_id = nil
-- Reconnect after server restart without busy looping
vim.defer_fn(function()
if #vim.api.nvim_list_uis() > 0 then
start_sse_subscriber()
end
end, 5000)
end,
})
end
-- Initialize SSE push listener when UI is active
if #vim.api.nvim_list_uis() > 0 then
start_sse_subscriber()
end
-- Load UI & Keymaps -- Load UI & Keymaps
local is_win = vim.fn.has("win32") == 1 local is_win = vim.fn.has("win32") == 1
local ui_path = is_win and "C:/Users/reazul.ashraf/workspace/rust/mcp-memory/gemini-ui.lua" or "/mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory/gemini-ui.lua" local ui_path = is_win and "C:/Users/reazul.ashraf/workspace/rust/mcp-memory/gemini-ui.lua" or "/mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory/gemini-ui.lua"
+44
View File
@@ -348,4 +348,48 @@ function M.send_to_terminal(command)
end end
end end
-- ============================================================================
-- Antigravity Statusline Integration (Push-driven / Zero-polling)
-- ============================================================================
local statusline_cache = {
text = ""
}
function M.update_statusline(active_task, active_gate)
local parts = {}
if active_task and active_task ~= vim.NIL and active_task ~= "" then
local title = tostring(active_task)
if #title > 24 then
title = title:sub(1, 22) .. ".."
end
table.insert(parts, "📋 " .. title)
end
if active_gate and active_gate ~= vim.NIL and active_gate ~= "" then
local status = tostring(active_gate):lower()
if status == "authorized" then
table.insert(parts, "🟢 PUSH:OK")
elseif status == "blocked" then
table.insert(parts, "🔴 PUSH:BLOCKED")
else
table.insert(parts, "🟡 PUSH:WAIT")
end
end
if #parts > 0 then
statusline_cache.text = " [AGY: " .. table.concat(parts, " | ") .. "] "
else
statusline_cache.text = ""
end
vim.schedule(function()
pcall(vim.cmd, "redrawstatus")
end)
end
function M.statusline()
return statusline_cache.text
end
return M return M
+6 -6
View File
@@ -28,7 +28,7 @@ deploy-win: deploy-server deploy-stub-win deploy-nvim-win deploy-rules-win
# Stop the background server gracefully and verify port release # Stop the background server gracefully and verify port release
stop port="3000": stop port="3000":
@Write-Host "Shutting down MCP server gracefully on port {{port}}..." -ForegroundColor Cyan @Write-Host "Shutting down MCP server gracefully on port {{port}}..." -ForegroundColor Cyan
-& { $token = Get-Content "$env:USERPROFILE\.gemini\mcp_memory\admin.token" -ErrorAction SilentlyContinue; if ($token) { $hdr = @{ Authorization = "Bearer $($token.Trim())" }; $res = Invoke-RestMethod -Uri "http://127.0.0.1:{{port}}/shutdown" -Method Post -Headers $hdr -ErrorAction SilentlyContinue } else { $res = Invoke-RestMethod -Uri "http://127.0.0.1:{{port}}/shutdown" -Method Post -ErrorAction SilentlyContinue }; if ($res) { Write-Host "✅ Shutdown acknowledged with version details:" -ForegroundColor Green; $res | ConvertTo-Json -Depth 5 }; $stopped = $false; for ($i = 1; $i -le 10; $i++) { $conn = Get-NetTCPConnection -LocalPort {{port}} -State Listen -ErrorAction SilentlyContinue; $proc = Get-Process mcp-memory-server -ErrorAction SilentlyContinue; if (-not $conn -and -not $proc) { $stopped = $true; Write-Host '✅ Server process and port {{port}} fully terminated.' -ForegroundColor Green; break }; Start-Sleep -Seconds 1 }; if (-not $stopped) { Write-Host '⚠️ Server process did not exit within 10s. Force killing process...' -ForegroundColor Yellow; Get-Process mcp-memory-server -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue; Get-NetTCPConnection -LocalPort {{port}} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue }; Start-Sleep -Seconds 2 } } -& { $token = Get-Content "$env:USERPROFILE\.gemini\mcp_memory\admin.token" -ErrorAction SilentlyContinue; if ($token) { $hdr = @{ Authorization = "Bearer $($token.Trim())" }; $res = Invoke-RestMethod -Uri "http://127.0.0.1:{{port}}/shutdown" -Method Post -Headers $hdr -ErrorAction SilentlyContinue } else { $res = Invoke-RestMethod -Uri "http://127.0.0.1:{{port}}/shutdown" -Method Post -ErrorAction SilentlyContinue }; if ($res) { Write-Host "✅ Shutdown acknowledged with version details:" -ForegroundColor Green; $res | ConvertTo-Json -Depth 5 }; $procs = @(Get-Process mcp-memory-server -ErrorAction SilentlyContinue); if ($procs.Count -gt 0) { foreach ($p in $procs) { $exited = $p.WaitForExit(10000); if (-not $exited) { Write-Host "⚠️ Process $($p.Id) did not exit within 10s. Force killing..." -ForegroundColor Yellow; $p | Stop-Process -Force -ErrorAction SilentlyContinue } } }; $conns = Get-NetTCPConnection -LocalPort {{port}} -State Listen -ErrorAction SilentlyContinue; if ($conns) { $conns | Select-Object -ExpandProperty OwningProcess | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue } } }
@Write-Host "✅ Shutdown complete. Port {{port}} and process are free." -ForegroundColor Green @Write-Host "✅ Shutdown complete. Port {{port}} and process are free." -ForegroundColor Green
# Start the server in the background and verify /health # Start the server in the background and verify /health
@@ -125,12 +125,12 @@ deploy-wsl: deploy-stub deploy-nvim deploy-rules-wsl
# Deploy WSL-native stub binary (skips if identical checksum) # Deploy WSL-native stub binary (skips if identical checksum)
deploy-stub: deploy-stub:
@Write-Host "Deploying WSL Stub natively..." -ForegroundColor Cyan @Write-Host "Deploying WSL Stub natively..." -ForegroundColor Cyan
wsl.exe -d Ubuntu -e bash -c 'export PATH="/home/riz/.cargo/bin:$PATH" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && src="target/release/mcp-memory-stub" && dest="/home/riz/.local/bin/mcp-memory-stub" && if [ -f "$dest" ] && cmp -s "$src" "$dest"; then echo -e "\033[33mSkipping $dest (identical checksum).\033[0m"; else cp "$src" "$dest" && echo -e "\033[32mCopied updated $src to $dest\033[0m"; fi' wsl.exe -d Ubuntu -e bash -c 'export PATH="/home/riz/.cargo/bin:$PATH" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && src="target/release/mcp-memory-stub" && dest="/home/riz/.local/bin/mcp-memory-stub" && if [ -f "$dest" ] && cmp -s "$src" "$dest"; then echo -e "\033[33mSkipping $dest (identical checksum).\033[0m"; else cp --remove-destination "$src" "$dest" && echo -e "\033[32mCopied updated $src to $dest\033[0m"; fi'
# Deploy WSL-native Neovim plugin binary (skips if identical checksum) # Deploy WSL-native Neovim plugin binary (skips if identical checksum)
deploy-nvim: deploy-nvim:
@Write-Host "Deploying WSL Nvim natively..." -ForegroundColor Cyan @Write-Host "Deploying WSL Nvim natively..." -ForegroundColor Cyan
wsl.exe -d Ubuntu -e bash -c 'export PATH="/home/riz/.cargo/bin:$PATH" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && src="target/release/mcp-memory-nvim" && dest="/home/riz/.local/bin/mcp-memory-nvim" && if [ -f "$dest" ] && cmp -s "$src" "$dest"; then echo -e "\033[33mSkipping $dest (identical checksum).\033[0m"; else cp "$src" "$dest" && echo -e "\033[32mCopied updated $src to $dest\033[0m"; fi' wsl.exe -d Ubuntu -e bash -c 'export PATH="/home/riz/.cargo/bin:$PATH" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && src="target/release/mcp-memory-nvim" && dest="/home/riz/.local/bin/mcp-memory-nvim" && if [ -f "$dest" ] && cmp -s "$src" "$dest"; then echo -e "\033[33mSkipping $dest (identical checksum).\033[0m"; else cp --remove-destination "$src" "$dest" && echo -e "\033[32mCopied updated $src to $dest\033[0m"; fi'
# Deploy global Agent rules to WSL config directory (skips identical files) # Deploy global Agent rules to WSL config directory (skips identical files)
deploy-rules-wsl: deploy-rules-wsl:
@@ -191,8 +191,8 @@ all-nvim-wsl: build-nvim deploy-nvim
# Complete lifecycle: stop, build, deploy, start, and verify WSL server # Complete lifecycle: stop, build, deploy, start, and verify WSL server
all-server-wsl port="3000": (stop port) build-server deploy-server (start port) (verify port) all-server-wsl port="3000": (stop port) build-server deploy-server (start port) (verify port)
# Install git post-commit hook for automatic ADR/Task/TechDebt state reconciliation # Install git hooks (post-commit reconciliation and pre-push gatekeeper)
install-git-hooks: install-git-hooks:
@Write-Host "Installing git post-commit reconciliation hook..." -ForegroundColor Cyan @Write-Host "Installing git post-commit and pre-push hooks..." -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 } -& { $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 }
+3
View File
@@ -17,3 +17,6 @@ mcp-stdio = { version = "1.0.0", path = "../mcp-stdio" }
bytes = "1.12.1" bytes = "1.12.1"
dashmap = "6.2.1" dashmap = "6.2.1"
[build-dependencies]
chrono = "0.4.45"
+20 -3
View File
@@ -1,22 +1,39 @@
use std::process::Command; use std::process::Command;
fn main() { fn main() {
let git_hash = Command::new("git") let mut git_hash = Command::new("git")
.args(["rev-parse", "--short", "HEAD"]) .args(["rev-parse", "--short", "HEAD"])
.output() .output()
.ok() .ok()
.and_then(|out| String::from_utf8(out.stdout).ok()) .and_then(|out| String::from_utf8(out.stdout).ok())
.unwrap_or_else(|| "unknown".to_string()); .unwrap_or_else(|| "unknown".to_string());
git_hash = git_hash.trim().to_string();
let is_dirty = Command::new("git")
.args(["status", "--porcelain"])
.output()
.is_ok_and(|out| !out.stdout.is_empty());
if is_dirty {
let dirty_ts = chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string();
git_hash.push_str(&format!("-dirty-{dirty_ts}"));
}
let git_date = Command::new("git") let git_date = Command::new("git")
.args(["log", "-1", "--format=%cd", "--date=format:%y.%m.%d.%H%M%S"]) .args(["log", "-1", "--format=%cd", "--date=format:%y.%m.%d.%H%M%S"])
.output() .output()
.ok() .ok()
.and_then(|out| String::from_utf8(out.stdout).ok()) .and_then(|out| String::from_utf8(out.stdout).ok())
.unwrap_or_else(|| "unknown".to_string()); .map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string());
let version = format!("{} ({})", git_date.trim(), git_hash.trim()); let version = format!("v{git_date} ({git_hash})");
println!("cargo:rustc-env=APP_VERSION={version}"); println!("cargo:rustc-env=APP_VERSION={version}");
println!("cargo:rerun-if-changed=../.git/HEAD"); println!("cargo:rerun-if-changed=../.git/HEAD");
println!("cargo:rerun-if-changed=../.git/index"); println!("cargo:rerun-if-changed=../.git/index");
println!("cargo:rerun-if-changed=src");
println!("cargo:rerun-if-changed=Cargo.toml");
println!("cargo:rerun-if-changed=build.rs");
} }
+97 -6
View File
@@ -319,6 +319,7 @@ static PENDING_REQUESTS: LazyLock<
pub struct NvimState { pub struct NvimState {
pub cursor: String, pub cursor: String,
pub active_buffer_id: u64, pub active_buffer_id: u64,
pub active_buffer_name: String,
} }
static NVIM_STATE: LazyLock<Arc<std::sync::Mutex<NvimState>>> = static NVIM_STATE: LazyLock<Arc<std::sync::Mutex<NvimState>>> =
@@ -341,8 +342,27 @@ fn handle_nvim_notification(params: &[rmpv::Value]) {
state.cursor = format!("Line: {}, Column: {}", row, col); state.cursor = format!("Line: {}, Column: {}", row, col);
} }
} }
"TextChanged" => { "TextChanged" | "BufEnter" => {
tracing::debug!("Shadow buffer text changed event received"); if params.len() > 1 {
let buf_id = match &params[1] {
rmpv::Value::Integer(i) => i.as_u64().unwrap_or(0),
_ => 0,
};
let buf_name = if params.len() > 2 {
match &params[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<mpsc::Sender<NvimRequest>, String> {
vim.api.nvim_create_autocmd({"TextChanged", "TextChangedI", "BufEnter"}, { vim.api.nvim_create_autocmd({"TextChanged", "TextChangedI", "BufEnter"}, {
group = "MCP_Tracking", group = "MCP_Tracking",
callback = function() 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 end
}) })
"#; "#;
@@ -1686,7 +1708,7 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
bufnr = 0 bufnr = 0
end end
_G.gemini.preview_diff(diff_id, bufnr, start_line, end_line, lines) _G.gemini.preview_diff(diff_id, bufnr, start_line, end_line, lines)
return "Preview added" return { status = "Preview added", diff_id = diff_id }
else else
error("gemini UI not loaded") error("gemini UI not loaded")
end 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; 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<rmpv::Value> = 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" => { "extmark" => {
if let (Some(line), Some(text)) = ( if let (Some(line), Some(text)) = (
args.get("line").and_then(serde_json::Value::as_i64), args.get("line").and_then(serde_json::Value::as_i64),
@@ -1996,8 +2073,22 @@ mod tests {
]), ]),
]; ];
handle_nvim_notification(&params); handle_nvim_notification(&params);
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] #[tokio::test]
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""
Pre-Push Gatekeeper for Git and MCP Memory.
Enforces workspace quality gates:
1. Allows tag pushes and branch deletions without verification.
2. Bypasses tests for docs/config-only modifications (.md, .txt, .png, justfile, etc.).
3. Queries mcp-memory gate API (/gate/verify?action=push&target=<branch>).
4. If no pre-authorized gate is found, runs project tests locally to verify 100% pass before allowing push.
"""
import os
import sys
import json
import subprocess
import urllib.request
import urllib.error
DOC_EXTENSIONS = {
".md", ".txt", ".png", ".jpg", ".jpeg", ".pdf", ".svg",
".drawio", ".gif", ".ico", ".csv"
}
DOC_FILENAMES = {
"justfile", "jenkinsfile", "dockerfile", "license",
"changelog", ".gitignore", ".gitattributes"
}
def is_doc_or_config(filepath: str) -> bool:
base = os.path.basename(filepath).lower()
if base in DOC_FILENAMES:
return True
ext = os.path.splitext(base)[1].lower()
return ext in DOC_EXTENSIONS
def run_git(cmd):
try:
res = subprocess.run(["git"] + cmd, capture_output=True, text=True, check=True)
return res.stdout.strip()
except Exception:
return ""
def check_mcp_gate(port: str, branch: str):
"""
Returns (status_code, reason)
status_code: 200 (Authorized), 403 (Blocked), 404 (Not found), 0 (Server unreachable)
"""
url = f"http://127.0.0.1:{port}/gate/verify?action=push&target={branch}"
try:
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=1.5) as resp:
return resp.status, "Authorized"
except urllib.error.HTTPError as e:
body = ""
try:
body = e.read().decode("utf-8")
except Exception:
pass
return e.code, body
except Exception:
return 0, "Server unreachable"
def set_mcp_gate(port: str, branch: str, authorize: bool, reason: str):
url = f"http://127.0.0.1:{port}/gate/set"
payload = {
"action": "push",
"target": branch,
"authorize": authorize,
"reason": reason
}
try:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(req, timeout=1.5):
pass
except Exception:
pass
def post_mcp_event(port: str, topic: str, payload: dict):
url = f"http://127.0.0.1:{port}/api/events/post"
try:
data = json.dumps({"topic": topic, "payload": payload}).encode("utf-8")
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(req, timeout=1.0):
pass
except Exception:
pass
def wait_for_mcp_decision(port: str, topic: str, timeout_secs: int = 2):
"""Waits for an operator or auditor decision on the event bus."""
url = f"http://127.0.0.1:{port}/events/wait?topic={topic}&timeout={timeout_secs}"
try:
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=timeout_secs + 0.5) as resp:
data = json.loads(resp.read().decode("utf-8"))
payload = data.get("payload", {})
status = str(payload.get("status", "")).lower()
reason = payload.get("reason", "Decision received via event bus")
if status in ("authorized", "approved", "ok", "true"):
return True, reason
elif status in ("blocked", "denied", "rejected", "false"):
return False, reason
except Exception:
pass
return None, ""
def run_local_tests() -> bool:
# 1. Rust workspace
if os.path.exists("Cargo.toml"):
print("\033[36m[Pre-Push Gatekeeper]\033[0m Running cargo test...")
cmd = ["cargo", "test", "--workspace"]
res = subprocess.run(cmd)
return res.returncode == 0
# 2. Python workspace (uv / pytest)
elif os.path.exists("pyproject.toml") or os.path.exists("setup.py"):
print("\033[36m[Pre-Push Gatekeeper]\033[0m Running python tests...")
cmd = ["uv", "run", "pytest"] if os.system("uv --version >nul 2>&1") == 0 else ["pytest"]
res = subprocess.run(cmd)
return res.returncode == 0
return True
def main():
# Read stdin passed by git pre-push: <local ref> <local sha> <remote ref> <remote sha>
lines = sys.stdin.read().splitlines()
if not lines:
sys.exit(0)
port = os.environ.get("MCP_PORT", "3000")
for line in lines:
parts = line.strip().split()
if len(parts) < 4:
continue
local_ref, local_sha, remote_ref, remote_sha = parts[:4]
# 1. Skip deletions
if local_sha == "0000000000000000000000000000000000000000" or local_ref == "(delete)":
continue
# 2. Skip tags
if local_ref.startswith("refs/tags/"):
print(f"\033[33m[Pre-Push Gatekeeper]\033[0m Tag push detected ({local_ref}). Quality gate bypassed.")
continue
# Extract branch name
branch = local_ref.replace("refs/heads/", "")
# 3. Check for documentation / config-only changes
zero_sha = "0000000000000000000000000000000000000000"
diff_range = local_sha if remote_sha == zero_sha else f"{remote_sha}..{local_sha}"
changed_files_raw = run_git(["diff", "--name-only", diff_range])
changed_files = [f.strip() for f in changed_files_raw.splitlines() if f.strip()]
if changed_files and all(is_doc_or_config(f) for f in changed_files):
print(f"\033[33m[Pre-Push Gatekeeper]\033[0m All modified files are docs/config-only. Quality gate bypassed.")
continue
# 4. Query MCP Memory gate status
status, reason = check_mcp_gate(port, branch)
if status == 200:
print(f"\033[32m[Pre-Push Gatekeeper]\033[0m \u2714 Push AUTHORIZED by MCP Memory gate for branch '{branch}'.")
continue
elif status == 403:
print(f"\033[31m[Pre-Push Gatekeeper]\033[0m \u274c Push BLOCKED by MCP Memory gate for branch '{branch}'!")
if reason:
print(f" Reason: {reason}")
sys.exit(1)
# 5. No gate found (404 or server offline) -> Request interactive approval or verify unit tests
if status != 0:
post_mcp_event(port, "gate:approval_requested", {
"branch": branch,
"local_sha": local_sha,
"remote_sha": remote_sha,
"changed_files_count": len(changed_files)
})
# Fast check: Did dashboard / operator / auditor approve or reject via event bus?
decision, dec_reason = wait_for_mcp_decision(port, f"gate:decision:{branch}", timeout_secs=1)
if decision is True:
print(f"\033[32m[Pre-Push Gatekeeper]\033[0m \u2714 Push INTERACTIVELY AUTHORIZED via event bus: {dec_reason}")
set_mcp_gate(port, branch, True, dec_reason)
post_mcp_event(port, "gate:event", {"status": "authorized", "active_gate": "authorized", "branch": branch})
continue
elif decision is False:
print(f"\033[31m[Pre-Push Gatekeeper]\033[0m \u274c Push BLOCKED by operator: {dec_reason}")
set_mcp_gate(port, branch, False, dec_reason)
post_mcp_event(port, "gate:event", {"status": "blocked", "active_gate": "blocked", "branch": branch})
sys.exit(1)
print(f"\033[33m[Pre-Push Gatekeeper]\033[0m No pre-authorized gate record for '{branch}'. Verifying unit tests...")
if run_local_tests():
print(f"\033[32m[Pre-Push Gatekeeper]\033[0m \u2714 Unit tests passed 100%. Push authorized.")
if status != 0:
set_mcp_gate(port, branch, True, "Unit tests passed locally via pre-push gatekeeper")
post_mcp_event(port, "gate:event", {"status": "authorized", "active_gate": "authorized", "branch": branch})
else:
print(f"\033[31m[Pre-Push Gatekeeper]\033[0m \u274c Push BLOCKED! Unit tests failed locally. Fix failing tests before pushing.")
if status != 0:
set_mcp_gate(port, branch, False, "Unit tests failed during pre-push gatekeeper check")
post_mcp_event(port, "gate:event", {"status": "blocked", "active_gate": "blocked", "branch": branch})
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
# Native shell pre-push hook using UDP Rendezvous
PORT=${MCP_UDP_PORT1:-3001}
while read local_ref local_sha remote_ref remote_sha; do
if [ "$local_sha" = "0000000000000000000000000000000000000000" ] || [ "$local_ref" = "(delete)" ]; then
continue
fi
if [[ "$local_ref" == refs/tags/* ]]; then
continue
fi
BRANCH=${local_ref#refs/heads/}
echo -e "\033[36m[Pre-Push Gatekeeper]\033[0m Checking gate for branch '$BRANCH'..."
# Check if gate is already authorized or blocked
if mcp-memory-server gate verify --action push --target "$BRANCH" --consume 2>/dev/null; then
echo -e "\033[32m[Pre-Push Gatekeeper]\033[0m Push AUTHORIZED by MCP Memory gate for branch '$BRANCH'."
continue
else
EXIT_CODE=$?
if [ $EXIT_CODE -eq 1 ]; then
echo -e "\033[31m[Pre-Push Gatekeeper]\033[0m Push BLOCKED by MCP Memory gate for branch '$BRANCH'."
exit 1
fi
# If exit code is 2 (Not Found), we wait via UDP Rendezvous
echo -e "\033[33m[Pre-Push Gatekeeper]\033[0m No pre-authorized gate for '$BRANCH'. Waiting for approval via UDP rendezvous..."
# Payload for UDP
PAYLOAD="{\"type\":\"gate_wait\",\"action\":\"push\",\"target\":\"$BRANCH\"}"
if udp-send --wait "$PORT" "$PAYLOAD"; then
echo -e "\033[32m[Pre-Push Gatekeeper]\033[0m Push AUTHORIZED via UDP rendezvous for branch '$BRANCH'."
continue
else
echo -e "\033[31m[Pre-Push Gatekeeper]\033[0m Push DENIED or timed out via UDP rendezvous for branch '$BRANCH'."
exit 1
fi
fi
done
exit 0
+15 -1
View File
@@ -48,6 +48,7 @@ ignore = "0.4.33"
notify = "7" notify = "7"
blake3 = "1.8" blake3 = "1.8"
tokio-stream = { version = "0.1", features = ["sync"] } tokio-stream = { version = "0.1", features = ["sync"] }
chrono = { version = "0.4.45", features = ["serde"] }
[build-dependencies] [build-dependencies]
chrono = "0.4.45" chrono = "0.4.45"
@@ -58,4 +59,17 @@ tempfile = "3.27.0"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59.0", features = ["Win32_System_DataExchange", "Win32_System_Memory"] } windows-sys = { version = "0.59.0", features = [
"Win32_System_DataExchange",
"Win32_System_Memory",
"Win32_UI_WindowsAndMessaging",
"Win32_Foundation",
"Win32_System_Com",
] }
windows = { version = "0.62.2", features = [
"Foundation",
"Foundation_Collections",
"Media_Ocr",
"Graphics_Imaging",
"Storage_Streams",
] }
+14 -3
View File
@@ -16,15 +16,26 @@ fn main() {
.is_ok_and(|out| !out.stdout.is_empty()); .is_ok_and(|out| !out.stdout.is_empty());
if is_dirty { if is_dirty {
git_hash.push_str("-dirty"); let dirty_ts = chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string();
git_hash.push_str(&format!("-dirty-{dirty_ts}"));
} }
let build_date = chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string(); let git_date = Command::new("git")
.args(["log", "-1", "--format=%cd", "--date=format:%y.%m.%d.%H%M%S"])
.output()
.ok()
.and_then(|out| String::from_utf8(out.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string());
let version = format!("{build_date} ({git_hash})"); let version = format!("v{git_date} ({git_hash})");
println!("cargo:rustc-env=APP_VERSION={version}"); println!("cargo:rustc-env=APP_VERSION={version}");
println!("cargo:rerun-if-changed=../.git/HEAD"); println!("cargo:rerun-if-changed=../.git/HEAD");
println!("cargo:rerun-if-changed=../.git/index"); println!("cargo:rerun-if-changed=../.git/index");
println!("cargo:rerun-if-changed=src");
println!("cargo:rerun-if-changed=Cargo.toml");
println!("cargo:rerun-if-changed=build.rs");
if std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default() == "windows" { if std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default() == "windows" {
let mut res = winres::WindowsResource::new(); let mut res = winres::WindowsResource::new();
+66 -19
View File
@@ -14,33 +14,80 @@ pub async fn wait_for_event_handler(
) -> impl IntoResponse { ) -> impl IntoResponse {
let topic = params.get("topic").cloned(); let topic = params.get("topic").cloned();
let session_id = params.get("session_id").cloned(); let session_id = params.get("session_id").cloned();
let timeout_duration = params
.get("timeout_ms")
.and_then(|ms| ms.parse::<u64>().ok())
.map(tokio::time::Duration::from_millis)
.or_else(|| {
params
.get("timeout")
.and_then(|secs| secs.parse::<u64>().ok())
.map(tokio::time::Duration::from_secs)
});
let mut rx = state.handler.state.event_bus_tx.subscribe(); let mut rx = state.handler.state.event_bus_tx.subscribe();
loop { let wait_future = async {
match rx.recv().await { loop {
Ok(event) => { match rx.recv().await {
let topic_matches = topic.as_ref().is_none_or(|t| t == &event.topic); Ok(event) => {
let session_matches = session_id let topic_matches = topic.as_ref().is_none_or(|t| t == &event.topic);
.as_ref() let session_matches = session_id
.is_none_or(|s| Some(s) == event.session_id.as_ref()); .as_ref()
.is_none_or(|s| Some(s) == event.session_id.as_ref());
if topic_matches && session_matches { if topic_matches && session_matches {
return axum::Json(event); return Ok(event);
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
tracing::warn!("Event bus receiver lagged by {} messages; continuing wait.", skipped);
continue;
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
return Err("Event bus closed");
} }
} }
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { }
tracing::warn!("Event bus receiver lagged by {} messages; continuing wait.", skipped); };
continue;
} match timeout_duration {
Err(tokio::sync::broadcast::error::RecvError::Closed) => { Some(dur) => match tokio::time::timeout(dur, wait_future).await {
return axum::Json(GenericEvent { Ok(Ok(event)) => (axum::http::StatusCode::OK, axum::Json(event)).into_response(),
Ok(Err(err)) => (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(GenericEvent {
topic: "error".to_string(), topic: "error".to_string(),
session_id: None, session_id: None,
payload: serde_json::json!({"error": "Event bus closed"}), payload: serde_json::json!({ "error": err }),
}); }),
} )
} .into_response(),
Err(_) => (
axum::http::StatusCode::REQUEST_TIMEOUT,
axum::Json(GenericEvent {
topic: "timeout".to_string(),
session_id: None,
payload: serde_json::json!({
"error": "timed out waiting for event",
"topic": topic
}),
}),
)
.into_response(),
},
None => match wait_future.await {
Ok(event) => (axum::http::StatusCode::OK, axum::Json(event)).into_response(),
Err(err) => (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(GenericEvent {
topic: "error".to_string(),
session_id: None,
payload: serde_json::json!({ "error": err }),
}),
)
.into_response(),
},
} }
} }
+22 -1
View File
@@ -104,9 +104,30 @@ pub async fn gate_set_handler(
}; };
app_state.handler.state.env.gates.modify(|gates| { app_state.handler.state.env.gates.modify(|gates| {
gates.retain(|g| !(g.action == record.action && g.target == record.target)); gates.retain(|g| !(g.action == record.action && g.target == record.target));
gates.push(record); gates.push(record.clone());
}); });
let summary = format!("Push gate {} set to {}", record.target, record.status);
app_state
.handler
.state
.record_activity("gate", &summary, record.reason.as_deref());
let _ = app_state
.handler
.state
.event_bus_tx
.send(crate::state::GenericEvent {
topic: "gate:event".to_string(),
session_id: None,
payload: serde_json::json!({
"action": record.action,
"target": record.target,
"status": record.status,
"reason": record.reason,
"active_gate": record.status,
}),
});
Ok((axum::http::StatusCode::OK, "Gate state updated.")) Ok((axum::http::StatusCode::OK, "Gate state updated."))
} }
+257 -18
View File
@@ -6,7 +6,7 @@ use axum::{
Router, Router,
extract::State, extract::State,
response::IntoResponse, response::IntoResponse,
routing::{get, post}, routing::{delete, get, post},
}; };
use std::sync::Arc; use std::sync::Arc;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
@@ -123,20 +123,8 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
} }
tracing::info!( tracing::info!(
"Received shutdown request via /shutdown endpoint. Creating session checkpoint and initiating graceful shutdown." "Received shutdown request via /shutdown endpoint. Initiating graceful shutdown."
); );
let mem_state = state.handler.state.clone();
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
let memo = crate::models::HandoffMemo {
id: format!("chk_shutdown_{}", now),
author: "ShutdownHook".to_string(),
content: format!("Automatic session checkpoint triggered on graceful server shutdown at epoch {}", now),
expires_at: None,
namespace: "global".to_string(),
timestamp: now,
..Default::default()
};
mem_state.telemetry.handoff_memos.modify(|m| m.push(memo));
if let Some(tx) = state.shutdown_tx.lock().unwrap().take() { if let Some(tx) = state.shutdown_tx.lock().unwrap().take() {
let _ = tx.send(()); let _ = tx.send(());
@@ -154,8 +142,7 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
"app_version": env!("APP_VERSION"), "app_version": env!("APP_VERSION"),
"cargo_pkg_version": env!("CARGO_PKG_VERSION"), "cargo_pkg_version": env!("CARGO_PKG_VERSION"),
"target_os": std::env::consts::OS, "target_os": std::env::consts::OS,
"target_arch": std::env::consts::ARCH, "target_arch": std::env::consts::ARCH
"shutdown_checkpoint": format!("chk_shutdown_{}", now)
} }
})).into_response() })).into_response()
}, },
@@ -309,6 +296,30 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
} }
}), }),
) )
.route(
"/api/tech_debts/{id}/resolve",
post({
let state_clone = app_state.handler.state.clone();
move |axum::extract::Path(id): axum::extract::Path<String>| async move {
let mut found = false;
state_clone.code.tech_debts.modify(|debts| {
for d in debts.iter_mut() {
if d.id == id {
d.is_resolved = true;
found = true;
break;
}
}
});
if found {
state_clone.record_activity("tech_debt", &format!("Resolved tech debt: {}", id), Some("resolved"));
(axum::http::StatusCode::OK, axum::Json(serde_json::json!({ "status": "success", "message": format!("Tech debt {} resolved", id) })))
} else {
(axum::http::StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "status": "error", "message": "Tech debt not found" })))
}
}
}),
)
.route( .route(
"/api/adrs", "/api/adrs",
get({ get({
@@ -398,6 +409,139 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
} }
}), }),
) )
.route(
"/api/env_fingerprints",
get({
let state_clone = app_state.handler.state.clone();
move || async move {
let json = state_clone.env.env_fingerprints.read_with(|items| {
let values: Vec<_> = items.values().cloned().collect();
serde_json::to_string(&values).unwrap_or_else(|_| "[]".to_string())
});
([(axum::http::header::CONTENT_TYPE, "application/json")], json)
}
}),
)
.route(
"/api/env_requirements",
get({
let state_clone = app_state.handler.state.clone();
move || async move {
let json = state_clone.env.env_requirements.read_with(|items| {
serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string())
});
([(axum::http::header::CONTENT_TYPE, "application/json")], json)
}
}),
)
.route(
"/api/environments",
get({
let state_clone = app_state.handler.state.clone();
move || async move {
let json = state_clone.env.environments.read_with(|items| {
serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string())
});
([(axum::http::header::CONTENT_TYPE, "application/json")], json)
}
}),
)
.route(
"/api/gates",
get({
let state_clone = app_state.handler.state.clone();
move || async move {
let json = state_clone.env.gates.read_with(|items| {
serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string())
});
([(axum::http::header::CONTENT_TYPE, "application/json")], json)
}
}),
)
.route(
"/api/gates/{id}/authorize",
post({
let state_clone = app_state.handler.state.clone();
move |axum::extract::Path(id): axum::extract::Path<String>| async move {
let mut found = false;
state_clone.env.gates.modify(|gates| {
for g in gates.iter_mut() {
if g.id == id {
g.status = "authorized".to_string();
g.reason = Some("Manually authorized via dashboard".to_string());
g.timestamp = crate::handlers::utils::now_secs();
found = true;
break;
}
}
});
if found {
state_clone.record_activity("gate", &format!("Authorized push gate: {}", id), Some("authorized"));
let _ = state_clone.event_bus_tx.send(crate::state::GenericEvent {
topic: "gate:event".to_string(),
session_id: None,
payload: serde_json::json!({ "id": id, "status": "authorized", "active_gate": "authorized" }),
});
(axum::http::StatusCode::OK, axum::Json(serde_json::json!({ "status": "success" })))
} else {
(axum::http::StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "status": "error", "message": "Gate not found" })))
}
}
}),
)
.route(
"/api/gates/{id}/block",
post({
let state_clone = app_state.handler.state.clone();
move |axum::extract::Path(id): axum::extract::Path<String>| async move {
let mut found = false;
state_clone.env.gates.modify(|gates| {
for g in gates.iter_mut() {
if g.id == id {
g.status = "blocked".to_string();
g.reason = Some("Manually blocked via dashboard".to_string());
g.timestamp = crate::handlers::utils::now_secs();
found = true;
break;
}
}
});
if found {
state_clone.record_activity("gate", &format!("Blocked push gate: {}", id), Some("blocked"));
let _ = state_clone.event_bus_tx.send(crate::state::GenericEvent {
topic: "gate:event".to_string(),
session_id: None,
payload: serde_json::json!({ "id": id, "status": "blocked", "active_gate": "blocked" }),
});
(axum::http::StatusCode::OK, axum::Json(serde_json::json!({ "status": "success" })))
} else {
(axum::http::StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "status": "error", "message": "Gate not found" })))
}
}
}),
)
.route(
"/api/gates/{id}",
delete({
let state_clone = app_state.handler.state.clone();
move |axum::extract::Path(id): axum::extract::Path<String>| async move {
let mut found = false;
state_clone.env.gates.modify(|gates| {
let initial_len = gates.len();
gates.retain(|g| g.id != id);
found = gates.len() < initial_len;
});
if found {
state_clone.record_activity("gate", &format!("Deleted push gate: {}", id), Some("deleted"));
let active_gate = state_clone.env.gates.read_with(|gates| gates.iter().find(|g| g.action == "push" || g.gate_type.as_deref() == Some("quality_gate")).map(|g| g.status.clone()));
let _ = state_clone.event_bus_tx.send(crate::state::GenericEvent { topic: "gate:event".to_string(), session_id: None, payload: serde_json::json!({ "id": id, "status": "deleted", "active_gate": active_gate }) });
(axum::http::StatusCode::OK, axum::Json(serde_json::json!({ "status": "success" })))
} else {
(axum::http::StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "status": "error", "message": "Gate not found" })))
}
}
}),
)
.route( .route(
"/api/stats", "/api/stats",
get({ get({
@@ -967,7 +1111,8 @@ mod tests {
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await .await
.unwrap(); .unwrap();
let summaries: Vec<crate::models::SessionSummary> = serde_json::from_slice(&body_bytes).unwrap(); let summaries: Vec<crate::models::SessionSummary> =
serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(summaries.len(), 1); assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].summary, "Session 2 summary"); assert_eq!(summaries[0].summary, "Session 2 summary");
} }
@@ -1002,9 +1147,103 @@ mod tests {
let response = app.oneshot(request).await.unwrap(); let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK); 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.status, "implemented");
assert_eq!(adr.git_commit.as_deref(), Some("git-hash-999")); assert_eq!(adr.git_commit.as_deref(), Some("git-hash-999"));
assert!(adr.resolved_at.is_some()); assert!(adr.resolved_at.is_some());
} }
#[tokio::test]
async fn test_environment_endpoints() {
let (app, app_state, _dir) = setup_app().await;
app_state.handler.state.env.environments.modify(|envs| {
envs.push(crate::models::EnvironmentDetail {
namespace: "default".to_string(),
name: "staging-k8s".to_string(),
url: "https://staging.internal.net".to_string(),
description: "Staging cluster".to_string(),
requires_vpn: true,
updated_at: 12345,
..Default::default()
});
});
let req = Request::builder()
.uri("/api/environments")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let envs: Vec<crate::models::EnvironmentDetail> = serde_json::from_slice(&body).unwrap();
assert_eq!(envs.len(), 1);
assert_eq!(envs[0].name, "staging-k8s");
}
#[tokio::test]
async fn test_interactive_endpoints() {
let (app, app_state, _dir) = setup_app().await;
// 1. Tech Debt resolve
app_state.handler.state.code.tech_debts.modify(|debts| {
debts.push(crate::models::TechDebt {
id: "TD-001".to_string(),
description: "Fix memory leak".to_string(),
is_resolved: false,
..Default::default()
});
});
let req = Request::builder()
.method("POST")
.uri("/api/tech_debts/TD-001/resolve")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let debt = app_state
.handler
.state
.code
.tech_debts
.read_with(|d| d[0].clone());
assert!(debt.is_resolved);
// 2. Gate authorize
app_state.handler.state.env.gates.modify(|gates| {
gates.push(crate::models::GateRecord {
id: "gate-123".to_string(),
action: "push".to_string(),
target: "master".to_string(),
status: "blocked".to_string(),
..Default::default()
});
});
let app2 = create_router(app_state.clone());
let req2 = Request::builder()
.method("POST")
.uri("/api/gates/gate-123/authorize")
.body(Body::empty())
.unwrap();
let resp2 = app2.oneshot(req2).await.unwrap();
assert_eq!(resp2.status(), StatusCode::OK);
let gate = app_state
.handler
.state
.env
.gates
.read_with(|g| g[0].clone());
assert_eq!(gate.status, "authorized");
}
} }
+49 -1
View File
@@ -187,6 +187,36 @@ pub async fn nvim_telemetry_handler(
let _ = state.handler.state.event_bus_tx.send(event); let _ = state.handler.state.event_bus_tx.send(event);
} }
if payload.event.starts_with("agent_") || payload.event.starts_with("diff_") {
let payload_val = serde_json::json!(&payload);
let _ = state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
topic: format!("nvim:ui:{}", payload.event),
session_id: Some(payload.session_id.clone()),
payload: payload_val.clone(),
});
if let Some(ref req_id) = payload.request_id {
let _ = state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
topic: format!("nvim:ui:{}:{}", payload.event, req_id),
session_id: Some(payload.session_id.clone()),
payload: payload_val.clone(),
});
}
if let Some(ref diff_id) = payload.diff_id {
let _ = state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
topic: format!("nvim:ui:{}:{}", payload.event, diff_id),
session_id: Some(payload.session_id.clone()),
payload: payload_val.clone(),
});
let _ = state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
topic: format!("nvim:ui:diff_decision:{}", diff_id),
session_id: Some(payload.session_id.clone()),
payload: payload_val.clone(),
});
}
}
if let Some(ref f) = payload.file { if let Some(ref f) = payload.file {
state.handler.state.record_activity( state.handler.state.record_activity(
"editor", "editor",
@@ -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!({ axum::Json(serde_json::json!({
"status": "ok", "status": "ok",
"tech_debts": tech_debts, "tech_debts": tech_debts,
"adrs": adrs "adrs": adrs,
"active_task": active_task,
"active_gate": active_gate
})) }))
} }
+35
View File
@@ -29,6 +29,7 @@ pub struct SessionCleanup {
pub state: Arc<AppState>, pub state: Arc<AppState>,
pub send_task: tokio::task::JoinHandle<()>, pub send_task: tokio::task::JoinHandle<()>,
pub recv_task: tokio::task::JoinHandle<()>, pub recv_task: tokio::task::JoinHandle<()>,
pub event_task: tokio::task::JoinHandle<()>,
} }
impl Drop for SessionCleanup { impl Drop for SessionCleanup {
@@ -41,6 +42,7 @@ impl Drop for SessionCleanup {
.remove(&self.session_id); .remove(&self.session_id);
self.send_task.abort(); self.send_task.abort();
self.recv_task.abort(); self.recv_task.abort();
self.event_task.abort();
} }
} }
@@ -147,11 +149,42 @@ pub async fn handle_socket(socket: WebSocket, state: Arc<AppState>, _client_type
} }
}); });
let event_tx = tx.clone();
let mut event_bus_rx = state.handler.state.event_bus_tx.subscribe();
let event_task = tokio::spawn(async move {
while let Ok(event) = event_bus_rx.recv().await {
if event.topic == "resource:updated" {
if let Some(uri) = event.payload.get("uri").and_then(|u| u.as_str()) {
let notif = serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/resources/updated",
"params": {
"uri": uri
}
});
if event_tx.send(notif.to_string()).await.is_err() {
break;
}
}
} else if event.topic == "task:event" {
let notif = serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/task/updated",
"params": event.payload
});
if event_tx.send(notif.to_string()).await.is_err() {
break;
}
}
}
});
let mut cleanup = SessionCleanup { let mut cleanup = SessionCleanup {
session_id: session_id.clone(), session_id: session_id.clone(),
state: Arc::clone(&state), state: Arc::clone(&state),
send_task, send_task,
recv_task, recv_task,
event_task,
}; };
tokio::select! { tokio::select! {
@@ -194,6 +227,7 @@ mod tests {
let send_task = tokio::spawn(async {}); let send_task = tokio::spawn(async {});
let recv_task = tokio::spawn(async {}); let recv_task = tokio::spawn(async {});
let event_task = tokio::spawn(async {});
{ {
let _cleanup = SessionCleanup { let _cleanup = SessionCleanup {
@@ -201,6 +235,7 @@ mod tests {
state: app_state.clone(), state: app_state.clone(),
send_task, send_task,
recv_task, recv_task,
event_task,
}; };
} // Drop happens here } // Drop happens here
+172
View File
@@ -642,6 +642,141 @@
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
} }
/* Command Palette Modal */
#cmd-palette-backdrop {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(4px);
z-index: 10000;
justify-content: center;
align-items: flex-start;
padding-top: 12vh;
box-sizing: border-box;
}
#cmd-palette-modal {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
width: 90%;
max-width: 680px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.45);
display: flex;
flex-direction: column;
overflow: hidden;
animation: paletteFadeIn 0.15s ease-out;
}
@keyframes paletteFadeIn {
from { opacity: 0; transform: translateY(-10px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.palette-header {
display: flex;
align-items: center;
padding: 14px 18px;
border-bottom: 1px solid var(--border-color);
background: var(--canvas-bg);
gap: 12px;
}
.palette-search-icon {
font-size: 1.1em;
color: var(--text-secondary);
}
.palette-input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-size: 1.05em;
color: var(--text-primary);
font-family: inherit;
}
.palette-kbd-badge {
font-size: 0.72em;
padding: 2px 6px;
border-radius: 4px;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-secondary);
font-family: monospace;
}
.palette-results {
max-height: 380px;
overflow-y: auto;
padding: 8px 0;
margin: 0;
list-style: none;
}
.palette-item {
padding: 10px 18px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
cursor: pointer;
transition: background 0.1s;
}
.palette-item:hover, .palette-item.selected {
background: rgba(52, 152, 219, 0.12);
}
.palette-item-left {
display: flex;
align-items: center;
gap: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.palette-item-icon {
font-size: 1em;
flex-shrink: 0;
}
.palette-item-text {
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
}
.palette-item-title {
font-weight: 600;
font-size: 0.92em;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.palette-item-sub {
font-size: 0.78em;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.palette-item-badge {
font-size: 0.72em;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid var(--border-color);
background: var(--canvas-bg);
color: var(--text-secondary);
flex-shrink: 0;
text-transform: uppercase;
}
.palette-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 18px;
background: var(--canvas-bg);
border-top: 1px solid var(--border-color);
font-size: 0.75em;
color: var(--text-secondary);
}
</style> </style>
</head> </head>
<body> <body>
@@ -651,12 +786,35 @@
<span id="app-version" style="font-size:0.8em; color:var(--text-secondary); margin-left:12px; font-family:monospace;"></span> <span id="app-version" style="font-size:0.8em; color:var(--text-secondary); margin-left:12px; font-family:monospace;"></span>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button class="theme-toggle" onclick="openCommandPalette()" title="Command Palette (Ctrl+K)" style="background:var(--card-bg); border:1px solid var(--border-color); color:var(--text-primary); border-radius:6px; padding:5px 10px; font-size:13px; display:flex; align-items:center; gap:6px; cursor:pointer;">
<span>🔍</span>
<span>Command Palette</span>
<span class="enterprise-kbd">Ctrl+K</span>
</button>
<button class="theme-toggle" onclick="testClipboard()" title="Test Clipboard Vision Tool" style="background:#3498db; color:white; border-radius:4px; padding:4px 8px; font-size:14px; display:flex; align-items:center; gap:5px;">📋 Inspect Clipboard</button> <button class="theme-toggle" onclick="testClipboard()" title="Test Clipboard Vision Tool" style="background:#3498db; color:white; border-radius:4px; padding:4px 8px; font-size:14px; display:flex; align-items:center; gap:5px;">📋 Inspect Clipboard</button>
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle Dark Mode">🌓</button> <button class="theme-toggle" onclick="toggleTheme()" title="Toggle Dark Mode">🌓</button>
<div style="font-weight: 500;"><span class="status-dot"></span>System Online</div> <div style="font-weight: 500;"><span class="status-dot"></span>System Online</div>
</div> </div>
</div> </div>
<!-- Command Palette Modal -->
<div id="cmd-palette-backdrop" onclick="onPaletteBackdropClick(event)">
<div id="cmd-palette-modal">
<div class="palette-header">
<span class="palette-search-icon">🔍</span>
<input id="palette-input" class="palette-input" type="text" placeholder="Type a command or search tasks, debts, ADRs, environments..." autocomplete="off" oninput="onPaletteSearch(this.value)" onkeydown="onPaletteKeyDown(event)">
<span class="palette-kbd-badge">ESC</span>
</div>
<div id="palette-results" class="palette-results">
<!-- Injected dynamically -->
</div>
<div class="palette-footer">
<div><span>↑↓</span> to navigate &nbsp;•&nbsp; <span>↵</span> to jump &nbsp;•&nbsp; <span>ESC</span> to close</div>
<div id="palette-count" style="font-family: monospace;">0 items</div>
</div>
</div>
</div>
<!-- Clipboard Modal --> <!-- Clipboard Modal -->
<div id="clipboard-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.8); z-index:9999; justify-content:center; align-items:center; flex-direction:column;"> <div id="clipboard-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.8); z-index:9999; justify-content:center; align-items:center; flex-direction:column;">
<div style="background:var(--card-bg); padding:20px; border-radius:8px; max-width:80%; max-height:80%; display:flex; flex-direction:column; gap:15px; border:1px solid var(--border-color); box-shadow: 0 10px 30px rgba(0,0,0,0.5);"> <div style="background:var(--card-bg); padding:20px; border-radius:8px; max-width:80%; max-height:80%; display:flex; flex-direction:column; gap:15px; border:1px solid var(--border-color); box-shadow: 0 10px 30px rgba(0,0,0,0.5);">
@@ -681,6 +839,7 @@
<button class="tab-button" onclick="switchTab('memos-tab', this)">Handoffs</button> <button class="tab-button" onclick="switchTab('memos-tab', this)">Handoffs</button>
<button class="tab-button" onclick="switchTab('snippets-tab', this)">Snippets</button> <button class="tab-button" onclick="switchTab('snippets-tab', this)">Snippets</button>
<button class="tab-button" onclick="switchTab('terminal-tab', this)">Terminal</button> <button class="tab-button" onclick="switchTab('terminal-tab', this)">Terminal</button>
<button class="tab-button" onclick="switchTab('env-tab', this)">Environment</button>
</div> </div>
<div id="graph-tab" class="tab-content"> <div id="graph-tab" class="tab-content">
@@ -785,6 +944,19 @@
</div> </div>
</div> </div>
<div id="env-tab" class="tab-content">
<div class="panel" style="flex:1; display:flex; flex-direction:column; overflow-y:auto;">
<h2>Target Environments & Infrastructure</h2>
<div id="environments-container" style="display:flex; flex-direction:column; gap:15px; margin-bottom: 30px;"></div>
<h2>Tool & System Fingerprints</h2>
<div id="fingerprints-container" style="display:flex; flex-direction:column; gap:15px; margin-bottom: 30px;"></div>
<h2>Environment Requirements & Secrets</h2>
<div id="requirements-container" style="display:flex; flex-direction:column; gap:15px; margin-bottom: 30px;"></div>
<h2>Quality & Authorization Gates</h2>
<div id="gates-container" style="display:flex; flex-direction:column; gap:15px;"></div>
</div>
</div>
<div id="activity-tab" class="tab-content active"> <div id="activity-tab" class="tab-content active">
<div class="panel activity-panel" style="flex:1; display:flex; flex-direction:column;"> <div class="panel activity-panel" style="flex:1; display:flex; flex-direction:column;">
<div id="activity-feed" style="flex:1;"></div> <div id="activity-feed" style="flex:1;"></div>
+507 -13
View File
@@ -42,6 +42,9 @@ function switchTab(tabId, btn) {
case "ledger-tab": case "ledger-tab":
loadLedger(); loadLedger();
break; break;
case "env-tab":
loadEnvironment();
break;
} }
} }
function toggleTheme() { function toggleTheme() {
@@ -291,10 +294,20 @@ async function loadGraph() {
} }
async function completeTask(id) { async function completeTask(id) {
try { 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(); loadTasks();
} catch (e) { } 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) { function buildTaskTreeHTML(tasks, parentId, depth = 0) {
@@ -683,6 +696,34 @@ async function loadActivityHistory() {
} }
var wsConnected = false; var wsConnected = false;
var activeSSE = null; 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 = {}; var tabRefreshDebounceTimers = {};
function requestDomainRefresh(domain) { function requestDomainRefresh(domain) {
const tabMap = { const tabMap = {
@@ -693,7 +734,9 @@ function requestDomainRefresh(domain) {
snippets: "snippets-tab", snippets: "snippets-tab",
terminal: "terminal-tab", terminal: "terminal-tab",
ledger: "ledger-tab", ledger: "ledger-tab",
memos: "memos-tab" memos: "memos-tab",
env: "env-tab",
gates: "env-tab"
}; };
const targetTab = tabMap[domain]; const targetTab = tabMap[domain];
if (!targetTab || targetTab !== currentTabId) { if (!targetTab || targetTab !== currentTabId) {
@@ -729,6 +772,10 @@ function requestDomainRefresh(domain) {
case "memos": case "memos":
loadMemos(); loadMemos();
break; break;
case "env":
case "gates":
loadEnvironment();
break;
} }
}, 250); }, 250);
} }
@@ -750,6 +797,9 @@ function handleIncomingActivity(payload) {
requestDomainRefresh("graph"); requestDomainRefresh("graph");
} else if (method === "notifications/task/completed" || category.startsWith("TASK")) { } else if (method === "notifications/task/completed" || category.startsWith("TASK")) {
requestDomainRefresh("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") { } else if (category === "TECH_DEBT" || category === "ERROR_FIX" || category === "CHECKPOINT") {
requestDomainRefresh("techdebt"); requestDomainRefresh("techdebt");
} else if (category === "SNIPPET") { } else if (category === "SNIPPET") {
@@ -790,6 +840,7 @@ function setupWS() {
} }
ws.onopen = function() { ws.onopen = function() {
wsConnected = true; wsConnected = true;
activeWS = ws;
if (activeSSE) { if (activeSSE) {
activeSSE.close(); activeSSE.close();
activeSSE = null; activeSSE = null;
@@ -798,7 +849,18 @@ function setupWS() {
ws.onmessage = function(event) { ws.onmessage = function(event) {
try { try {
const data = JSON.parse(event.data); 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; const payload = data.params || data.data || data;
handleIncomingActivity(payload); handleIncomingActivity(payload);
} }
@@ -808,12 +870,24 @@ function setupWS() {
}; };
ws.onclose = function() { ws.onclose = function() {
wsConnected = false; 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..."); console.log("WebSocket closed, falling back to SSE and retrying WS in 5s...");
setupSSE(); setupSSE();
setTimeout(setupWS, 5000); setTimeout(setupWS, 5000);
}; };
ws.onerror = function() { ws.onerror = function() {
wsConnected = false; wsConnected = false;
activeWS = null;
pendingWsRequests.forEach((handler) => {
clearTimeout(handler.timer);
handler.reject(new Error("WebSocket error"));
});
pendingWsRequests.clear();
setupSSE(); setupSSE();
}; };
} }
@@ -1328,7 +1402,10 @@ function loadTechDebt() {
<span style="background:${sevBg}; color:${sevColor}; border:1px solid ${sevBorder}; font-size:0.75em; font-weight:bold; padding:2px 6px; border-radius:4px; text-transform:uppercase;">${escapeHtml(item.severity || "medium")}</span> <span style="background:${sevBg}; color:${sevColor}; border:1px solid ${sevBorder}; font-size:0.75em; font-weight:bold; padding:2px 6px; border-radius:4px; text-transform:uppercase;">${escapeHtml(item.severity || "medium")}</span>
${item.effort_estimate ? `<span style="background:var(--canvas-bg); font-size:0.75em; padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); color:var(--text-secondary);">⏳ ${escapeHtml(item.effort_estimate)}</span>` : ""} ${item.effort_estimate ? `<span style="background:var(--canvas-bg); font-size:0.75em; padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); color:var(--text-secondary);">⏳ ${escapeHtml(item.effort_estimate)}</span>` : ""}
</div> </div>
<div>${statusBadge}</div> <div style="display:flex; align-items:center; gap:8px;">
<div>${statusBadge}</div>
${!item.is_resolved ? `<button class="complete-btn" style="position:static;" onclick="resolveTechDebt('${escapeHtml(item.id)}')" title="Resolve Tech Debt">✓</button>` : ""}
</div>
</div> </div>
${fileLoc ? `<div style="margin-top:6px; font-family:monospace; font-size:0.85em; color:var(--accent-color);">\uD83D\uDCC4 ${escapeHtml(fileLoc)}</div>` : ""} ${fileLoc ? `<div style="margin-top:6px; font-family:monospace; font-size:0.85em; color:var(--accent-color);">\uD83D\uDCC4 ${escapeHtml(fileLoc)}</div>` : ""}
<div style="margin-top:6px; font-size:0.9em; line-height:1.4;"><strong>Description:</strong> ${escapeHtml(item.description || "")}</div> <div style="margin-top:6px; font-size:0.9em; line-height:1.4;"><strong>Description:</strong> ${escapeHtml(item.description || "")}</div>
@@ -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 `
<div style="display:flex; justify-content:space-between; align-items:center; gap:8px;">
<div>
<strong style="font-size:1.05em; color:var(--text-primary);">${escapeHtml(item.name || "Environment")}</strong>
<span style="background:${typeColor}; color:#fff; font-size:0.75em; font-weight:bold; padding:2px 6px; border-radius:4px; margin-left:6px; text-transform:uppercase;">${escapeHtml(envType)}</span>
${isVpn ? '<span style="background:#e67e22; color:#fff; font-size:0.75em; font-weight:bold; padding:2px 6px; border-radius:4px; margin-left:6px;">\uD83D\uDD12 VPN Required</span>' : ""}
</div>
<span style="font-size:0.75em; color:var(--text-secondary); background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">${escapeHtml(item.namespace || "default")}</span>
</div>
${item.url ? `<div style="margin-top:6px; font-family:monospace; font-size:0.85em;"><a href="${escapeHtml(item.url)}" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color); text-decoration:none;">\uD83C\uDF10 ${escapeHtml(item.url)}</a></div>` : ""}
${item.description ? `<div style="margin-top:4px; font-size:0.9em; color:var(--text-primary);">${escapeHtml(item.description)}</div>` : ""}
<div style="margin-top:8px; font-size:0.8em; display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
${item.ssh_host ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); font-family:monospace;">SSH: ${escapeHtml(item.ssh_host)}</span>` : ""}
${item.healthcheck_endpoint ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); font-family:monospace;">Health: ${escapeHtml(item.healthcheck_endpoint)}</span>` : ""}
${item.repo_name ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">\uD83D\uDCE6 ${escapeHtml(item.repo_name)}</span>` : ""}
</div>
`;
}, {
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]) => `<span style="background:var(--canvas-bg); padding:2px 8px; border-radius:4px; font-size:0.8em; font-family:monospace; border:1px solid var(--border-color);"><strong>${escapeHtml(k)}:</strong> ${escapeHtml(String(v))}</span>`).join(" ");
return `
<div style="display:flex; justify-content:space-between; align-items:center; gap:8px;">
<div>
<strong style="font-size:1.05em; color:var(--text-primary);">\uD83D\uDCBB ${escapeHtml(item.os || "Unknown OS")} (${escapeHtml(item.shell || "shell")})</strong>
</div>
<span style="font-size:0.75em; color:var(--text-secondary); background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">${escapeHtml(item.namespace || "default")}</span>
</div>
${tools ? `<div style="margin-top:10px; display:flex; gap:6px; flex-wrap:wrap; align-items:center;">${tools}</div>` : '<div style="margin-top:6px; font-size:0.85em; color:var(--text-secondary);">No tool versions registered</div>'}
<div style="margin-top:8px; font-size:0.8em; color:var(--text-secondary);">
${item.repo_name ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); margin-right:8px;">\uD83D\uDCE6 ${escapeHtml(item.repo_name)}</span>` : ""}
</div>
`;
}, {
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 `
<div style="display:flex; justify-content:space-between; align-items:center; gap:8px;">
<div>
<span style="font-family:monospace; font-size:1.05em; font-weight:bold; color:var(--text-primary);">${escapeHtml(item.key)}</span>
${isSecret ? '<span style="background:#e74c3c; color:#fff; font-size:0.75em; font-weight:bold; padding:2px 6px; border-radius:4px; margin-left:6px;">\uD83D\uDD12 Secret</span>' : '<span style="background:var(--canvas-bg); color:var(--text-secondary); border:1px solid var(--border-color); font-size:0.75em; padding:2px 6px; border-radius:4px; margin-left:6px;">Public</span>'}
</div>
<span style="font-size:0.75em; color:var(--text-secondary); background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">${escapeHtml(item.namespace || "default")}</span>
</div>
${item.description ? `<div style="margin-top:6px; font-size:0.9em; color:var(--text-primary);">${escapeHtml(item.description)}</div>` : ""}
<div style="margin-top:8px; font-size:0.8em; display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
${item.default_value ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); font-family:monospace;">Default: ${escapeHtml(item.default_value)}</span>` : ""}
${item.validation_regex ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); font-family:monospace;">Regex: ${escapeHtml(item.validation_regex)}</span>` : ""}
${item.repo_name ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">\uD83D\uDCE6 ${escapeHtml(item.repo_name)}</span>` : ""}
</div>
`;
}, {
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 `
<div style="display:flex; justify-content:space-between; align-items:center; gap:8px;">
<div>
<strong style="font-size:1.05em; color:var(--text-primary);">${escapeHtml(item.action)} &rarr; ${escapeHtml(item.target)}</strong>
<span style="background:${statusColor}; color:#fff; font-size:0.75em; font-weight:bold; padding:2px 6px; border-radius:4px; margin-left:6px; text-transform:uppercase;">${escapeHtml(status)}</span>
</div>
<div style="display:flex; align-items:center; gap:6px;">
<span style="font-size:0.75em; color:var(--text-secondary); background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">${escapeHtml(item.namespace || "global")}</span>
${status !== "authorized" ? `<button class="tab-button" style="padding:2px 8px; font-size:0.75em; background:rgba(46,204,113,0.15); color:var(--success-color); border:1px solid var(--success-color); cursor:pointer;" onclick="authorizeGate('${escapeHtml(item.id)}')" title="Authorize Gate">✓ Authorize</button>` : ""}
${status !== "blocked" ? `<button class="tab-button" style="padding:2px 8px; font-size:0.75em; background:rgba(231,76,60,0.15); color:var(--error-color); border:1px solid var(--error-color); cursor:pointer;" onclick="blockGate('${escapeHtml(item.id)}')" title="Block Gate">\uD83D\uDEAB Block</button>` : ""}
<button class="tab-button" style="padding:2px 6px; font-size:0.75em; background:var(--canvas-bg); color:var(--text-secondary); border:1px solid var(--border-color); cursor:pointer;" onclick="deleteGate('${escapeHtml(item.id)}')" title="Delete Gate">✕</button>
</div>
</div>
${item.reason ? `<div style="margin-top:6px; font-size:0.9em; color:var(--text-primary);">${escapeHtml(item.reason)}</div>` : ""}
<div style="margin-top:8px; font-size:0.8em; display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
${item.gate_type ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">Type: ${escapeHtml(item.gate_type)}</span>` : ""}
${item.enforcer ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">Enforcer: ${escapeHtml(item.enforcer)}</span>` : ""}
${item.repo_name ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">\uD83D\uDCE6 ${escapeHtml(item.repo_name)}</span>` : ""}
</div>
`;
}, {
itemNoun: "Gates",
cardBorderColor: "#e74c3c",
defaultSort: "desc",
timestampExtractor: (item) => item.timestamp,
searchFields: (item) => [item.action, item.target, item.status, item.reason, item.enforcer]
});
}
function loadAllExtras() { function loadAllExtras() {
loadTerminal(); loadTerminal();
loadTechDebt(); loadTechDebt();
@@ -1772,21 +1964,308 @@ function setupSSE() {
console.error("SSE initialization error", e); 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 = '<li class="enterprise-empty-state" style="margin: 10px 18px;">No matching items found.</li>';
return;
}
container.innerHTML = filteredPaletteItems.map((item, idx) => {
const isSel = idx === paletteSelectedIndex;
return `
<li class="palette-item ${isSel ? "selected" : ""}" data-index="${idx}" onclick="selectPaletteItem(${idx})">
<div class="palette-item-left">
<span class="palette-item-icon">${escapeHtml(item.icon)}</span>
<div class="palette-item-text">
<span class="palette-item-title">${escapeHtml(item.title)}</span>
<span class="palette-item-sub">${escapeHtml(item.sub)}</span>
</div>
</div>
<span class="palette-item-badge">${escapeHtml(item.badge)}</span>
</li>
`;
}).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) { document.addEventListener("keydown", function(e) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault(); e.preventDefault();
const searchTabBtn = document.querySelector('button[onclick*="search-tab"]'); const backdrop = document.getElementById("cmd-palette-backdrop");
if (searchTabBtn) { if (backdrop && backdrop.style.display === "flex") {
switchTab("search-tab", searchTabBtn); closeCommandPalette();
} } else {
const searchInput = document.getElementById("search-box"); openCommandPalette();
if (searchInput) {
searchInput.focus();
searchInput.select();
} }
return; 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") { 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"); const activeSearch = document.querySelector(".tab-content.active .enterprise-search-input, .tab-content.active #task-filter-input");
if (activeSearch) { if (activeSearch) {
e.preventDefault(); e.preventDefault();
@@ -1821,6 +2300,9 @@ function refreshActiveTab() {
case "ledger-tab": case "ledger-tab":
loadLedger(); loadLedger();
break; break;
case "env-tab":
loadEnvironment();
break;
case "activity-tab": case "activity-tab":
loadActivityHistory(); loadActivityHistory();
break; break;
@@ -1851,6 +2333,7 @@ Object.assign(window, {
requestDomainRefresh, requestDomainRefresh,
handleIncomingActivity, handleIncomingActivity,
setupWS, setupWS,
sendWsRpc,
loadGenericList, loadGenericList,
onListSearchInput, onListSearchInput,
onListClearSearch, onListClearSearch,
@@ -1866,6 +2349,17 @@ Object.assign(window, {
loadMemos, loadMemos,
loadSnippets, loadSnippets,
loadAllExtras, loadAllExtras,
loadEnvironment,
resolveTechDebt,
authorizeGate,
blockGate,
deleteGate,
openCommandPalette,
closeCommandPalette,
onPaletteBackdropClick,
onPaletteSearch,
onPaletteKeyDown,
selectPaletteItem,
testClipboard, testClipboard,
loadVersion, loadVersion,
setupSSE, setupSSE,
+579 -15
View File
@@ -150,6 +150,9 @@ function switchTab(tabId: string, btn?: HTMLElement | null): void {
case "ledger-tab": case "ledger-tab":
loadLedger(); loadLedger();
break; break;
case "env-tab":
loadEnvironment();
break;
} }
} }
@@ -464,10 +467,20 @@ async function loadGraph(): Promise<void> {
// --- Task Tree (HTN/DAG) --- // --- Task Tree (HTN/DAG) ---
async function completeTask(id: string): Promise<void> { async function completeTask(id: string): Promise<void> {
try { 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(); loadTasks();
} catch (e) { } 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<void> {
let wsConnected = false; let wsConnected = false;
let activeSSE: EventSource | null = null; let activeSSE: EventSource | null = null;
let activeWS: WebSocket | null = null;
let wsRpcId = 1;
const pendingWsRequests = new Map<number | string, { resolve: (val: any) => void; reject: (err: any) => void; timer: any }>();
function sendWsRpc(method: string, params: any = {}, timeoutMs = 5000): Promise<any> {
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<string, any> = {}; const tabRefreshDebounceTimers: Record<string, any> = {};
function requestDomainRefresh(domain: string): void { function requestDomainRefresh(domain: string): void {
@@ -939,6 +982,8 @@ function requestDomainRefresh(domain: string): void {
terminal: "terminal-tab", terminal: "terminal-tab",
ledger: "ledger-tab", ledger: "ledger-tab",
memos: "memos-tab", memos: "memos-tab",
env: "env-tab",
gates: "env-tab",
}; };
const targetTab = tabMap[domain]; const targetTab = tabMap[domain];
@@ -977,6 +1022,10 @@ function requestDomainRefresh(domain: string): void {
case "memos": case "memos":
loadMemos(); loadMemos();
break; break;
case "env":
case "gates":
loadEnvironment();
break;
} }
}, 250); }, 250);
} }
@@ -1007,6 +1056,14 @@ function handleIncomingActivity(payload: any): void {
category.startsWith("TASK") category.startsWith("TASK")
) { ) {
requestDomainRefresh("task"); requestDomainRefresh("task");
} else if (
category === "GATE" ||
category === "QUALITY_GATE" ||
category === "ENVIRONMENT" ||
category === "ENV"
) {
requestDomainRefresh("gates");
requestDomainRefresh("env");
} else if ( } else if (
category === "TECH_DEBT" || category === "TECH_DEBT" ||
category === "ERROR_FIX" || category === "ERROR_FIX" ||
@@ -1056,6 +1113,7 @@ function setupWS(): void {
ws.onopen = function () { ws.onopen = function () {
wsConnected = true; wsConnected = true;
activeWS = ws;
if (activeSSE) { if (activeSSE) {
activeSSE.close(); activeSSE.close();
activeSSE = null; activeSSE = null;
@@ -1065,8 +1123,23 @@ function setupWS(): void {
ws.onmessage = function (event: MessageEvent) { ws.onmessage = function (event: MessageEvent) {
try { try {
const data = JSON.parse(event.data); 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 ( if (
data.type === "activity" || data.type === "activity" ||
data.type === "gate" ||
data.type === "task" ||
data.type === "tech_debt" ||
data.type === "nvim_telemetry" ||
data.method === "notifications/activity" || data.method === "notifications/activity" ||
data.method === "notifications/task/completed" || data.method === "notifications/task/completed" ||
data.method === "notifications/resources/updated" data.method === "notifications/resources/updated"
@@ -1081,6 +1154,12 @@ function setupWS(): void {
ws.onclose = function () { ws.onclose = function () {
wsConnected = false; wsConnected = false;
activeWS = null;
pendingWsRequests.forEach((handler) => {
clearTimeout(handler.timer);
handler.reject(new Error("WebSocket disconnected"));
});
pendingWsRequests.clear();
console.log( console.log(
"WebSocket closed, falling back to SSE and retrying WS in 5s...", "WebSocket closed, falling back to SSE and retrying WS in 5s...",
); );
@@ -1090,6 +1169,12 @@ function setupWS(): void {
ws.onerror = function () { ws.onerror = function () {
wsConnected = false; wsConnected = false;
activeWS = null;
pendingWsRequests.forEach((handler) => {
clearTimeout(handler.timer);
handler.reject(new Error("WebSocket error"));
});
pendingWsRequests.clear();
setupSSE(); setupSSE();
}; };
} }
@@ -1777,7 +1862,10 @@ function loadTechDebt(): void {
<span style="background:${sevBg}; color:${sevColor}; border:1px solid ${sevBorder}; font-size:0.75em; font-weight:bold; padding:2px 6px; border-radius:4px; text-transform:uppercase;">${escapeHtml(item.severity || "medium")}</span> <span style="background:${sevBg}; color:${sevColor}; border:1px solid ${sevBorder}; font-size:0.75em; font-weight:bold; padding:2px 6px; border-radius:4px; text-transform:uppercase;">${escapeHtml(item.severity || "medium")}</span>
${item.effort_estimate ? `<span style="background:var(--canvas-bg); font-size:0.75em; padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); color:var(--text-secondary);">⏳ ${escapeHtml(item.effort_estimate)}</span>` : ""} ${item.effort_estimate ? `<span style="background:var(--canvas-bg); font-size:0.75em; padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); color:var(--text-secondary);">⏳ ${escapeHtml(item.effort_estimate)}</span>` : ""}
</div> </div>
<div>${statusBadge}</div> <div style="display:flex; align-items:center; gap:8px;">
<div>${statusBadge}</div>
${!item.is_resolved ? `<button class="complete-btn" style="position:static;" onclick="resolveTechDebt('${escapeHtml(item.id)}')" title="Resolve Tech Debt">✓</button>` : ""}
</div>
</div> </div>
${fileLoc ? `<div style="margin-top:6px; font-family:monospace; font-size:0.85em; color:var(--accent-color);">📄 ${escapeHtml(fileLoc)}</div>` : ""} ${fileLoc ? `<div style="margin-top:6px; font-family:monospace; font-size:0.85em; color:var(--accent-color);">📄 ${escapeHtml(fileLoc)}</div>` : ""}
<div style="margin-top:6px; font-size:0.9em; line-height:1.4;"><strong>Description:</strong> ${escapeHtml(item.description || "")}</div> <div style="margin-top:6px; font-size:0.9em; line-height:1.4;"><strong>Description:</strong> ${escapeHtml(item.description || "")}</div>
@@ -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 `
<div style="display:flex; justify-content:space-between; align-items:center; gap:8px;">
<div>
<strong style="font-size:1.05em; color:var(--text-primary);">${escapeHtml(item.name || "Environment")}</strong>
<span style="background:${typeColor}; color:#fff; font-size:0.75em; font-weight:bold; padding:2px 6px; border-radius:4px; margin-left:6px; text-transform:uppercase;">${escapeHtml(envType)}</span>
${isVpn ? '<span style="background:#e67e22; color:#fff; font-size:0.75em; font-weight:bold; padding:2px 6px; border-radius:4px; margin-left:6px;">🔒 VPN Required</span>' : ""}
</div>
<span style="font-size:0.75em; color:var(--text-secondary); background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">${escapeHtml(item.namespace || "default")}</span>
</div>
${item.url ? `<div style="margin-top:6px; font-family:monospace; font-size:0.85em;"><a href="${escapeHtml(item.url)}" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color); text-decoration:none;">🌐 ${escapeHtml(item.url)}</a></div>` : ""}
${item.description ? `<div style="margin-top:4px; font-size:0.9em; color:var(--text-primary);">${escapeHtml(item.description)}</div>` : ""}
<div style="margin-top:8px; font-size:0.8em; display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
${item.ssh_host ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); font-family:monospace;">SSH: ${escapeHtml(item.ssh_host)}</span>` : ""}
${item.healthcheck_endpoint ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); font-family:monospace;">Health: ${escapeHtml(item.healthcheck_endpoint)}</span>` : ""}
${item.repo_name ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">📦 ${escapeHtml(item.repo_name)}</span>` : ""}
</div>
`;
},
{
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]) => `<span style="background:var(--canvas-bg); padding:2px 8px; border-radius:4px; font-size:0.8em; font-family:monospace; border:1px solid var(--border-color);"><strong>${escapeHtml(k)}:</strong> ${escapeHtml(String(v))}</span>`
).join(" ");
return `
<div style="display:flex; justify-content:space-between; align-items:center; gap:8px;">
<div>
<strong style="font-size:1.05em; color:var(--text-primary);">💻 ${escapeHtml(item.os || "Unknown OS")} (${escapeHtml(item.shell || "shell")})</strong>
</div>
<span style="font-size:0.75em; color:var(--text-secondary); background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">${escapeHtml(item.namespace || "default")}</span>
</div>
${tools ? `<div style="margin-top:10px; display:flex; gap:6px; flex-wrap:wrap; align-items:center;">${tools}</div>` : '<div style="margin-top:6px; font-size:0.85em; color:var(--text-secondary);">No tool versions registered</div>'}
<div style="margin-top:8px; font-size:0.8em; color:var(--text-secondary);">
${item.repo_name ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); margin-right:8px;">📦 ${escapeHtml(item.repo_name)}</span>` : ""}
</div>
`;
},
{
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 `
<div style="display:flex; justify-content:space-between; align-items:center; gap:8px;">
<div>
<span style="font-family:monospace; font-size:1.05em; font-weight:bold; color:var(--text-primary);">${escapeHtml(item.key)}</span>
${isSecret ? '<span style="background:#e74c3c; color:#fff; font-size:0.75em; font-weight:bold; padding:2px 6px; border-radius:4px; margin-left:6px;">🔒 Secret</span>' : '<span style="background:var(--canvas-bg); color:var(--text-secondary); border:1px solid var(--border-color); font-size:0.75em; padding:2px 6px; border-radius:4px; margin-left:6px;">Public</span>'}
</div>
<span style="font-size:0.75em; color:var(--text-secondary); background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">${escapeHtml(item.namespace || "default")}</span>
</div>
${item.description ? `<div style="margin-top:6px; font-size:0.9em; color:var(--text-primary);">${escapeHtml(item.description)}</div>` : ""}
<div style="margin-top:8px; font-size:0.8em; display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
${item.default_value ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); font-family:monospace;">Default: ${escapeHtml(item.default_value)}</span>` : ""}
${item.validation_regex ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); font-family:monospace;">Regex: ${escapeHtml(item.validation_regex)}</span>` : ""}
${item.repo_name ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">📦 ${escapeHtml(item.repo_name)}</span>` : ""}
</div>
`;
},
{
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 `
<div style="display:flex; justify-content:space-between; align-items:center; gap:8px;">
<div>
<strong style="font-size:1.05em; color:var(--text-primary);">${escapeHtml(item.action)} &rarr; ${escapeHtml(item.target)}</strong>
<span style="background:${statusColor}; color:#fff; font-size:0.75em; font-weight:bold; padding:2px 6px; border-radius:4px; margin-left:6px; text-transform:uppercase;">${escapeHtml(status)}</span>
</div>
<div style="display:flex; align-items:center; gap:6px;">
<span style="font-size:0.75em; color:var(--text-secondary); background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">${escapeHtml(item.namespace || "global")}</span>
${status !== "authorized" ? `<button class="tab-button" style="padding:2px 8px; font-size:0.75em; background:rgba(46,204,113,0.15); color:var(--success-color); border:1px solid var(--success-color); cursor:pointer;" onclick="authorizeGate('${escapeHtml(item.id)}')" title="Authorize Gate">✓ Authorize</button>` : ""}
${status !== "blocked" ? `<button class="tab-button" style="padding:2px 8px; font-size:0.75em; background:rgba(231,76,60,0.15); color:var(--error-color); border:1px solid var(--error-color); cursor:pointer;" onclick="blockGate('${escapeHtml(item.id)}')" title="Block Gate">🚫 Block</button>` : ""}
<button class="tab-button" style="padding:2px 6px; font-size:0.75em; background:var(--canvas-bg); color:var(--text-secondary); border:1px solid var(--border-color); cursor:pointer;" onclick="deleteGate('${escapeHtml(item.id)}')" title="Delete Gate">✕</button>
</div>
</div>
${item.reason ? `<div style="margin-top:6px; font-size:0.9em; color:var(--text-primary);">${escapeHtml(item.reason)}</div>` : ""}
<div style="margin-top:8px; font-size:0.8em; display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
${item.gate_type ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">Type: ${escapeHtml(item.gate_type)}</span>` : ""}
${item.enforcer ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">Enforcer: ${escapeHtml(item.enforcer)}</span>` : ""}
${item.repo_name ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">📦 ${escapeHtml(item.repo_name)}</span>` : ""}
</div>
`;
},
{
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 { function loadAllExtras(): void {
loadTerminal(); loadTerminal();
loadTechDebt(); loadTechDebt();
@@ -2343,31 +2574,349 @@ function setupSSE(): void {
} }
} }
// --- Interactive Dashboard Controls ---
async function resolveTechDebt(id: string): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 = '<li class="enterprise-empty-state" style="margin: 10px 18px;">No matching items found.</li>';
return;
}
container.innerHTML = filteredPaletteItems
.map((item, idx) => {
const isSel = idx === paletteSelectedIndex;
return `
<li class="palette-item ${isSel ? "selected" : ""}" data-index="${idx}" onclick="selectPaletteItem(${idx})">
<div class="palette-item-left">
<span class="palette-item-icon">${escapeHtml(item.icon)}</span>
<div class="palette-item-text">
<span class="palette-item-title">${escapeHtml(item.title)}</span>
<span class="palette-item-sub">${escapeHtml(item.sub)}</span>
</div>
</div>
<span class="palette-item-badge">${escapeHtml(item.badge)}</span>
</li>
`;
})
.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) { document.addEventListener("keydown", function (e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault(); e.preventDefault();
const searchTabBtn = document.querySelector( const backdrop = document.getElementById("cmd-palette-backdrop");
'button[onclick*="search-tab"]', if (backdrop && backdrop.style.display === "flex") {
) as HTMLElement | null; closeCommandPalette();
if (searchTabBtn) { } else {
switchTab("search-tab", searchTabBtn); openCommandPalette();
}
const searchInput = document.getElementById(
"search-box",
) as HTMLInputElement | null;
if (searchInput) {
searchInput.focus();
searchInput.select();
} }
return; 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 // Pressing '/' focuses the current active list search input if not already typing
if ( if (
e.key === "/" && e.key === "/" &&
document.activeElement?.tagName !== "INPUT" && document.activeElement?.tagName !== "INPUT" &&
document.activeElement?.tagName !== "TEXTAREA" document.activeElement?.tagName !== "TEXTAREA"
) { ) {
const backdrop = document.getElementById("cmd-palette-backdrop");
if (backdrop && backdrop.style.display === "flex") return;
const activeSearch = document.querySelector( const activeSearch = document.querySelector(
".tab-content.active .enterprise-search-input, .tab-content.active #task-filter-input", ".tab-content.active .enterprise-search-input, .tab-content.active #task-filter-input",
) as HTMLInputElement | null; ) as HTMLInputElement | null;
@@ -2405,6 +2954,9 @@ function refreshActiveTab(): void {
case "ledger-tab": case "ledger-tab":
loadLedger(); loadLedger();
break; break;
case "env-tab":
loadEnvironment();
break;
case "activity-tab": case "activity-tab":
loadActivityHistory(); loadActivityHistory();
break; break;
@@ -2437,6 +2989,7 @@ Object.assign(window, {
requestDomainRefresh, requestDomainRefresh,
handleIncomingActivity, handleIncomingActivity,
setupWS, setupWS,
sendWsRpc,
loadGenericList, loadGenericList,
onListSearchInput, onListSearchInput,
onListClearSearch, onListClearSearch,
@@ -2452,6 +3005,17 @@ Object.assign(window, {
loadMemos, loadMemos,
loadSnippets, loadSnippets,
loadAllExtras, loadAllExtras,
loadEnvironment,
resolveTechDebt,
authorizeGate,
blockGate,
deleteGate,
openCommandPalette,
closeCommandPalette,
onPaletteBackdropClick,
onPaletteSearch,
onPaletteKeyDown,
selectPaletteItem,
testClipboard, testClipboard,
loadVersion, loadVersion,
setupSSE, setupSSE,
+10 -2
View File
@@ -26,13 +26,21 @@ impl McpTool for EnvironmentHandler {
match req.action { match req.action {
EnvAction::UpdateFingerprint => { EnvAction::UpdateFingerprint => {
let tool_versions = req.tool_versions.unwrap_or_default(); let tool_versions = req.tool_versions.unwrap_or_default();
let detected_os = req.os.unwrap_or_else(|| std::env::consts::OS.to_string());
let detected_shell = req.shell
.or_else(|| std::env::var("SHELL").ok())
.or_else(|| std::env::var("STARSHIP_SHELL").ok())
.or_else(|| if std::env::var("PSModulePath").is_ok() { Some("pwsh".to_string()) } else { None })
.or_else(|| if std::env::var("COMSPEC").is_ok() { Some("cmd".to_string()) } else { None })
.unwrap_or_else(|| "unknown".to_string());
state.env.env_fingerprints.modify(|fps| { state.env.env_fingerprints.modify(|fps| {
fps.insert( fps.insert(
ns.clone(), ns.clone(),
crate::models::EnvFingerprint { crate::models::EnvFingerprint {
namespace: ns.clone(), namespace: ns.clone(),
os: std::env::consts::OS.to_string(), os: detected_os,
shell: std::env::var("SHELL").unwrap_or_else(|_| "unknown".to_string()), shell: detected_shell,
tool_versions, tool_versions,
updated_at: crate::handlers::utils::now_secs(), updated_at: crate::handlers::utils::now_secs(),
..Default::default() ..Default::default()
+801 -18
View File
@@ -8,9 +8,229 @@ use serde_json::{Value, json};
use std::borrow::Cow; use std::borrow::Cow;
use std::sync::Arc; use std::sync::Arc;
static CLIPBOARD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); 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("<!--StartFragment-->") {
let after_start = start_idx + "<!--StartFragment-->".len();
if let Some(end_idx) = raw[after_start..].find("<!--EndFragment-->") {
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<String>> = Vec::new();
let mut current_row: Vec<String> = 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::<usize>().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<String> { pub fn get_native_clipboard_text() -> Option<String> {
let _guard = CLIPBOARD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let _guard = CLIPBOARD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
for _ in 0..3 { for _ in 0..3 {
@@ -25,6 +245,83 @@ pub fn get_native_clipboard_text() -> Option<String> {
None None
} }
#[cfg(target_os = "windows")]
pub fn get_windows_html_clipboard() -> Option<String> {
use windows_sys::Win32::System::DataExchange::{
CloseClipboard, GetClipboardData, IsClipboardFormatAvailable, OpenClipboard,
RegisterClipboardFormatW,
};
use windows_sys::Win32::System::Memory::{GlobalLock, GlobalSize, GlobalUnlock};
let format_name: Vec<u16> = "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<String> {
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")] #[cfg(target_os = "windows")]
fn get_windows_png_clipboard_image() -> Option<image::DynamicImage> { fn get_windows_png_clipboard_image() -> Option<image::DynamicImage> {
use windows_sys::Win32::System::DataExchange::{ use windows_sys::Win32::System::DataExchange::{
@@ -67,6 +364,7 @@ fn get_windows_png_clipboard_image() -> Option<image::DynamicImage> {
} }
None None
} }
pub fn get_native_clipboard_image() -> Option<image::DynamicImage> { pub fn get_native_clipboard_image() -> Option<image::DynamicImage> {
let _guard = CLIPBOARD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let _guard = CLIPBOARD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -89,6 +387,242 @@ pub fn get_native_clipboard_image() -> Option<image::DynamicImage> {
None None
} }
pub async fn process_clipboard_snapshot(state: Arc<MemoryState>) {
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<tokio::sync::mpsc::UnboundedSender<()>> =
std::sync::OnceLock::new();
#[cfg(target_os = "windows")]
pub fn spawn_clipboard_listener(state: Arc<MemoryState>) {
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<u16> =
"McpMemoryClipboardWatcherClass\0".encode_utf16().collect();
let wnd_class = WNDCLASSEXW {
cbSize: std::mem::size_of::<WNDCLASSEXW>() 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<MemoryState>) {
tracing::info!("Clipboard listener: running in headless/Linux mode (no Win32 listener).");
}
pub struct ClipboardHandler; pub struct ClipboardHandler;
#[async_trait] #[async_trait]
@@ -100,13 +634,154 @@ impl McpTool for ClipboardHandler {
fn schema(&self) -> Value { fn schema(&self) -> Value {
crate::mcp::tool_def::<ClipboardTool>( crate::mcp::tool_def::<ClipboardTool>(
"clipboard", "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<MemoryState>) -> crate::error::Result<String> { async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: ClipboardTool = serde_json::from_value(args).map_err(|e| e.to_string())?; let req: ClipboardTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
match req.action { 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::<Vec<_>>()
};
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 => { ClipboardAction::Read => {
let (mut out, b64_opt) = let (mut out, b64_opt) =
tokio::task::spawn_blocking(move || -> crate::error::Result<(serde_json::Map<String, Value>, Option<String>)> { tokio::task::spawn_blocking(move || -> crate::error::Result<(serde_json::Map<String, Value>, Option<String>)> {
@@ -119,14 +794,15 @@ impl McpTool for ClipboardHandler {
if let Some(dynamic_img) = get_native_clipboard_image() { if let Some(dynamic_img) = get_native_clipboard_image() {
let mut img = dynamic_img; let mut img = dynamic_img;
let max_dim = 1024; let max_dim = 1440;
if img.width() > max_dim || img.height() > max_dim { if img.width() > max_dim || img.height() > max_dim {
img = img.resize(max_dim, max_dim, FilterType::Lanczos3); img = img.resize(max_dim, max_dim, FilterType::Lanczos3);
} }
let rgb_img = img.into_rgb8(); let rgb_img = img.into_rgb8();
let mut jpeg_bytes = std::io::Cursor::new(Vec::new()); 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(); let bytes = jpeg_bytes.into_inner();
use base64::Engine; use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
@@ -136,18 +812,12 @@ impl McpTool for ClipboardHandler {
.unwrap_or_default() .unwrap_or_default()
.join(".gemini/mcp_memory/clipboard"); .join(".gemini/mcp_memory/clipboard");
let _ = std::fs::create_dir_all(&cache_dir); 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() { if std::fs::write(&file_path, &bytes).is_ok() {
let path_str = file_path.to_string_lossy().to_string(); let path_str = file_path.to_string_lossy().to_string();
out.insert("image_path".into(), json!(path_str)); out.insert("image_path".into(), json!(path_str));
#[cfg(target_os = "windows")] let wsl_path = to_wsl_path(&path_str);
if let Some(rest) = path_str.strip_prefix("C:\\") { out.insert("image_path_wsl".into(), json!(wsl_path));
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));
}
} }
} }
} }
@@ -171,7 +841,9 @@ impl McpTool for ClipboardHandler {
} }
state.record_activity("clipboard", "Read contents from OS clipboard", None); state.record_activity("clipboard", "Read contents from OS clipboard", None);
Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&Value::Object(out))?) Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&Value::Object(
out,
))?)
} }
ClipboardAction::Write => { ClipboardAction::Write => {
let text_opt = req.text; let text_opt = req.text;
@@ -258,7 +930,6 @@ impl McpTool for ClipboardHandler {
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -266,7 +937,6 @@ mod tests {
use std::sync::Arc; use std::sync::Arc;
use tempfile::tempdir; use tempfile::tempdir;
#[tokio::test] #[tokio::test]
async fn test_write_clipboard() { async fn test_write_clipboard() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
@@ -351,6 +1021,119 @@ mod tests {
"Regression detected: vision.rs must not invoke xclip!" "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<!--StartFragment--><table><tr><th>Header 1</th><th>Header 2</th></tr><tr><td>Row 1 Cell 1</td><td>Row 1 Cell 2</td></tr></table><!--EndFragment-->";
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);
}
} }
+7
View File
@@ -131,6 +131,13 @@ pub async fn start_background_indexer(state: Arc<MemoryState>) {
snippets.drain(0..overflow); snippets.drain(0..overflow);
} }
}); });
let threshold: usize = std::env::var("MCP_MEMORY_CONDENSE_THRESHOLD")
.unwrap_or_else(|_| "100".to_string())
.parse()
.unwrap_or(100);
if state.code.snippets.read_with(|s| s.len()) > threshold {
state.condense_notify.notify_one();
}
for snippet in &snippets_to_index { for snippet in &snippets_to_index {
let _ = idx.index_snippet(snippet).await; let _ = idx.index_snippet(snippet).await;
+155 -32
View File
@@ -96,7 +96,62 @@ pub struct AppState {
pub async fn ttl_sweeper_worker(state: Arc<MemoryState>) { pub async fn ttl_sweeper_worker(state: Arc<MemoryState>) {
loop { loop {
state.ttl_notify.notified().await; let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut next_expiry: Option<u64> = 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() let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default() .unwrap_or_default()
@@ -139,12 +194,18 @@ pub async fn ttl_sweeper_worker(state: Arc<MemoryState>) {
state.telemetry.session_summaries.modify(|summaries| { state.telemetry.session_summaries.modify(|summaries| {
summaries.retain(|s| s.expires_at.is_none_or(|exp| exp > now)); summaries.retain(|s| s.expires_at.is_none_or(|exp| exp > now));
}); });
state.env.gates.modify(|gates| {
gates.retain(|g| g.expires_at.is_none_or(|exp| exp > now));
});
} }
} }
pub async fn index_committer_worker(state: Arc<MemoryState>) { pub async fn index_committer_worker(state: Arc<MemoryState>) {
loop { loop {
state.index_commit_notify.notified().await; tokio::select! {
_ = state.shutdown_notify.notified() => break,
_ = state.index_commit_notify.notified() => {},
}
let idx = state.search_index.read().await.clone(); let idx = state.search_index.read().await.clone();
let _ = idx.commit().await; let _ = idx.commit().await;
} }
@@ -152,7 +213,10 @@ pub async fn index_committer_worker(state: Arc<MemoryState>) {
pub async fn condense_graph_worker(state: Arc<MemoryState>) { pub async fn condense_graph_worker(state: Arc<MemoryState>) {
loop { loop {
state.condense_notify.notified().await; tokio::select! {
_ = state.shutdown_notify.notified() => break,
_ = state.condense_notify.notified() => {},
}
let threshold: usize = std::env::var("MCP_MEMORY_CONDENSE_THRESHOLD") let threshold: usize = std::env::var("MCP_MEMORY_CONDENSE_THRESHOLD")
.unwrap_or_else(|_| "100".to_string()) .unwrap_or_else(|_| "100".to_string())
@@ -224,6 +288,7 @@ pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::erro
tokio::spawn(ttl_sweeper_worker(Arc::clone(&state))); tokio::spawn(ttl_sweeper_worker(Arc::clone(&state)));
tokio::spawn(condense_graph_worker(Arc::clone(&state))); tokio::spawn(condense_graph_worker(Arc::clone(&state)));
crate::watcher::spawn_watcher(Arc::clone(&state)); crate::watcher::spawn_watcher(Arc::clone(&state));
crate::handlers::vision::spawn_clipboard_listener(Arc::clone(&state));
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
let app_state = Arc::new(AppState { let app_state = Arc::new(AppState {
handler: Arc::new(MemoryHandler::new(Arc::clone(&state))), handler: Arc::new(MemoryHandler::new(Arc::clone(&state))),
@@ -270,32 +335,66 @@ pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::erro
tokio::spawn(async move { tokio::spawn(async move {
let port1 = std::env::var("MCP_UDP_PORT1").unwrap_or_else(|_| "3001".to_string()); let port1 = std::env::var("MCP_UDP_PORT1").unwrap_or_else(|_| "3001".to_string());
if let Ok(socket) = tokio::net::UdpSocket::bind(format!("127.0.0.1:{}", port1)).await { if let Ok(socket) = tokio::net::UdpSocket::bind(format!("127.0.0.1:{}", port1)).await {
let socket = Arc::new(socket);
let socket_rx = socket.clone();
let mut subscribers: HashMap<(String, String), std::net::SocketAddr> = HashMap::new();
let mut buf = vec![0u8; 65536]; let mut buf = vec![0u8; 65536];
let mut event_rx = udp_state.handler.state.event_bus_tx.subscribe();
loop { loop {
if let Ok((len, _addr)) = socket.recv_from(&mut buf).await tokio::select! {
&& let Ok(payload) = recv_res = socket_rx.recv_from(&mut buf) => {
serde_json::from_slice::<crate::models::TerminalHistory>(&buf[..len]) if let Ok((len, addr)) = recv_res {
{ if let Ok(payload) = serde_json::from_slice::<crate::models::TerminalHistory>(&buf[..len]) {
udp_state udp_state
.handler .handler
.state .state
.record_terminal_history(payload.clone()); .record_terminal_history(payload.clone());
let ws_msg = serde_json::json!({ let ws_msg = serde_json::json!({
"type": "terminal_telemetry", "type": "terminal_telemetry",
"data": payload "data": payload
}); });
let msg_str = ws_msg.to_string(); let msg_str = ws_msg.to_string();
let senders: Vec<_> = udp_state let senders: Vec<_> = udp_state
.clients .clients
.read() .read()
.unwrap_or_else(|e| e.into_inner()) .unwrap_or_else(|e| e.into_inner())
.values() .values()
.cloned() .cloned()
.collect(); .collect();
for tx in senders { for tx in senders {
let _ = tx.try_send(msg_str.clone()); let _ = tx.try_send(msg_str.clone());
}
} else if let Ok(gate_wait) = serde_json::from_slice::<serde_json::Value>(&buf[..len]) {
if gate_wait.get("type").and_then(|t| t.as_str()) == Some("gate_wait") {
if let (Some(action), Some(target)) = (
gate_wait.get("action").and_then(|a| a.as_str()),
gate_wait.get("target").and_then(|t| t.as_str())
) {
subscribers.insert((action.to_string(), target.to_string()), addr);
}
}
}
}
}
Ok(event) = event_rx.recv() => {
if event.topic == "gate:event" {
if let (Some(action), Some(target), Some(status)) = (
event.payload.get("action").and_then(|a| a.as_str()),
event.payload.get("target").and_then(|t| t.as_str()),
event.payload.get("status").and_then(|s| s.as_str()),
) {
if status == "authorized" || status == "blocked" {
if let Some(addr) = subscribers.remove(&(action.to_string(), target.to_string())) {
let response = if status == "authorized" { b"APPROVED" } else { b"REJECTED" };
let _ = socket.send_to(response, addr).await;
}
}
}
}
} }
} }
} }
@@ -372,14 +471,38 @@ pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::erro
let _ = nvim_udp_state.handler.state.event_bus_tx.send(event); let _ = nvim_udp_state.handler.state.event_bus_tx.send(event);
} }
if payload.event.starts_with("agent_") { if payload.event.starts_with("agent_") || payload.event.starts_with("diff_") {
let topic = format!("nvim:ui:{}", payload.event); let payload_val = serde_json::json!(&payload);
let event = crate::state::GenericEvent { // 1. General event topic (e.g. nvim:ui:agent_prompt_response, nvim:ui:agent_diff_accepted)
topic, let _ = nvim_udp_state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
topic: format!("nvim:ui:{}", payload.event),
session_id: Some(payload.session_id.clone()), session_id: Some(payload.session_id.clone()),
payload: serde_json::json!(&payload), payload: payload_val.clone(),
}; });
let _ = nvim_udp_state.handler.state.event_bus_tx.send(event);
// 2. Correlated request_id topic (e.g. nvim:ui:agent_prompt_response:REQ_ID)
if let Some(ref req_id) = payload.request_id {
let _ = nvim_udp_state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
topic: format!("nvim:ui:{}:{}", payload.event, req_id),
session_id: Some(payload.session_id.clone()),
payload: payload_val.clone(),
});
}
// 3. Correlated diff_id topics
if let Some(ref diff_id) = payload.diff_id {
let _ = nvim_udp_state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
topic: format!("nvim:ui:{}:{}", payload.event, diff_id),
session_id: Some(payload.session_id.clone()),
payload: payload_val.clone(),
});
// General diff decision topic
let _ = nvim_udp_state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
topic: format!("nvim:ui:diff_decision:{}", diff_id),
session_id: Some(payload.session_id.clone()),
payload: payload_val.clone(),
});
}
} }
} }
} }
+153
View File
@@ -772,6 +772,159 @@ impl MemoryHandler {
} }
} }
} }
"tasks/complete" => {
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
let task_id = params.get("id").and_then(|v| v.as_str()).unwrap_or("");
if task_id.is_empty() {
return Some(crate::mcp::error(id, -32602, "Missing task id parameter"));
}
self.state.project.tasks.modify(|tasks| {
for t in tasks.iter_mut() {
if t.id == task_id {
t.status = "completed".to_string();
t.updated_at = crate::handlers::utils::now_secs();
break;
}
}
});
let unblocked = crate::handlers::reconciliation::cascade_unblock_tasks(&self.state);
let milestones = crate::handlers::reconciliation::recalculate_milestones(&self.state);
self.state.record_activity("task", &format!("Completed task via WebSocket RPC: {}", task_id), Some("completed"));
let _ = self.state.event_bus_tx.send(crate::state::GenericEvent {
topic: "task:event".to_string(),
session_id: None,
payload: serde_json::json!({
"task_id": task_id,
"status": "completed",
"unblocked_tasks": unblocked,
"updated_milestones": milestones,
}),
});
Some(crate::mcp::success(id, serde_json::json!({
"status": "success",
"unblocked_tasks": unblocked,
"updated_milestones": milestones
})))
}
"tech_debts/resolve" => {
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
let debt_id = params.get("id").and_then(|v| v.as_str()).unwrap_or("");
if debt_id.is_empty() {
return Some(crate::mcp::error(id, -32602, "Missing debt id parameter"));
}
let mut found = false;
self.state.code.tech_debts.modify(|debts| {
for d in debts.iter_mut() {
if d.id == debt_id {
d.is_resolved = true;
found = true;
break;
}
}
});
if found {
self.state.record_activity("tech_debt", &format!("Resolved tech debt via WebSocket RPC: {}", debt_id), Some("resolved"));
let _ = self.state.event_bus_tx.send(crate::state::GenericEvent {
topic: "tech_debt:event".to_string(),
session_id: None,
payload: serde_json::json!({
"id": debt_id,
"status": "resolved"
}),
});
Some(crate::mcp::success(id, serde_json::json!({ "status": "success", "message": format!("Tech debt {} resolved", debt_id) })))
} else {
Some(crate::mcp::error(id, -32602, "Tech debt not found"))
}
}
"gates/authorize" => {
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
let gate_id = params.get("id").and_then(|v| v.as_str()).unwrap_or("");
let mut found = false;
self.state.env.gates.modify(|gates| {
for g in gates.iter_mut() {
if g.id == gate_id {
g.status = "authorized".to_string();
g.reason = Some("Manually authorized via WebSocket RPC".to_string());
g.timestamp = crate::handlers::utils::now_secs();
found = true;
break;
}
}
});
if found {
self.state.record_activity("gate", &format!("Authorized push gate via WebSocket: {}", gate_id), Some("authorized"));
let _ = self.state.event_bus_tx.send(crate::state::GenericEvent {
topic: "gate:event".to_string(),
session_id: None,
payload: serde_json::json!({ "id": gate_id, "status": "authorized", "active_gate": "authorized" }),
});
Some(crate::mcp::success(id, serde_json::json!({ "status": "success" })))
} else {
Some(crate::mcp::error(id, -32602, "Gate not found"))
}
}
"gates/block" => {
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
let gate_id = params.get("id").and_then(|v| v.as_str()).unwrap_or("");
let mut found = false;
self.state.env.gates.modify(|gates| {
for g in gates.iter_mut() {
if g.id == gate_id {
g.status = "blocked".to_string();
g.reason = Some("Manually blocked via WebSocket RPC".to_string());
g.timestamp = crate::handlers::utils::now_secs();
found = true;
break;
}
}
});
if found {
self.state.record_activity("gate", &format!("Blocked push gate via WebSocket: {}", gate_id), Some("blocked"));
let _ = self.state.event_bus_tx.send(crate::state::GenericEvent {
topic: "gate:event".to_string(),
session_id: None,
payload: serde_json::json!({ "id": gate_id, "status": "blocked", "active_gate": "blocked" }),
});
Some(crate::mcp::success(id, serde_json::json!({ "status": "success" })))
} else {
Some(crate::mcp::error(id, -32602, "Gate not found"))
}
}
"gates/delete" => {
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
let gate_id = params.get("id").and_then(|v| v.as_str()).unwrap_or("");
let mut removed = false;
self.state.env.gates.modify(|gates| {
if let Some(pos) = gates.iter().position(|g| g.id == gate_id) {
gates.remove(pos);
removed = true;
}
});
if removed {
self.state.record_activity("gate", &format!("Deleted push gate via WebSocket: {}", gate_id), Some("deleted"));
let _ = self.state.event_bus_tx.send(crate::state::GenericEvent {
topic: "gate:event".to_string(),
session_id: None,
payload: serde_json::json!({ "id": gate_id, "status": "deleted" }),
});
Some(crate::mcp::success(id, serde_json::json!({ "status": "success" })))
} else {
Some(crate::mcp::error(id, -32602, "Gate not found"))
}
}
"resources/subscribe" => {
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
let uri = params.get("uri").and_then(|u| u.as_str()).unwrap_or("");
tracing::info!("Client subscribed to MCP resource: {}", uri);
Some(crate::mcp::success(id, serde_json::json!({})))
}
"resources/unsubscribe" => {
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
let uri = params.get("uri").and_then(|u| u.as_str()).unwrap_or("");
tracing::info!("Client unsubscribed from MCP resource: {}", uri);
Some(crate::mcp::success(id, serde_json::json!({})))
}
m if m.starts_with("notifications/") => None, m if m.starts_with("notifications/") => None,
"ping" => Some(crate::mcp::success(id, serde_json::json!({}))), "ping" => Some(crate::mcp::success(id, serde_json::json!({}))),
_ => { _ => {
+28
View File
@@ -179,6 +179,34 @@ impl MemoryIndex {
}) })
} }
pub fn index_clipboard_image(
&self,
id: &str,
file_path: &str,
ocr_text: &str,
) -> tokio::task::JoinHandle<tantivy::Result<()>> {
let writer = Arc::clone(&self.writer);
let id_field = self.id_field;
let id_val = id.to_string();
let needs_commit = Arc::clone(&self.needs_commit);
let doc = doc!(
self.id_field => id,
self.title_field => format!("Clipboard Screenshot: {}", file_path),
self.body_field => ocr_text,
self.type_field => "clipboard_image",
self.namespace_field => "global"
);
tokio::task::spawn_blocking(move || {
let writer = writer.lock().unwrap_or_else(|e| e.into_inner());
writer.delete_term(tantivy::Term::from_field_text(id_field, &id_val));
writer.add_document(doc)?;
needs_commit.store(true, std::sync::atomic::Ordering::SeqCst);
Ok(())
})
}
pub fn delete_document(&self, id: &str) -> tokio::task::JoinHandle<tantivy::Result<()>> { pub fn delete_document(&self, id: &str) -> tokio::task::JoinHandle<tantivy::Result<()>> {
let writer = Arc::clone(&self.writer); let writer = Arc::clone(&self.writer);
let id_field = self.id_field; let id_field = self.id_field;
+110 -1
View File
@@ -42,6 +42,78 @@ pub struct TelemetryStores {
pub agent_signals: Store<Vec<AgentSignal>>, pub agent_signals: Store<Vec<AgentSignal>>,
} }
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct CachedClipboardImage {
pub file_path: String,
pub file_path_wsl: String,
pub captured_at_epoch_ms: u64,
pub age: String,
pub width: u32,
pub height: u32,
pub size_bytes: usize,
pub ocr_text: Option<String>,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct CachedClipboardText {
pub text: String,
pub captured_at_epoch_ms: u64,
pub age: String,
pub char_count: usize,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ClipboardHistoryItem {
Image(CachedClipboardImage),
Text(CachedClipboardText),
}
#[derive(Default)]
pub struct ClipboardCacheState {
pub last_image: Option<CachedClipboardImage>,
pub last_text: Option<CachedClipboardText>,
pub history: std::collections::VecDeque<ClipboardHistoryItem>,
}
impl ClipboardCacheState {
pub fn push_image(&mut self, image: CachedClipboardImage) {
self.last_image = Some(image.clone());
self.history.push_front(ClipboardHistoryItem::Image(image));
if self.history.len() > 20 {
self.history.pop_back();
}
}
pub fn push_text(&mut self, text: CachedClipboardText) {
if let Some(ref prev) = self.last_text {
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 struct MemoryState {
pub base_dir: PathBuf, pub base_dir: PathBuf,
pub index_commit_notify: Arc<tokio::sync::Notify>, pub index_commit_notify: Arc<tokio::sync::Notify>,
@@ -59,6 +131,7 @@ pub struct MemoryState {
pub activity_tx: tokio::sync::broadcast::Sender<String>, pub activity_tx: tokio::sync::broadcast::Sender<String>,
pub event_bus_tx: tokio::sync::broadcast::Sender<GenericEvent>, pub event_bus_tx: tokio::sync::broadcast::Sender<GenericEvent>,
pub ollama: Arc<crate::ollama::OllamaClient>, pub ollama: Arc<crate::ollama::OllamaClient>,
pub clipboard_cache: Arc<tokio::sync::RwLock<ClipboardCacheState>>,
} }
impl MemoryState { impl MemoryState {
@@ -133,6 +206,7 @@ impl MemoryState {
activity_tx: tokio::sync::broadcast::channel(100).0, activity_tx: tokio::sync::broadcast::channel(100).0,
event_bus_tx: tokio::sync::broadcast::channel(1000).0, event_bus_tx: tokio::sync::broadcast::channel(1000).0,
clipboard_cache: Arc::new(tokio::sync::RwLock::new(ClipboardCacheState::default())),
}; };
// Normalize pre-existing graph entity and relation types // Normalize pre-existing graph entity and relation types
@@ -162,6 +236,19 @@ impl MemoryState {
pub fn modify_graph<F: FnOnce(&mut KnowledgeGraph)>(&self, update_fn: F) { pub fn modify_graph<F: FnOnce(&mut KnowledgeGraph)>(&self, update_fn: F) {
self.graph.modify(update_fn); self.graph.modify(update_fn);
let threshold: usize = std::env::var("MCP_MEMORY_CONDENSE_THRESHOLD")
.unwrap_or_else(|_| "100".to_string())
.parse()
.unwrap_or(100);
let count = self.graph.read_with(|g| g.entities.len());
if count >= threshold {
self.condense_notify.notify_one();
}
let _ = self.event_bus_tx.send(GenericEvent {
topic: "resource:updated".to_string(),
session_id: None,
payload: serde_json::json!({ "uri": "memory://graph" }),
});
} }
pub async fn get_search_index(&self) -> MemoryIndex { pub async fn get_search_index(&self) -> MemoryIndex {
@@ -283,7 +370,24 @@ impl MemoryState {
} }
pub fn broadcast_task_event(&self, event: TaskEvent) { pub fn broadcast_task_event(&self, event: TaskEvent) {
let payload_val = serde_json::to_value(&event).unwrap_or_default(); let mut payload_val = serde_json::to_value(&event).unwrap_or_default();
if let Some(obj) = payload_val.as_object_mut() {
let active_task = self.project.tasks.read_with(|tasks| {
tasks
.iter()
.find(|t| t.is_active())
.or_else(|| {
tasks
.iter()
.find(|t| t.status == "pending" && t.parent_id.is_none())
})
.map(|t| t.title.clone())
});
obj.insert(
"active_task".to_string(),
serde_json::to_value(active_task).unwrap_or_default(),
);
}
let summary_str = format!("Task {} -> {}", event.task_id, event.status); let summary_str = format!("Task {} -> {}", event.task_id, event.status);
let details_str = payload_val.to_string(); let details_str = payload_val.to_string();
@@ -315,6 +419,11 @@ impl MemoryState {
payload: payload_val, payload: payload_val,
}; };
let _ = self.event_bus_tx.send(generic_ev); let _ = self.event_bus_tx.send(generic_ev);
let _ = self.event_bus_tx.send(GenericEvent {
topic: "resource:updated".to_string(),
session_id: None,
payload: serde_json::json!({ "uri": "memory://tasks/active" }),
});
let ws_notification = serde_json::json!({ let ws_notification = serde_json::json!({
"jsonrpc": "2.0", "jsonrpc": "2.0",
+24 -5
View File
@@ -984,24 +984,43 @@ pub struct EnvironmentTool {
pub requires_vpn: Option<bool>, pub requires_vpn: Option<bool>,
/// Optional namespace. /// Optional namespace.
pub namespace: Option<String>, pub namespace: Option<String>,
/// Optional operating system name (e.g. 'windows', 'linux', 'darwin').
pub os: Option<String>,
/// Optional shell name (e.g. 'pwsh', 'bash', 'zsh', 'cmd').
pub shell: Option<String>,
} }
#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] #[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum ClipboardAction { pub enum ClipboardAction {
#[serde(alias = "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")] #[serde(alias = "read", alias = "READ", alias = "Read")]
Read, Read,
#[serde(alias = "write", alias = "WRITE", alias = "Write")] #[serde(alias = "write", alias = "WRITE", alias = "Write")]
Write, Write,
} }
/// OS Clipboard management (read, write). /// Smart OS Clipboard management with overwrite-immune screenshot caching and OCR.
/// Actions: /// Actions:
/// - 'read': Read current clipboard contents. /// - 'image': Get latest screenshot image path (Windows + WSL) and extracted verbatim OCR text.
/// Returns cached screenshot even if text was copied afterwards!
/// - 'text': Get latest clipboard text (or normalized Markdown if HTML was copied).
/// - 'history': View recent clipboard history ring buffer (images and text with timestamps).
/// - 'clear': Clear OS clipboard and memory cache.
/// - 'read': Read current clipboard contents (legacy alias).
/// - 'write': Write content to OS clipboard. Optional: text, html, files, image_path. /// - 'write': Write content to OS clipboard. Optional: text, html, files, image_path.
/// Next steps on error: Provide text/html/files/image_path when calling 'write'. ///
/// Triggers: Call 'image' immediately when user says "look at image in clipboard", "see screenshot",
/// "look at clipboard", "what I copied", or shares terminal error captures.
#[derive(Debug, Deserialize, Serialize, JsonSchema)] #[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ClipboardTool { pub struct ClipboardTool {
/// Action to perform: 'read' or 'write'. /// Action to perform: 'image', 'text', 'history', 'clear', 'read', or 'write'.
pub action: ClipboardAction, pub action: ClipboardAction,
/// Plain text content (for 'write'). /// Plain text content (for 'write').
pub text: Option<String>, pub text: Option<String>,
+22 -1
View File
@@ -116,6 +116,8 @@ async fn trigger_autonomous_review(path: &Path, state: Arc<MemoryState>) {
let file_str = path.to_string_lossy().to_string(); 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 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| { state.modify_graph(|g| {
// Ensure File entity exists // Ensure File entity exists
g.entities.entry(file_str.clone()).or_insert_with(|| { g.entities.entry(file_str.clone()).or_insert_with(|| {
@@ -147,7 +149,8 @@ async fn trigger_autonomous_review(path: &Path, state: Arc<MemoryState>) {
created_at: Some(now), created_at: Some(now),
updated_at: Some(now), updated_at: Some(now),
..Default::default() ..Default::default()
}); }
);
let rel = crate::models::Relation { let rel = crate::models::Relation {
from: file_str.clone(), from: file_str.clone(),
@@ -161,6 +164,24 @@ async fn trigger_autonomous_review(path: &Path, state: Arc<MemoryState>) {
} }
} }
}); });
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"
}),
});
} }
} }
} }
+9 -2
View File
@@ -129,8 +129,15 @@ fn test_dashboard_route_parity() {
} else { } else {
full_call full_call
}; };
let normalized = clean.replace("${id}", "{id}"); let mut normalized = clean.to_string();
endpoint = normalized.to_string(); while let Some(start_var) = normalized.find("${") {
if let Some(end_var) = normalized[start_var..].find('}') {
normalized.replace_range(start_var..=start_var + end_var, "{id}");
} else {
break;
}
}
endpoint = normalized;
} }
} }
} }
+3
View File
@@ -17,3 +17,6 @@ serde_json = "1.0.151"
mcp-stdio = { version = "1.0.0", path = "../mcp-stdio" } mcp-stdio = { version = "1.0.0", path = "../mcp-stdio" }
regex = "1.13.1" regex = "1.13.1"
async-channel = "2.5.0" async-channel = "2.5.0"
[build-dependencies]
chrono = "0.4.45"
+20 -3
View File
@@ -1,22 +1,39 @@
use std::process::Command; use std::process::Command;
fn main() { fn main() {
let git_hash = Command::new("git") let mut git_hash = Command::new("git")
.args(["rev-parse", "--short", "HEAD"]) .args(["rev-parse", "--short", "HEAD"])
.output() .output()
.ok() .ok()
.and_then(|out| String::from_utf8(out.stdout).ok()) .and_then(|out| String::from_utf8(out.stdout).ok())
.unwrap_or_else(|| "unknown".to_string()); .unwrap_or_else(|| "unknown".to_string());
git_hash = git_hash.trim().to_string();
let is_dirty = Command::new("git")
.args(["status", "--porcelain"])
.output()
.is_ok_and(|out| !out.stdout.is_empty());
if is_dirty {
let dirty_ts = chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string();
git_hash.push_str(&format!("-dirty-{dirty_ts}"));
}
let git_date = Command::new("git") let git_date = Command::new("git")
.args(["log", "-1", "--format=%cd", "--date=format:%y.%m.%d.%H%M%S"]) .args(["log", "-1", "--format=%cd", "--date=format:%y.%m.%d.%H%M%S"])
.output() .output()
.ok() .ok()
.and_then(|out| String::from_utf8(out.stdout).ok()) .and_then(|out| String::from_utf8(out.stdout).ok())
.unwrap_or_else(|| "unknown".to_string()); .map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string());
let version = format!("{} ({})", git_date.trim(), git_hash.trim()); let version = format!("v{git_date} ({git_hash})");
println!("cargo:rustc-env=APP_VERSION={version}"); println!("cargo:rustc-env=APP_VERSION={version}");
println!("cargo:rerun-if-changed=../.git/HEAD"); println!("cargo:rerun-if-changed=../.git/HEAD");
println!("cargo:rerun-if-changed=../.git/index"); println!("cargo:rerun-if-changed=../.git/index");
println!("cargo:rerun-if-changed=src");
println!("cargo:rerun-if-changed=Cargo.toml");
println!("cargo:rerun-if-changed=build.rs");
} }