Compare commits
2
Commits
64857f9d5e
...
8bc8c97504
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bc8c97504 | ||
|
|
f4dcf3acbf |
No files matched your search
@@ -9,3 +9,4 @@ llvm-cov.zip
|
||||
temp.json
|
||||
*.txt
|
||||
summary.md
|
||||
__pycache__/
|
||||
Generated
+3
@@ -2661,6 +2661,7 @@ dependencies = [
|
||||
"tree-sitter-rust",
|
||||
"tree-sitter-typescript",
|
||||
"uuid",
|
||||
"windows",
|
||||
"windows-sys 0.59.0",
|
||||
"winres",
|
||||
]
|
||||
@@ -2670,6 +2671,7 @@ name = "mcp-memory-stub"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"chrono",
|
||||
"clap",
|
||||
"dirs 7.0.0",
|
||||
"futures-util",
|
||||
@@ -2988,6 +2990,7 @@ name = "nvim-core"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"dirs 7.0.0",
|
||||
"mcp-stdio",
|
||||
|
||||
@@ -42,6 +42,12 @@ Before making expensive active tool calls, read available MCP resources:
|
||||
- **Hypotheses & Root Cause Analysis**: When diagnosing complex bugs or race conditions, call `hypotheses` (action: "log" / "query") to record test evidence and maintain reasoning trails across sessions.
|
||||
- **Inter-Agent Coordination**: Autonomous subagents should call `agent_signals` (action: "broadcast" / "query") to publish events and discover peer agent status.
|
||||
- **Process & Daemon Logs**: Query or tail daemon logs with `process_logs` (action: "get", "watch", "clear") instead of dumping log files into context.
|
||||
- **Clipboard & Multimodal Screenshots**:
|
||||
- **User Image & Visual Queries**: When the user says "look at image in clipboard", "see screenshot", "check the clipboard", or refers to an error screenshot, ALWAYS call `clipboard(action: "image")`.
|
||||
- **Decoupled Screenshot History**: Even if the user got sidetracked and copied text/URLs afterwards, `clipboard(action: "image")` reliably retrieves the last captured screenshot from the event-driven cache with exact metadata (`file_path`, `file_path_wsl`, `age`, `dimensions`, `ocr_text`).
|
||||
- **WSL Ubuntu Compatibility**: For WSL sessions, use the returned `file_path_wsl` (e.g. `/mnt/c/...`) directly with `view_file`.
|
||||
- **Text-Only Models & Fast Context**: Use the returned deterministic `ocr_text` to immediately extract error traces, URLs, and code snippets without waiting for multimodal vision tokens.
|
||||
- **Copying Formatted Tables / Data**: `clipboard(action: "read")` automatically strips Win32 `CF_HTML` headers and converts HTML tables directly into clean GFM Markdown.
|
||||
- **End-of-Session Handoff**: When finishing work or logging off, trigger the `handoff_routine` prompt or call `generate_standup_report` and `handoff_memos` (action: "leave").
|
||||
|
||||
## 3. Delegation
|
||||
|
||||
+25
-6
@@ -1,20 +1,39 @@
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
let git_hash = Command::new("git")
|
||||
.args(&["rev-parse", "--short", "HEAD"])
|
||||
let mut git_hash = Command::new("git")
|
||||
.args(["rev-parse", "--short", "HEAD"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|out| String::from_utf8(out.stdout).ok())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
git_hash = git_hash.trim().to_string();
|
||||
|
||||
let is_dirty = Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.output()
|
||||
.is_ok_and(|out| !out.stdout.is_empty());
|
||||
|
||||
if is_dirty {
|
||||
let dirty_ts = chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string();
|
||||
git_hash.push_str(&format!("-dirty-{dirty_ts}"));
|
||||
}
|
||||
|
||||
let git_date = Command::new("git")
|
||||
.args(&["log", "-1", "--format=%cd", "--date=format:%Y.%m.%d"])
|
||||
.args(["log", "-1", "--format=%cd", "--date=format:%y.%m.%d.%H%M%S"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|out| String::from_utf8(out.stdout).ok())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string());
|
||||
|
||||
let version = format!("{} ({} {})", env!("CARGO_PKG_VERSION"), git_date.trim(), git_hash.trim());
|
||||
println!("cargo:rustc-env=APP_VERSION={}", version);
|
||||
let version = format!("v{git_date} ({git_hash})");
|
||||
println!("cargo:rustc-env=APP_VERSION={version}");
|
||||
println!("cargo:rerun-if-changed=../.git/HEAD");
|
||||
println!("cargo:rerun-if-changed=../.git/index");
|
||||
println!("cargo:rerun-if-changed=src");
|
||||
println!("cargo:rerun-if-changed=Cargo.toml");
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
}
|
||||
+85
-4
@@ -1,5 +1,9 @@
|
||||
-- gemini-integration.lua
|
||||
-- Registers the active Neovim instance for the Antigravity MCP Server
|
||||
|
||||
local last_active_task = nil
|
||||
local last_active_gate = nil
|
||||
|
||||
vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePost"}, {
|
||||
callback = function(ev)
|
||||
-- Only register if Neovim has an active UI attached (ignore headless/embedded instances)
|
||||
@@ -18,7 +22,7 @@ vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePos
|
||||
end
|
||||
end
|
||||
|
||||
-- 2. V2 Telemetry Push (WebSockets & Cross-OS Sync via Rust Server)
|
||||
-- 2. V2 Telemetry Push (HTTP with Knowledge Projection + UDP Fast Mirror)
|
||||
local file = vim.api.nvim_buf_get_name(0)
|
||||
local cursor = vim.api.nvim_win_get_cursor(0)
|
||||
local row, col = cursor[1], cursor[2]
|
||||
@@ -26,6 +30,7 @@ vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePos
|
||||
'{"session_id": "%s", "event": "%s", "file": "%s", "line": %d, "col": %d}',
|
||||
server_name:gsub("\\", "\\\\"), ev.event, file:gsub("\\", "\\\\"), row, col
|
||||
)
|
||||
|
||||
local current_buf = ev.buf
|
||||
vim.fn.jobstart({
|
||||
"curl", "-s", "-X", "POST", "http://127.0.0.1:3000/nvim/telemetry",
|
||||
@@ -38,11 +43,16 @@ vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePos
|
||||
local raw = table.concat(data, "")
|
||||
if raw ~= "" then
|
||||
local ok, resp = pcall(vim.fn.json_decode, raw)
|
||||
if ok and resp and (resp.tech_debts or resp.adrs) then
|
||||
if ok and resp then
|
||||
vim.schedule(function()
|
||||
if _G.gemini and _G.gemini.project_knowledge then
|
||||
if (resp.tech_debts or resp.adrs) and _G.gemini and _G.gemini.project_knowledge then
|
||||
_G.gemini.project_knowledge(resp.tech_debts, resp.adrs, current_buf)
|
||||
end
|
||||
if _G.gemini and _G.gemini.update_statusline then
|
||||
last_active_task = resp.active_task
|
||||
last_active_gate = resp.active_gate
|
||||
_G.gemini.update_statusline(resp.active_task, resp.active_gate)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -50,7 +60,6 @@ vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePos
|
||||
end,
|
||||
})
|
||||
|
||||
-- 3. UDP Mirroring (Native fast sync)
|
||||
local uv = vim.uv or vim.loop
|
||||
local client = uv.new_udp()
|
||||
if client then
|
||||
@@ -64,6 +73,78 @@ vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter", "BufWritePos
|
||||
desc = "Register active Neovim instance for Gemini CLI",
|
||||
})
|
||||
|
||||
-- 3. Push-driven Server-Sent Events (SSE) subscriber (Zero Polling)
|
||||
local sse_job_id = nil
|
||||
|
||||
local function start_sse_subscriber()
|
||||
if sse_job_id then
|
||||
pcall(vim.fn.jobstop, sse_job_id)
|
||||
sse_job_id = nil
|
||||
end
|
||||
|
||||
sse_job_id = vim.fn.jobstart({
|
||||
"curl", "-s", "-N", "http://127.0.0.1:3000/api/events/stream"
|
||||
}, {
|
||||
on_stdout = function(_, data)
|
||||
if not data then return end
|
||||
for _, line in ipairs(data) do
|
||||
if line:match("^data:%s*") then
|
||||
local raw_json = line:gsub("^data:%s*", "")
|
||||
local ok, ev = pcall(vim.fn.json_decode, raw_json)
|
||||
if ok and ev and ev.payload then
|
||||
local topic = ev.topic
|
||||
local payload = ev.payload
|
||||
local changed = false
|
||||
|
||||
if topic == "task:event" then
|
||||
if payload.active_task ~= nil then
|
||||
last_active_task = payload.active_task
|
||||
changed = true
|
||||
elseif payload.status == "in_progress" or payload.status == "active" then
|
||||
last_active_task = payload.title or payload.task_id
|
||||
changed = true
|
||||
elseif payload.status == "completed" or payload.status == "expired" or payload.status == "failed" then
|
||||
if last_active_task == (payload.title or payload.task_id) then
|
||||
last_active_task = nil
|
||||
changed = true
|
||||
end
|
||||
end
|
||||
elseif topic == "gate:event" then
|
||||
if payload.active_gate ~= nil then
|
||||
last_active_gate = payload.active_gate
|
||||
changed = true
|
||||
elseif payload.status then
|
||||
last_active_gate = payload.status
|
||||
changed = true
|
||||
end
|
||||
end
|
||||
|
||||
if changed and _G.gemini and _G.gemini.update_statusline then
|
||||
vim.schedule(function()
|
||||
_G.gemini.update_statusline(last_active_task, last_active_gate)
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end,
|
||||
on_exit = function()
|
||||
sse_job_id = nil
|
||||
-- Reconnect after server restart without busy looping
|
||||
vim.defer_fn(function()
|
||||
if #vim.api.nvim_list_uis() > 0 then
|
||||
start_sse_subscriber()
|
||||
end
|
||||
end, 5000)
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
-- Initialize SSE push listener when UI is active
|
||||
if #vim.api.nvim_list_uis() > 0 then
|
||||
start_sse_subscriber()
|
||||
end
|
||||
|
||||
-- Load UI & Keymaps
|
||||
local is_win = vim.fn.has("win32") == 1
|
||||
local ui_path = is_win and "C:/Users/reazul.ashraf/workspace/rust/mcp-memory/gemini-ui.lua" or "/mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory/gemini-ui.lua"
|
||||
|
||||
@@ -348,4 +348,48 @@ function M.send_to_terminal(command)
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Antigravity Statusline Integration (Push-driven / Zero-polling)
|
||||
-- ============================================================================
|
||||
local statusline_cache = {
|
||||
text = ""
|
||||
}
|
||||
|
||||
function M.update_statusline(active_task, active_gate)
|
||||
local parts = {}
|
||||
|
||||
if active_task and active_task ~= vim.NIL and active_task ~= "" then
|
||||
local title = tostring(active_task)
|
||||
if #title > 24 then
|
||||
title = title:sub(1, 22) .. ".."
|
||||
end
|
||||
table.insert(parts, "📋 " .. title)
|
||||
end
|
||||
|
||||
if active_gate and active_gate ~= vim.NIL and active_gate ~= "" then
|
||||
local status = tostring(active_gate):lower()
|
||||
if status == "authorized" then
|
||||
table.insert(parts, "🟢 PUSH:OK")
|
||||
elseif status == "blocked" then
|
||||
table.insert(parts, "🔴 PUSH:BLOCKED")
|
||||
else
|
||||
table.insert(parts, "🟡 PUSH:WAIT")
|
||||
end
|
||||
end
|
||||
|
||||
if #parts > 0 then
|
||||
statusline_cache.text = " [AGY: " .. table.concat(parts, " | ") .. "] "
|
||||
else
|
||||
statusline_cache.text = ""
|
||||
end
|
||||
|
||||
vim.schedule(function()
|
||||
pcall(vim.cmd, "redrawstatus")
|
||||
end)
|
||||
end
|
||||
|
||||
function M.statusline()
|
||||
return statusline_cache.text
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -28,7 +28,7 @@ deploy-win: deploy-server deploy-stub-win deploy-nvim-win deploy-rules-win
|
||||
# Stop the background server gracefully and verify port release
|
||||
stop port="3000":
|
||||
@Write-Host "Shutting down MCP server gracefully on port {{port}}..." -ForegroundColor Cyan
|
||||
-& { $token = Get-Content "$env:USERPROFILE\.gemini\mcp_memory\admin.token" -ErrorAction SilentlyContinue; if ($token) { $hdr = @{ Authorization = "Bearer $($token.Trim())" }; $res = Invoke-RestMethod -Uri "http://127.0.0.1:{{port}}/shutdown" -Method Post -Headers $hdr -ErrorAction SilentlyContinue } else { $res = Invoke-RestMethod -Uri "http://127.0.0.1:{{port}}/shutdown" -Method Post -ErrorAction SilentlyContinue }; if ($res) { Write-Host "✅ Shutdown acknowledged with version details:" -ForegroundColor Green; $res | ConvertTo-Json -Depth 5 }; $stopped = $false; for ($i = 1; $i -le 10; $i++) { $conn = Get-NetTCPConnection -LocalPort {{port}} -State Listen -ErrorAction SilentlyContinue; $proc = Get-Process mcp-memory-server -ErrorAction SilentlyContinue; if (-not $conn -and -not $proc) { $stopped = $true; Write-Host '✅ Server process and port {{port}} fully terminated.' -ForegroundColor Green; break }; Start-Sleep -Seconds 1 }; if (-not $stopped) { Write-Host '⚠️ Server process did not exit within 10s. Force killing process...' -ForegroundColor Yellow; Get-Process mcp-memory-server -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue; Get-NetTCPConnection -LocalPort {{port}} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue }; Start-Sleep -Seconds 2 } }
|
||||
-& { $token = Get-Content "$env:USERPROFILE\.gemini\mcp_memory\admin.token" -ErrorAction SilentlyContinue; if ($token) { $hdr = @{ Authorization = "Bearer $($token.Trim())" }; $res = Invoke-RestMethod -Uri "http://127.0.0.1:{{port}}/shutdown" -Method Post -Headers $hdr -ErrorAction SilentlyContinue } else { $res = Invoke-RestMethod -Uri "http://127.0.0.1:{{port}}/shutdown" -Method Post -ErrorAction SilentlyContinue }; if ($res) { Write-Host "✅ Shutdown acknowledged with version details:" -ForegroundColor Green; $res | ConvertTo-Json -Depth 5 }; $procs = @(Get-Process mcp-memory-server -ErrorAction SilentlyContinue); if ($procs.Count -gt 0) { foreach ($p in $procs) { $exited = $p.WaitForExit(10000); if (-not $exited) { Write-Host "⚠️ Process $($p.Id) did not exit within 10s. Force killing..." -ForegroundColor Yellow; $p | Stop-Process -Force -ErrorAction SilentlyContinue } } }; $conns = Get-NetTCPConnection -LocalPort {{port}} -State Listen -ErrorAction SilentlyContinue; if ($conns) { $conns | Select-Object -ExpandProperty OwningProcess | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue } } }
|
||||
@Write-Host "✅ Shutdown complete. Port {{port}} and process are free." -ForegroundColor Green
|
||||
|
||||
# Start the server in the background and verify /health
|
||||
@@ -125,12 +125,12 @@ deploy-wsl: deploy-stub deploy-nvim deploy-rules-wsl
|
||||
# Deploy WSL-native stub binary (skips if identical checksum)
|
||||
deploy-stub:
|
||||
@Write-Host "Deploying WSL Stub natively..." -ForegroundColor Cyan
|
||||
wsl.exe -d Ubuntu -e bash -c 'export PATH="/home/riz/.cargo/bin:$PATH" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && src="target/release/mcp-memory-stub" && dest="/home/riz/.local/bin/mcp-memory-stub" && if [ -f "$dest" ] && cmp -s "$src" "$dest"; then echo -e "\033[33mSkipping $dest (identical checksum).\033[0m"; else cp "$src" "$dest" && echo -e "\033[32mCopied updated $src to $dest\033[0m"; fi'
|
||||
wsl.exe -d Ubuntu -e bash -c 'export PATH="/home/riz/.cargo/bin:$PATH" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && src="target/release/mcp-memory-stub" && dest="/home/riz/.local/bin/mcp-memory-stub" && if [ -f "$dest" ] && cmp -s "$src" "$dest"; then echo -e "\033[33mSkipping $dest (identical checksum).\033[0m"; else cp --remove-destination "$src" "$dest" && echo -e "\033[32mCopied updated $src to $dest\033[0m"; fi'
|
||||
|
||||
# Deploy WSL-native Neovim plugin binary (skips if identical checksum)
|
||||
deploy-nvim:
|
||||
@Write-Host "Deploying WSL Nvim natively..." -ForegroundColor Cyan
|
||||
wsl.exe -d Ubuntu -e bash -c 'export PATH="/home/riz/.cargo/bin:$PATH" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && src="target/release/mcp-memory-nvim" && dest="/home/riz/.local/bin/mcp-memory-nvim" && if [ -f "$dest" ] && cmp -s "$src" "$dest"; then echo -e "\033[33mSkipping $dest (identical checksum).\033[0m"; else cp "$src" "$dest" && echo -e "\033[32mCopied updated $src to $dest\033[0m"; fi'
|
||||
wsl.exe -d Ubuntu -e bash -c 'export PATH="/home/riz/.cargo/bin:$PATH" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && src="target/release/mcp-memory-nvim" && dest="/home/riz/.local/bin/mcp-memory-nvim" && if [ -f "$dest" ] && cmp -s "$src" "$dest"; then echo -e "\033[33mSkipping $dest (identical checksum).\033[0m"; else cp --remove-destination "$src" "$dest" && echo -e "\033[32mCopied updated $src to $dest\033[0m"; fi'
|
||||
|
||||
# Deploy global Agent rules to WSL config directory (skips identical files)
|
||||
deploy-rules-wsl:
|
||||
@@ -191,8 +191,8 @@ all-nvim-wsl: build-nvim deploy-nvim
|
||||
# Complete lifecycle: stop, build, deploy, start, and verify WSL server
|
||||
all-server-wsl port="3000": (stop port) build-server deploy-server (start port) (verify port)
|
||||
|
||||
# Install git post-commit hook for automatic ADR/Task/TechDebt state reconciliation
|
||||
# Install git hooks (post-commit reconciliation and pre-push gatekeeper)
|
||||
install-git-hooks:
|
||||
@Write-Host "Installing git post-commit reconciliation hook..." -ForegroundColor Cyan
|
||||
-& { $hookContent = "#!/bin/sh`npython3 scripts/git-reconcile.py 2>/dev/null || python scripts/git-reconcile.py 2>/dev/null || true`n"; $hookPath = ".git\hooks\post-commit"; [System.IO.File]::WriteAllText($hookPath, $hookContent); Write-Host "✅ Git post-commit hook installed to $hookPath" -ForegroundColor Green }
|
||||
@Write-Host "Installing git post-commit and pre-push hooks..." -ForegroundColor Cyan
|
||||
-& { $postCommitContent = "#!/bin/sh`npython3 scripts/git-reconcile.py 2>/dev/null || python scripts/git-reconcile.py 2>/dev/null || true`n"; [System.IO.File]::WriteAllText(".git\hooks\post-commit", $postCommitContent); $prePushContent = "#!/bin/sh`nif command -v python3 >/dev/null 2>&1; then`n exec python3 scripts/git-pre-push.py `"`$@`"`nelse`n exec python scripts/git-pre-push.py `"`$@`"`nfi`n"; [System.IO.File]::WriteAllText(".git\hooks\pre-push", $prePushContent); Write-Host "✅ Git post-commit and pre-push hooks installed." -ForegroundColor Green }
|
||||
|
||||
@@ -17,3 +17,6 @@ mcp-stdio = { version = "1.0.0", path = "../mcp-stdio" }
|
||||
bytes = "1.12.1"
|
||||
dashmap = "6.2.1"
|
||||
|
||||
[build-dependencies]
|
||||
chrono = "0.4.45"
|
||||
|
||||
+20
-3
@@ -1,22 +1,39 @@
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
let git_hash = Command::new("git")
|
||||
let mut git_hash = Command::new("git")
|
||||
.args(["rev-parse", "--short", "HEAD"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|out| String::from_utf8(out.stdout).ok())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
git_hash = git_hash.trim().to_string();
|
||||
|
||||
let is_dirty = Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.output()
|
||||
.is_ok_and(|out| !out.stdout.is_empty());
|
||||
|
||||
if is_dirty {
|
||||
let dirty_ts = chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string();
|
||||
git_hash.push_str(&format!("-dirty-{dirty_ts}"));
|
||||
}
|
||||
|
||||
let git_date = Command::new("git")
|
||||
.args(["log", "-1", "--format=%cd", "--date=format:%y.%m.%d.%H%M%S"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|out| String::from_utf8(out.stdout).ok())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string());
|
||||
|
||||
let version = format!("{} ({})", git_date.trim(), git_hash.trim());
|
||||
let version = format!("v{git_date} ({git_hash})");
|
||||
println!("cargo:rustc-env=APP_VERSION={version}");
|
||||
println!("cargo:rerun-if-changed=../.git/HEAD");
|
||||
println!("cargo:rerun-if-changed=../.git/index");
|
||||
println!("cargo:rerun-if-changed=src");
|
||||
println!("cargo:rerun-if-changed=Cargo.toml");
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
}
|
||||
+95
-4
@@ -319,6 +319,7 @@ static PENDING_REQUESTS: LazyLock<
|
||||
pub struct NvimState {
|
||||
pub cursor: String,
|
||||
pub active_buffer_id: u64,
|
||||
pub active_buffer_name: String,
|
||||
}
|
||||
|
||||
static NVIM_STATE: LazyLock<Arc<std::sync::Mutex<NvimState>>> =
|
||||
@@ -341,8 +342,27 @@ fn handle_nvim_notification(params: &[rmpv::Value]) {
|
||||
state.cursor = format!("Line: {}, Column: {}", row, col);
|
||||
}
|
||||
}
|
||||
"TextChanged" => {
|
||||
tracing::debug!("Shadow buffer text changed event received");
|
||||
"TextChanged" | "BufEnter" => {
|
||||
if params.len() > 1 {
|
||||
let buf_id = match ¶ms[1] {
|
||||
rmpv::Value::Integer(i) => i.as_u64().unwrap_or(0),
|
||||
_ => 0,
|
||||
};
|
||||
let buf_name = if params.len() > 2 {
|
||||
match ¶ms[2] {
|
||||
rmpv::Value::String(s) => s.as_str().unwrap_or("").to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let mut state = NVIM_STATE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
state.active_buffer_id = buf_id;
|
||||
if !buf_name.is_empty() {
|
||||
state.active_buffer_name = buf_name;
|
||||
}
|
||||
}
|
||||
tracing::debug!("Neovim buffer changed/entered event received");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -591,7 +611,9 @@ async fn get_nvim_connection() -> Result<mpsc::Sender<NvimRequest>, String> {
|
||||
vim.api.nvim_create_autocmd({"TextChanged", "TextChangedI", "BufEnter"}, {
|
||||
group = "MCP_Tracking",
|
||||
callback = function()
|
||||
pcall(vim.rpcnotify, channel, "mcp_event", "TextChanged", vim.api.nvim_get_current_buf())
|
||||
local b = vim.api.nvim_get_current_buf()
|
||||
local n = vim.api.nvim_buf_get_name(b)
|
||||
pcall(vim.rpcnotify, channel, "mcp_event", "TextChanged", b, n)
|
||||
end
|
||||
})
|
||||
"#;
|
||||
@@ -1686,7 +1708,7 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
|
||||
bufnr = 0
|
||||
end
|
||||
_G.gemini.preview_diff(diff_id, bufnr, start_line, end_line, lines)
|
||||
return "Preview added"
|
||||
return { status = "Preview added", diff_id = diff_id }
|
||||
else
|
||||
error("gemini UI not loaded")
|
||||
end
|
||||
@@ -1708,6 +1730,61 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
|
||||
send_error(id, -32602, "Missing arguments: requires file_path, start_line, end_line, and preview_content").await;
|
||||
}
|
||||
}
|
||||
"prompt" | "prompt_user" => {
|
||||
let prompt_text = args
|
||||
.get("prompt")
|
||||
.or_else(|| args.get("prompt_text"))
|
||||
.and_then(|v| v.as_str());
|
||||
if let Some(prompt) = prompt_text {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let request_id = args
|
||||
.get("request_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
.to_string()
|
||||
});
|
||||
|
||||
let options_arr = match args.get("options").and_then(|v| v.as_array()) {
|
||||
Some(arr) => {
|
||||
let rmp_arr: Vec<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" => {
|
||||
if let (Some(line), Some(text)) = (
|
||||
args.get("line").and_then(serde_json::Value::as_i64),
|
||||
@@ -1996,10 +2073,24 @@ mod tests {
|
||||
]),
|
||||
];
|
||||
handle_nvim_notification(¶ms);
|
||||
{
|
||||
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]
|
||||
async fn test_jsonrpc_structs_and_responses() {
|
||||
let req = JsonRpcRequest {
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -48,6 +48,7 @@ ignore = "0.4.33"
|
||||
notify = "7"
|
||||
blake3 = "1.8"
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
chrono = { version = "0.4.45", features = ["serde"] }
|
||||
|
||||
[build-dependencies]
|
||||
chrono = "0.4.45"
|
||||
@@ -58,4 +59,17 @@ tempfile = "3.27.0"
|
||||
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59.0", features = ["Win32_System_DataExchange", "Win32_System_Memory"] }
|
||||
windows-sys = { version = "0.59.0", features = [
|
||||
"Win32_System_DataExchange",
|
||||
"Win32_System_Memory",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Com",
|
||||
] }
|
||||
windows = { version = "0.62.2", features = [
|
||||
"Foundation",
|
||||
"Foundation_Collections",
|
||||
"Media_Ocr",
|
||||
"Graphics_Imaging",
|
||||
"Storage_Streams",
|
||||
] }
|
||||
+14
-3
@@ -16,15 +16,26 @@ fn main() {
|
||||
.is_ok_and(|out| !out.stdout.is_empty());
|
||||
|
||||
if is_dirty {
|
||||
git_hash.push_str("-dirty");
|
||||
let dirty_ts = chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string();
|
||||
git_hash.push_str(&format!("-dirty-{dirty_ts}"));
|
||||
}
|
||||
|
||||
let build_date = chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string();
|
||||
let git_date = Command::new("git")
|
||||
.args(["log", "-1", "--format=%cd", "--date=format:%y.%m.%d.%H%M%S"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|out| String::from_utf8(out.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string());
|
||||
|
||||
let version = format!("{build_date} ({git_hash})");
|
||||
let version = format!("v{git_date} ({git_hash})");
|
||||
println!("cargo:rustc-env=APP_VERSION={version}");
|
||||
println!("cargo:rerun-if-changed=../.git/HEAD");
|
||||
println!("cargo:rerun-if-changed=../.git/index");
|
||||
println!("cargo:rerun-if-changed=src");
|
||||
println!("cargo:rerun-if-changed=Cargo.toml");
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default() == "windows" {
|
||||
let mut res = winres::WindowsResource::new();
|
||||
|
||||
@@ -14,9 +14,20 @@ pub async fn wait_for_event_handler(
|
||||
) -> impl IntoResponse {
|
||||
let topic = params.get("topic").cloned();
|
||||
let session_id = params.get("session_id").cloned();
|
||||
let timeout_duration = params
|
||||
.get("timeout_ms")
|
||||
.and_then(|ms| ms.parse::<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 wait_future = async {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => {
|
||||
@@ -26,7 +37,7 @@ pub async fn wait_for_event_handler(
|
||||
.is_none_or(|s| Some(s) == event.session_id.as_ref());
|
||||
|
||||
if topic_matches && session_matches {
|
||||
return axum::Json(event);
|
||||
return Ok(event);
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
|
||||
@@ -34,13 +45,49 @@ pub async fn wait_for_event_handler(
|
||||
continue;
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
return axum::Json(GenericEvent {
|
||||
return Err("Event bus closed");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match timeout_duration {
|
||||
Some(dur) => match tokio::time::timeout(dur, wait_future).await {
|
||||
Ok(Ok(event)) => (axum::http::StatusCode::OK, axum::Json(event)).into_response(),
|
||||
Ok(Err(err)) => (
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
axum::Json(GenericEvent {
|
||||
topic: "error".to_string(),
|
||||
session_id: None,
|
||||
payload: serde_json::json!({"error": "Event bus closed"}),
|
||||
});
|
||||
}
|
||||
}
|
||||
payload: serde_json::json!({ "error": err }),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => (
|
||||
axum::http::StatusCode::REQUEST_TIMEOUT,
|
||||
axum::Json(GenericEvent {
|
||||
topic: "timeout".to_string(),
|
||||
session_id: None,
|
||||
payload: serde_json::json!({
|
||||
"error": "timed out waiting for event",
|
||||
"topic": topic
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
},
|
||||
None => match wait_future.await {
|
||||
Ok(event) => (axum::http::StatusCode::OK, axum::Json(event)).into_response(),
|
||||
Err(err) => (
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
axum::Json(GenericEvent {
|
||||
topic: "error".to_string(),
|
||||
session_id: None,
|
||||
payload: serde_json::json!({ "error": err }),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+22
-1
@@ -104,7 +104,28 @@ pub async fn gate_set_handler(
|
||||
};
|
||||
app_state.handler.state.env.gates.modify(|gates| {
|
||||
gates.retain(|g| !(g.action == record.action && g.target == record.target));
|
||||
gates.push(record);
|
||||
gates.push(record.clone());
|
||||
});
|
||||
|
||||
let summary = format!("Push gate {} set to {}", record.target, record.status);
|
||||
app_state
|
||||
.handler
|
||||
.state
|
||||
.record_activity("gate", &summary, record.reason.as_deref());
|
||||
let _ = app_state
|
||||
.handler
|
||||
.state
|
||||
.event_bus_tx
|
||||
.send(crate::state::GenericEvent {
|
||||
topic: "gate:event".to_string(),
|
||||
session_id: None,
|
||||
payload: serde_json::json!({
|
||||
"action": record.action,
|
||||
"target": record.target,
|
||||
"status": record.status,
|
||||
"reason": record.reason,
|
||||
"active_gate": record.status,
|
||||
}),
|
||||
});
|
||||
|
||||
Ok((axum::http::StatusCode::OK, "Gate state updated."))
|
||||
|
||||
+257
-18
@@ -6,7 +6,7 @@ use axum::{
|
||||
Router,
|
||||
extract::State,
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio_stream::StreamExt;
|
||||
@@ -123,20 +123,8 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Received shutdown request via /shutdown endpoint. Creating session checkpoint and initiating graceful shutdown."
|
||||
"Received shutdown request via /shutdown endpoint. Initiating graceful shutdown."
|
||||
);
|
||||
let mem_state = state.handler.state.clone();
|
||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||
let memo = crate::models::HandoffMemo {
|
||||
id: format!("chk_shutdown_{}", now),
|
||||
author: "ShutdownHook".to_string(),
|
||||
content: format!("Automatic session checkpoint triggered on graceful server shutdown at epoch {}", now),
|
||||
expires_at: None,
|
||||
namespace: "global".to_string(),
|
||||
timestamp: now,
|
||||
..Default::default()
|
||||
};
|
||||
mem_state.telemetry.handoff_memos.modify(|m| m.push(memo));
|
||||
|
||||
if let Some(tx) = state.shutdown_tx.lock().unwrap().take() {
|
||||
let _ = tx.send(());
|
||||
@@ -154,8 +142,7 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
|
||||
"app_version": env!("APP_VERSION"),
|
||||
"cargo_pkg_version": env!("CARGO_PKG_VERSION"),
|
||||
"target_os": std::env::consts::OS,
|
||||
"target_arch": std::env::consts::ARCH,
|
||||
"shutdown_checkpoint": format!("chk_shutdown_{}", now)
|
||||
"target_arch": std::env::consts::ARCH
|
||||
}
|
||||
})).into_response()
|
||||
},
|
||||
@@ -309,6 +296,30 @@ pub fn create_router(app_state: Arc<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(
|
||||
"/api/adrs",
|
||||
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(
|
||||
"/api/stats",
|
||||
get({
|
||||
@@ -967,7 +1111,8 @@ mod tests {
|
||||
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let summaries: Vec<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[0].summary, "Session 2 summary");
|
||||
}
|
||||
@@ -1002,9 +1147,103 @@ mod tests {
|
||||
let response = app.oneshot(request).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let adr = app_state.handler.state.code.adrs.read_with(|adrs| adrs[0].clone());
|
||||
let adr = app_state
|
||||
.handler
|
||||
.state
|
||||
.code
|
||||
.adrs
|
||||
.read_with(|adrs| adrs[0].clone());
|
||||
assert_eq!(adr.status, "implemented");
|
||||
assert_eq!(adr.git_commit.as_deref(), Some("git-hash-999"));
|
||||
assert!(adr.resolved_at.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_environment_endpoints() {
|
||||
let (app, app_state, _dir) = setup_app().await;
|
||||
|
||||
app_state.handler.state.env.environments.modify(|envs| {
|
||||
envs.push(crate::models::EnvironmentDetail {
|
||||
namespace: "default".to_string(),
|
||||
name: "staging-k8s".to_string(),
|
||||
url: "https://staging.internal.net".to_string(),
|
||||
description: "Staging cluster".to_string(),
|
||||
requires_vpn: true,
|
||||
updated_at: 12345,
|
||||
..Default::default()
|
||||
});
|
||||
});
|
||||
|
||||
let req = Request::builder()
|
||||
.uri("/api/environments")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let envs: Vec<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");
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,36 @@ pub async fn nvim_telemetry_handler(
|
||||
let _ = state.handler.state.event_bus_tx.send(event);
|
||||
}
|
||||
|
||||
if payload.event.starts_with("agent_") || payload.event.starts_with("diff_") {
|
||||
let payload_val = serde_json::json!(&payload);
|
||||
let _ = state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||
topic: format!("nvim:ui:{}", payload.event),
|
||||
session_id: Some(payload.session_id.clone()),
|
||||
payload: payload_val.clone(),
|
||||
});
|
||||
|
||||
if let Some(ref req_id) = payload.request_id {
|
||||
let _ = state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||
topic: format!("nvim:ui:{}:{}", payload.event, req_id),
|
||||
session_id: Some(payload.session_id.clone()),
|
||||
payload: payload_val.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(ref diff_id) = payload.diff_id {
|
||||
let _ = state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||
topic: format!("nvim:ui:{}:{}", payload.event, diff_id),
|
||||
session_id: Some(payload.session_id.clone()),
|
||||
payload: payload_val.clone(),
|
||||
});
|
||||
let _ = state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
|
||||
topic: format!("nvim:ui:diff_decision:{}", diff_id),
|
||||
session_id: Some(payload.session_id.clone()),
|
||||
payload: payload_val.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref f) = payload.file {
|
||||
state.handler.state.record_activity(
|
||||
"editor",
|
||||
@@ -195,10 +225,28 @@ pub async fn nvim_telemetry_handler(
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Extract active task & push gate for zero-polling statusline
|
||||
let active_task = state.handler.state.project.tasks.read_with(|tasks| {
|
||||
tasks
|
||||
.iter()
|
||||
.find(|t| t.is_active())
|
||||
.or_else(|| tasks.iter().find(|t| t.status == "pending" && t.parent_id.is_none()))
|
||||
.map(|t| t.title.clone())
|
||||
});
|
||||
|
||||
let active_gate = state.handler.state.env.gates.read_with(|gates| {
|
||||
gates
|
||||
.iter()
|
||||
.find(|g| g.action == "push" || g.gate_type.as_deref() == Some("quality_gate"))
|
||||
.map(|g| g.status.clone())
|
||||
});
|
||||
|
||||
axum::Json(serde_json::json!({
|
||||
"status": "ok",
|
||||
"tech_debts": tech_debts,
|
||||
"adrs": adrs
|
||||
"adrs": adrs,
|
||||
"active_task": active_task,
|
||||
"active_gate": active_gate
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ pub struct SessionCleanup {
|
||||
pub state: Arc<AppState>,
|
||||
pub send_task: tokio::task::JoinHandle<()>,
|
||||
pub recv_task: tokio::task::JoinHandle<()>,
|
||||
pub event_task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl Drop for SessionCleanup {
|
||||
@@ -41,6 +42,7 @@ impl Drop for SessionCleanup {
|
||||
.remove(&self.session_id);
|
||||
self.send_task.abort();
|
||||
self.recv_task.abort();
|
||||
self.event_task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,11 +149,42 @@ pub async fn handle_socket(socket: WebSocket, state: Arc<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 {
|
||||
session_id: session_id.clone(),
|
||||
state: Arc::clone(&state),
|
||||
send_task,
|
||||
recv_task,
|
||||
event_task,
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
@@ -194,6 +227,7 @@ mod tests {
|
||||
|
||||
let send_task = tokio::spawn(async {});
|
||||
let recv_task = tokio::spawn(async {});
|
||||
let event_task = tokio::spawn(async {});
|
||||
|
||||
{
|
||||
let _cleanup = SessionCleanup {
|
||||
@@ -201,6 +235,7 @@ mod tests {
|
||||
state: app_state.clone(),
|
||||
send_task,
|
||||
recv_task,
|
||||
event_task,
|
||||
};
|
||||
} // Drop happens here
|
||||
|
||||
|
||||
@@ -642,6 +642,141 @@
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Command Palette Modal */
|
||||
#cmd-palette-backdrop {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 10000;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding-top: 12vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
#cmd-palette-modal {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 680px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: paletteFadeIn 0.15s ease-out;
|
||||
}
|
||||
@keyframes paletteFadeIn {
|
||||
from { opacity: 0; transform: translateY(-10px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
.palette-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--canvas-bg);
|
||||
gap: 12px;
|
||||
}
|
||||
.palette-search-icon {
|
||||
font-size: 1.1em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.palette-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 1.05em;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
}
|
||||
.palette-kbd-badge {
|
||||
font-size: 0.72em;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
font-family: monospace;
|
||||
}
|
||||
.palette-results {
|
||||
max-height: 380px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.palette-item {
|
||||
padding: 10px 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.palette-item:hover, .palette-item.selected {
|
||||
background: rgba(52, 152, 219, 0.12);
|
||||
}
|
||||
.palette-item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.palette-item-icon {
|
||||
font-size: 1em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.palette-item-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.palette-item-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.92em;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.palette-item-sub {
|
||||
font-size: 0.78em;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.palette-item-badge {
|
||||
font-size: 0.72em;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--canvas-bg);
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.palette-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 18px;
|
||||
background: var(--canvas-bg);
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-size: 0.75em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<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>
|
||||
</div>
|
||||
<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="toggleTheme()" title="Toggle Dark Mode">🌓</button>
|
||||
<div style="font-weight: 500;"><span class="status-dot"></span>System Online</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 • <span>↵</span> to jump • <span>ESC</span> to close</div>
|
||||
<div id="palette-count" style="font-family: monospace;">0 items</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 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('snippets-tab', this)">Snippets</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 id="graph-tab" class="tab-content">
|
||||
@@ -785,6 +944,19 @@
|
||||
</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 class="panel activity-panel" style="flex:1; display:flex; flex-direction:column;">
|
||||
<div id="activity-feed" style="flex:1;"></div>
|
||||
|
||||
+506
-12
@@ -42,6 +42,9 @@ function switchTab(tabId, btn) {
|
||||
case "ledger-tab":
|
||||
loadLedger();
|
||||
break;
|
||||
case "env-tab":
|
||||
loadEnvironment();
|
||||
break;
|
||||
}
|
||||
}
|
||||
function toggleTheme() {
|
||||
@@ -291,10 +294,20 @@ async function loadGraph() {
|
||||
}
|
||||
async function completeTask(id) {
|
||||
try {
|
||||
await fetch(`/api/tasks/${id}/complete`, { method: "POST" });
|
||||
if (activeWS && activeWS.readyState === WebSocket.OPEN) {
|
||||
await sendWsRpc("tasks/complete", { id });
|
||||
} else {
|
||||
await fetch(`/api/tasks/${encodeURIComponent(id)}/complete`, { method: "POST" });
|
||||
}
|
||||
loadTasks();
|
||||
} catch (e) {
|
||||
console.error("Failed to complete task", e);
|
||||
console.error("Failed to complete task via WS, falling back to HTTP", e);
|
||||
try {
|
||||
await fetch(`/api/tasks/${encodeURIComponent(id)}/complete`, { method: "POST" });
|
||||
loadTasks();
|
||||
} catch (err) {
|
||||
console.error("Failed to complete task via HTTP fallback", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
function buildTaskTreeHTML(tasks, parentId, depth = 0) {
|
||||
@@ -683,6 +696,34 @@ async function loadActivityHistory() {
|
||||
}
|
||||
var wsConnected = false;
|
||||
var activeSSE = null;
|
||||
var activeWS = null;
|
||||
var wsRpcId = 1;
|
||||
var pendingWsRequests = new Map;
|
||||
function sendWsRpc(method, params = {}, timeoutMs = 5000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!activeWS || activeWS.readyState !== WebSocket.OPEN) {
|
||||
return reject(new Error("WebSocket not open"));
|
||||
}
|
||||
const id = ++wsRpcId;
|
||||
const timer = setTimeout(() => {
|
||||
pendingWsRequests.delete(id);
|
||||
reject(new Error(`WebSocket RPC timeout for ${method}`));
|
||||
}, timeoutMs);
|
||||
pendingWsRequests.set(id, { resolve, reject, timer });
|
||||
try {
|
||||
activeWS.send(JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method,
|
||||
params
|
||||
}));
|
||||
} catch (e) {
|
||||
clearTimeout(timer);
|
||||
pendingWsRequests.delete(id);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
var tabRefreshDebounceTimers = {};
|
||||
function requestDomainRefresh(domain) {
|
||||
const tabMap = {
|
||||
@@ -693,7 +734,9 @@ function requestDomainRefresh(domain) {
|
||||
snippets: "snippets-tab",
|
||||
terminal: "terminal-tab",
|
||||
ledger: "ledger-tab",
|
||||
memos: "memos-tab"
|
||||
memos: "memos-tab",
|
||||
env: "env-tab",
|
||||
gates: "env-tab"
|
||||
};
|
||||
const targetTab = tabMap[domain];
|
||||
if (!targetTab || targetTab !== currentTabId) {
|
||||
@@ -729,6 +772,10 @@ function requestDomainRefresh(domain) {
|
||||
case "memos":
|
||||
loadMemos();
|
||||
break;
|
||||
case "env":
|
||||
case "gates":
|
||||
loadEnvironment();
|
||||
break;
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
@@ -750,6 +797,9 @@ function handleIncomingActivity(payload) {
|
||||
requestDomainRefresh("graph");
|
||||
} else if (method === "notifications/task/completed" || category.startsWith("TASK")) {
|
||||
requestDomainRefresh("task");
|
||||
} else if (category === "GATE" || category === "QUALITY_GATE" || category === "ENVIRONMENT" || category === "ENV") {
|
||||
requestDomainRefresh("gates");
|
||||
requestDomainRefresh("env");
|
||||
} else if (category === "TECH_DEBT" || category === "ERROR_FIX" || category === "CHECKPOINT") {
|
||||
requestDomainRefresh("techdebt");
|
||||
} else if (category === "SNIPPET") {
|
||||
@@ -790,6 +840,7 @@ function setupWS() {
|
||||
}
|
||||
ws.onopen = function() {
|
||||
wsConnected = true;
|
||||
activeWS = ws;
|
||||
if (activeSSE) {
|
||||
activeSSE.close();
|
||||
activeSSE = null;
|
||||
@@ -798,7 +849,18 @@ function setupWS() {
|
||||
ws.onmessage = function(event) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === "activity" || data.method === "notifications/activity" || data.method === "notifications/task/completed" || data.method === "notifications/resources/updated") {
|
||||
if (data.id !== undefined && pendingWsRequests.has(data.id)) {
|
||||
const handler = pendingWsRequests.get(data.id);
|
||||
pendingWsRequests.delete(data.id);
|
||||
clearTimeout(handler.timer);
|
||||
if (data.error) {
|
||||
handler.reject(new Error(data.error.message || JSON.stringify(data.error)));
|
||||
} else {
|
||||
handler.resolve(data.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data.type === "activity" || data.type === "gate" || data.type === "task" || data.type === "tech_debt" || data.type === "nvim_telemetry" || data.method === "notifications/activity" || data.method === "notifications/task/completed" || data.method === "notifications/resources/updated") {
|
||||
const payload = data.params || data.data || data;
|
||||
handleIncomingActivity(payload);
|
||||
}
|
||||
@@ -808,12 +870,24 @@ function setupWS() {
|
||||
};
|
||||
ws.onclose = function() {
|
||||
wsConnected = false;
|
||||
activeWS = null;
|
||||
pendingWsRequests.forEach((handler) => {
|
||||
clearTimeout(handler.timer);
|
||||
handler.reject(new Error("WebSocket disconnected"));
|
||||
});
|
||||
pendingWsRequests.clear();
|
||||
console.log("WebSocket closed, falling back to SSE and retrying WS in 5s...");
|
||||
setupSSE();
|
||||
setTimeout(setupWS, 5000);
|
||||
};
|
||||
ws.onerror = function() {
|
||||
wsConnected = false;
|
||||
activeWS = null;
|
||||
pendingWsRequests.forEach((handler) => {
|
||||
clearTimeout(handler.timer);
|
||||
handler.reject(new Error("WebSocket error"));
|
||||
});
|
||||
pendingWsRequests.clear();
|
||||
setupSSE();
|
||||
};
|
||||
}
|
||||
@@ -1328,7 +1402,10 @@ function loadTechDebt() {
|
||||
<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>` : ""}
|
||||
</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>
|
||||
${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>
|
||||
@@ -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)} → ${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() {
|
||||
loadTerminal();
|
||||
loadTechDebt();
|
||||
@@ -1772,21 +1964,308 @@ function setupSSE() {
|
||||
console.error("SSE initialization error", e);
|
||||
}
|
||||
}
|
||||
async function resolveTechDebt(id) {
|
||||
try {
|
||||
if (activeWS && activeWS.readyState === WebSocket.OPEN) {
|
||||
await sendWsRpc("tech_debts/resolve", { id });
|
||||
} else {
|
||||
const res = await fetch(`/api/tech_debts/${encodeURIComponent(id)}/resolve`, { method: "POST" });
|
||||
if (!res.ok)
|
||||
console.error("Failed to resolve tech debt", await res.text());
|
||||
}
|
||||
loadTechDebt();
|
||||
} catch (err) {
|
||||
console.error("Error resolving tech debt via WS, trying HTTP fallback", err);
|
||||
try {
|
||||
const res = await fetch(`/api/tech_debts/${encodeURIComponent(id)}/resolve`, { method: "POST" });
|
||||
if (!res.ok)
|
||||
console.error("Failed to resolve tech debt", await res.text());
|
||||
loadTechDebt();
|
||||
} catch (fallbackErr) {
|
||||
console.error("Error resolving tech debt", fallbackErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
async function authorizeGate(id) {
|
||||
try {
|
||||
if (activeWS && activeWS.readyState === WebSocket.OPEN) {
|
||||
await sendWsRpc("gates/authorize", { id });
|
||||
} else {
|
||||
const res = await fetch(`/api/gates/${encodeURIComponent(id)}/authorize`, { method: "POST" });
|
||||
if (!res.ok)
|
||||
console.error("Failed to authorize gate", await res.text());
|
||||
}
|
||||
loadEnvironment();
|
||||
} catch (err) {
|
||||
console.error("Error authorizing gate via WS, trying HTTP fallback", err);
|
||||
try {
|
||||
const res = await fetch(`/api/gates/${encodeURIComponent(id)}/authorize`, { method: "POST" });
|
||||
if (!res.ok)
|
||||
console.error("Failed to authorize gate", await res.text());
|
||||
loadEnvironment();
|
||||
} catch (fallbackErr) {
|
||||
console.error("Error authorizing gate", fallbackErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
async function blockGate(id) {
|
||||
try {
|
||||
if (activeWS && activeWS.readyState === WebSocket.OPEN) {
|
||||
await sendWsRpc("gates/block", { id });
|
||||
} else {
|
||||
const res = await fetch(`/api/gates/${encodeURIComponent(id)}/block`, { method: "POST" });
|
||||
if (!res.ok)
|
||||
console.error("Failed to block gate", await res.text());
|
||||
}
|
||||
loadEnvironment();
|
||||
} catch (err) {
|
||||
console.error("Error blocking gate via WS, trying HTTP fallback", err);
|
||||
try {
|
||||
const res = await fetch(`/api/gates/${encodeURIComponent(id)}/block`, { method: "POST" });
|
||||
if (!res.ok)
|
||||
console.error("Failed to block gate", await res.text());
|
||||
loadEnvironment();
|
||||
} catch (fallbackErr) {
|
||||
console.error("Error blocking gate", fallbackErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
async function deleteGate(id) {
|
||||
try {
|
||||
if (activeWS && activeWS.readyState === WebSocket.OPEN) {
|
||||
await sendWsRpc("gates/delete", { id });
|
||||
} else {
|
||||
const res = await fetch(`/api/gates/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
if (!res.ok)
|
||||
console.error("Failed to delete gate", await res.text());
|
||||
}
|
||||
loadEnvironment();
|
||||
} catch (err) {
|
||||
console.error("Error deleting gate via WS, trying HTTP fallback", err);
|
||||
try {
|
||||
const res = await fetch(`/api/gates/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
if (!res.ok)
|
||||
console.error("Failed to delete gate", await res.text());
|
||||
loadEnvironment();
|
||||
} catch (fallbackErr) {
|
||||
console.error("Error deleting gate", fallbackErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
var paletteItems = [];
|
||||
var filteredPaletteItems = [];
|
||||
var paletteSelectedIndex = 0;
|
||||
var paletteDataLoaded = false;
|
||||
async function preloadPaletteData() {
|
||||
try {
|
||||
const [tasksRes, debtsRes, adrsRes, envsRes, gatesRes] = await Promise.all([
|
||||
fetch("/api/tasks", { cache: "no-store" }).then((r) => r.json()).catch(() => []),
|
||||
fetch("/api/tech_debts", { cache: "no-store" }).then((r) => r.json()).catch(() => []),
|
||||
fetch("/api/adrs", { cache: "no-store" }).then((r) => r.json()).catch(() => []),
|
||||
fetch("/api/environments", { cache: "no-store" }).then((r) => r.json()).catch(() => []),
|
||||
fetch("/api/gates", { cache: "no-store" }).then((r) => r.json()).catch(() => [])
|
||||
]);
|
||||
const items = [
|
||||
{ id: "nav-activity", category: "nav", icon: "⚡", title: "Live Activity Feed", sub: "Inspect real-time agent signals & SSE", badge: "VIEW", tabId: "activity-tab" },
|
||||
{ id: "nav-ledger", category: "nav", icon: "\uD83D\uDCD6", title: "Code Ledger", sub: "View logged code changes & commits", badge: "VIEW", tabId: "ledger-tab" },
|
||||
{ id: "nav-graph", category: "nav", icon: "\uD83D\uDD78️", title: "Knowledge Graph Explorer", sub: "Explore nodes, relationships & dependencies", badge: "VIEW", tabId: "graph-tab" },
|
||||
{ id: "nav-search", category: "nav", icon: "\uD83D\uDD0D", title: "Omni-Search", sub: "Full-text cross-entity search", badge: "VIEW", tabId: "search-tab" },
|
||||
{ id: "nav-tasks", category: "nav", icon: "\uD83D\uDCCB", title: "Task Board", sub: "HTN / DAG task hierarchies", badge: "VIEW", tabId: "task-tab" },
|
||||
{ id: "nav-techdebt", category: "nav", icon: "⚡", title: "Tech Debt & Error Fixes", sub: "Known tech debt and error fixes", badge: "VIEW", tabId: "techdebt-tab" },
|
||||
{ id: "nav-adrs", category: "nav", icon: "\uD83D\uDCDC", title: "Architectural Decision Records", sub: "ADRs and design choices", badge: "VIEW", tabId: "adrs-tab" },
|
||||
{ id: "nav-memos", category: "nav", icon: "\uD83D\uDCDD", title: "Handoff Memos", sub: "Agent handoffs & session summaries", badge: "VIEW", tabId: "memos-tab" },
|
||||
{ id: "nav-snippets", category: "nav", icon: "\uD83D\uDCCE", title: "Code Snippets", sub: "Indexed snippets & patterns", badge: "VIEW", tabId: "snippets-tab" },
|
||||
{ id: "nav-terminal", category: "nav", icon: "\uD83D\uDCBB", title: "Terminal History", sub: "Command execution log & exit codes", badge: "VIEW", tabId: "terminal-tab" },
|
||||
{ id: "nav-env", category: "nav", icon: "\uD83C\uDF10", title: "Environment & Quality Gates", sub: "Deploy targets, fingerprints, requirements & gates", badge: "VIEW", tabId: "env-tab" }
|
||||
];
|
||||
if (Array.isArray(tasksRes)) {
|
||||
tasksRes.forEach((t) => {
|
||||
items.push({
|
||||
id: t.id,
|
||||
category: "task",
|
||||
icon: t.status === "completed" ? "✓" : "\uD83D\uDCCB",
|
||||
title: t.title || t.id,
|
||||
sub: t.description || `Task (${t.status || "pending"})`,
|
||||
badge: t.status || "TASK",
|
||||
tabId: "task-tab"
|
||||
});
|
||||
});
|
||||
}
|
||||
if (Array.isArray(debtsRes)) {
|
||||
debtsRes.forEach((d) => {
|
||||
items.push({
|
||||
id: d.id,
|
||||
category: "techdebt",
|
||||
icon: "⚡",
|
||||
title: `${d.id}: ${d.description || ""}`.trim(),
|
||||
sub: d.file_path ? `${d.file_path} - Severity: ${d.severity || "medium"}` : `Severity: ${d.severity || "medium"}`,
|
||||
badge: d.is_resolved ? "RESOLVED" : d.severity || "DEBT",
|
||||
tabId: "techdebt-tab"
|
||||
});
|
||||
});
|
||||
}
|
||||
if (Array.isArray(adrsRes)) {
|
||||
adrsRes.forEach((a) => {
|
||||
items.push({
|
||||
id: a.id,
|
||||
category: "adr",
|
||||
icon: "\uD83D\uDCDC",
|
||||
title: `${a.id}: ${a.title || ""}`.trim(),
|
||||
sub: a.decision || `Status: ${a.status || "accepted"}`,
|
||||
badge: a.status || "ADR",
|
||||
tabId: "adrs-tab"
|
||||
});
|
||||
});
|
||||
}
|
||||
if (Array.isArray(envsRes)) {
|
||||
envsRes.forEach((e) => {
|
||||
items.push({
|
||||
id: e.name || e.url,
|
||||
category: "env",
|
||||
icon: "\uD83C\uDF10",
|
||||
title: e.name || "Target Environment",
|
||||
sub: `${e.url || ""} ${e.description ? "• " + e.description : ""}`.trim(),
|
||||
badge: e.env_type || "ENV",
|
||||
tabId: "env-tab"
|
||||
});
|
||||
});
|
||||
}
|
||||
if (Array.isArray(gatesRes)) {
|
||||
gatesRes.forEach((g) => {
|
||||
items.push({
|
||||
id: g.id,
|
||||
category: "gate",
|
||||
icon: g.status === "authorized" ? "\uD83D\uDFE2" : g.status === "blocked" ? "\uD83D\uDD34" : "\uD83D\uDFE1",
|
||||
title: `${g.action} → ${g.target}`,
|
||||
sub: g.reason || `Enforcer: ${g.enforcer || "gate"}`,
|
||||
badge: g.status || "GATE",
|
||||
tabId: "env-tab"
|
||||
});
|
||||
});
|
||||
}
|
||||
paletteItems = items;
|
||||
paletteDataLoaded = true;
|
||||
} catch (err) {
|
||||
console.error("Failed to preload palette data", err);
|
||||
}
|
||||
}
|
||||
function openCommandPalette() {
|
||||
const backdrop = document.getElementById("cmd-palette-backdrop");
|
||||
const input = document.getElementById("palette-input");
|
||||
if (!backdrop || !input)
|
||||
return;
|
||||
backdrop.style.display = "flex";
|
||||
input.value = "";
|
||||
paletteSelectedIndex = 0;
|
||||
preloadPaletteData().then(() => {
|
||||
onPaletteSearch("");
|
||||
input.focus();
|
||||
});
|
||||
}
|
||||
function closeCommandPalette() {
|
||||
const backdrop = document.getElementById("cmd-palette-backdrop");
|
||||
if (backdrop)
|
||||
backdrop.style.display = "none";
|
||||
}
|
||||
function onPaletteBackdropClick(e) {
|
||||
if (e.target === document.getElementById("cmd-palette-backdrop")) {
|
||||
closeCommandPalette();
|
||||
}
|
||||
}
|
||||
function onPaletteSearch(query) {
|
||||
const q = query.toLowerCase().trim();
|
||||
if (!q) {
|
||||
filteredPaletteItems = paletteItems.slice(0, 30);
|
||||
} else {
|
||||
filteredPaletteItems = paletteItems.filter((item) => item.title.toLowerCase().includes(q) || item.sub.toLowerCase().includes(q) || item.badge.toLowerCase().includes(q) || item.category.toLowerCase().includes(q)).slice(0, 40);
|
||||
}
|
||||
paletteSelectedIndex = 0;
|
||||
renderPaletteResults();
|
||||
}
|
||||
function renderPaletteResults() {
|
||||
const container = document.getElementById("palette-results");
|
||||
const countEl = document.getElementById("palette-count");
|
||||
if (!container)
|
||||
return;
|
||||
if (countEl)
|
||||
countEl.innerText = `${filteredPaletteItems.length} results`;
|
||||
if (filteredPaletteItems.length === 0) {
|
||||
container.innerHTML = '<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) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
const searchTabBtn = document.querySelector('button[onclick*="search-tab"]');
|
||||
if (searchTabBtn) {
|
||||
switchTab("search-tab", searchTabBtn);
|
||||
}
|
||||
const searchInput = document.getElementById("search-box");
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
searchInput.select();
|
||||
const backdrop = document.getElementById("cmd-palette-backdrop");
|
||||
if (backdrop && backdrop.style.display === "flex") {
|
||||
closeCommandPalette();
|
||||
} else {
|
||||
openCommandPalette();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
const backdrop = document.getElementById("cmd-palette-backdrop");
|
||||
if (backdrop && backdrop.style.display === "flex") {
|
||||
closeCommandPalette();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "/" && document.activeElement?.tagName !== "INPUT" && document.activeElement?.tagName !== "TEXTAREA") {
|
||||
const backdrop = document.getElementById("cmd-palette-backdrop");
|
||||
if (backdrop && backdrop.style.display === "flex")
|
||||
return;
|
||||
const activeSearch = document.querySelector(".tab-content.active .enterprise-search-input, .tab-content.active #task-filter-input");
|
||||
if (activeSearch) {
|
||||
e.preventDefault();
|
||||
@@ -1821,6 +2300,9 @@ function refreshActiveTab() {
|
||||
case "ledger-tab":
|
||||
loadLedger();
|
||||
break;
|
||||
case "env-tab":
|
||||
loadEnvironment();
|
||||
break;
|
||||
case "activity-tab":
|
||||
loadActivityHistory();
|
||||
break;
|
||||
@@ -1851,6 +2333,7 @@ Object.assign(window, {
|
||||
requestDomainRefresh,
|
||||
handleIncomingActivity,
|
||||
setupWS,
|
||||
sendWsRpc,
|
||||
loadGenericList,
|
||||
onListSearchInput,
|
||||
onListClearSearch,
|
||||
@@ -1866,6 +2349,17 @@ Object.assign(window, {
|
||||
loadMemos,
|
||||
loadSnippets,
|
||||
loadAllExtras,
|
||||
loadEnvironment,
|
||||
resolveTechDebt,
|
||||
authorizeGate,
|
||||
blockGate,
|
||||
deleteGate,
|
||||
openCommandPalette,
|
||||
closeCommandPalette,
|
||||
onPaletteBackdropClick,
|
||||
onPaletteSearch,
|
||||
onPaletteKeyDown,
|
||||
selectPaletteItem,
|
||||
testClipboard,
|
||||
loadVersion,
|
||||
setupSSE,
|
||||
|
||||
+578
-14
@@ -150,6 +150,9 @@ function switchTab(tabId: string, btn?: HTMLElement | null): void {
|
||||
case "ledger-tab":
|
||||
loadLedger();
|
||||
break;
|
||||
case "env-tab":
|
||||
loadEnvironment();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,10 +467,20 @@ async function loadGraph(): Promise<void> {
|
||||
// --- Task Tree (HTN/DAG) ---
|
||||
async function completeTask(id: string): Promise<void> {
|
||||
try {
|
||||
await fetch(`/api/tasks/${id}/complete`, { method: "POST" });
|
||||
if (activeWS && activeWS.readyState === WebSocket.OPEN) {
|
||||
await sendWsRpc("tasks/complete", { id });
|
||||
} else {
|
||||
await fetch(`/api/tasks/${encodeURIComponent(id)}/complete`, { method: "POST" });
|
||||
}
|
||||
loadTasks();
|
||||
} catch (e) {
|
||||
console.error("Failed to complete task", e);
|
||||
console.error("Failed to complete task via WS, falling back to HTTP", e);
|
||||
try {
|
||||
await fetch(`/api/tasks/${encodeURIComponent(id)}/complete`, { method: "POST" });
|
||||
loadTasks();
|
||||
} catch (err) {
|
||||
console.error("Failed to complete task via HTTP fallback", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -927,6 +940,36 @@ async function loadActivityHistory(): Promise<void> {
|
||||
|
||||
let wsConnected = false;
|
||||
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> = {};
|
||||
|
||||
function requestDomainRefresh(domain: string): void {
|
||||
@@ -939,6 +982,8 @@ function requestDomainRefresh(domain: string): void {
|
||||
terminal: "terminal-tab",
|
||||
ledger: "ledger-tab",
|
||||
memos: "memos-tab",
|
||||
env: "env-tab",
|
||||
gates: "env-tab",
|
||||
};
|
||||
|
||||
const targetTab = tabMap[domain];
|
||||
@@ -977,6 +1022,10 @@ function requestDomainRefresh(domain: string): void {
|
||||
case "memos":
|
||||
loadMemos();
|
||||
break;
|
||||
case "env":
|
||||
case "gates":
|
||||
loadEnvironment();
|
||||
break;
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
@@ -1007,6 +1056,14 @@ function handleIncomingActivity(payload: any): void {
|
||||
category.startsWith("TASK")
|
||||
) {
|
||||
requestDomainRefresh("task");
|
||||
} else if (
|
||||
category === "GATE" ||
|
||||
category === "QUALITY_GATE" ||
|
||||
category === "ENVIRONMENT" ||
|
||||
category === "ENV"
|
||||
) {
|
||||
requestDomainRefresh("gates");
|
||||
requestDomainRefresh("env");
|
||||
} else if (
|
||||
category === "TECH_DEBT" ||
|
||||
category === "ERROR_FIX" ||
|
||||
@@ -1056,6 +1113,7 @@ function setupWS(): void {
|
||||
|
||||
ws.onopen = function () {
|
||||
wsConnected = true;
|
||||
activeWS = ws;
|
||||
if (activeSSE) {
|
||||
activeSSE.close();
|
||||
activeSSE = null;
|
||||
@@ -1065,8 +1123,23 @@ function setupWS(): void {
|
||||
ws.onmessage = function (event: MessageEvent) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.id !== undefined && pendingWsRequests.has(data.id)) {
|
||||
const handler = pendingWsRequests.get(data.id)!;
|
||||
pendingWsRequests.delete(data.id);
|
||||
clearTimeout(handler.timer);
|
||||
if (data.error) {
|
||||
handler.reject(new Error(data.error.message || JSON.stringify(data.error)));
|
||||
} else {
|
||||
handler.resolve(data.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
data.type === "activity" ||
|
||||
data.type === "gate" ||
|
||||
data.type === "task" ||
|
||||
data.type === "tech_debt" ||
|
||||
data.type === "nvim_telemetry" ||
|
||||
data.method === "notifications/activity" ||
|
||||
data.method === "notifications/task/completed" ||
|
||||
data.method === "notifications/resources/updated"
|
||||
@@ -1081,6 +1154,12 @@ function setupWS(): void {
|
||||
|
||||
ws.onclose = function () {
|
||||
wsConnected = false;
|
||||
activeWS = null;
|
||||
pendingWsRequests.forEach((handler) => {
|
||||
clearTimeout(handler.timer);
|
||||
handler.reject(new Error("WebSocket disconnected"));
|
||||
});
|
||||
pendingWsRequests.clear();
|
||||
console.log(
|
||||
"WebSocket closed, falling back to SSE and retrying WS in 5s...",
|
||||
);
|
||||
@@ -1090,6 +1169,12 @@ function setupWS(): void {
|
||||
|
||||
ws.onerror = function () {
|
||||
wsConnected = false;
|
||||
activeWS = null;
|
||||
pendingWsRequests.forEach((handler) => {
|
||||
clearTimeout(handler.timer);
|
||||
handler.reject(new Error("WebSocket error"));
|
||||
});
|
||||
pendingWsRequests.clear();
|
||||
setupSSE();
|
||||
};
|
||||
}
|
||||
@@ -1777,7 +1862,10 @@ function loadTechDebt(): void {
|
||||
<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>` : ""}
|
||||
</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>
|
||||
${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>
|
||||
@@ -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)} → ${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 {
|
||||
loadTerminal();
|
||||
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) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
const searchTabBtn = document.querySelector(
|
||||
'button[onclick*="search-tab"]',
|
||||
) as HTMLElement | null;
|
||||
if (searchTabBtn) {
|
||||
switchTab("search-tab", searchTabBtn);
|
||||
}
|
||||
const searchInput = document.getElementById(
|
||||
"search-box",
|
||||
) as HTMLInputElement | null;
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
searchInput.select();
|
||||
const backdrop = document.getElementById("cmd-palette-backdrop");
|
||||
if (backdrop && backdrop.style.display === "flex") {
|
||||
closeCommandPalette();
|
||||
} else {
|
||||
openCommandPalette();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Escape") {
|
||||
const backdrop = document.getElementById("cmd-palette-backdrop");
|
||||
if (backdrop && backdrop.style.display === "flex") {
|
||||
closeCommandPalette();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Pressing '/' focuses the current active list search input if not already typing
|
||||
if (
|
||||
e.key === "/" &&
|
||||
document.activeElement?.tagName !== "INPUT" &&
|
||||
document.activeElement?.tagName !== "TEXTAREA"
|
||||
) {
|
||||
const backdrop = document.getElementById("cmd-palette-backdrop");
|
||||
if (backdrop && backdrop.style.display === "flex") return;
|
||||
const activeSearch = document.querySelector(
|
||||
".tab-content.active .enterprise-search-input, .tab-content.active #task-filter-input",
|
||||
) as HTMLInputElement | null;
|
||||
@@ -2405,6 +2954,9 @@ function refreshActiveTab(): void {
|
||||
case "ledger-tab":
|
||||
loadLedger();
|
||||
break;
|
||||
case "env-tab":
|
||||
loadEnvironment();
|
||||
break;
|
||||
case "activity-tab":
|
||||
loadActivityHistory();
|
||||
break;
|
||||
@@ -2437,6 +2989,7 @@ Object.assign(window, {
|
||||
requestDomainRefresh,
|
||||
handleIncomingActivity,
|
||||
setupWS,
|
||||
sendWsRpc,
|
||||
loadGenericList,
|
||||
onListSearchInput,
|
||||
onListClearSearch,
|
||||
@@ -2452,6 +3005,17 @@ Object.assign(window, {
|
||||
loadMemos,
|
||||
loadSnippets,
|
||||
loadAllExtras,
|
||||
loadEnvironment,
|
||||
resolveTechDebt,
|
||||
authorizeGate,
|
||||
blockGate,
|
||||
deleteGate,
|
||||
openCommandPalette,
|
||||
closeCommandPalette,
|
||||
onPaletteBackdropClick,
|
||||
onPaletteSearch,
|
||||
onPaletteKeyDown,
|
||||
selectPaletteItem,
|
||||
testClipboard,
|
||||
loadVersion,
|
||||
setupSSE,
|
||||
|
||||
@@ -26,13 +26,21 @@ impl McpTool for EnvironmentHandler {
|
||||
match req.action {
|
||||
EnvAction::UpdateFingerprint => {
|
||||
let tool_versions = req.tool_versions.unwrap_or_default();
|
||||
let detected_os = req.os.unwrap_or_else(|| std::env::consts::OS.to_string());
|
||||
let detected_shell = req.shell
|
||||
.or_else(|| std::env::var("SHELL").ok())
|
||||
.or_else(|| std::env::var("STARSHIP_SHELL").ok())
|
||||
.or_else(|| if std::env::var("PSModulePath").is_ok() { Some("pwsh".to_string()) } else { None })
|
||||
.or_else(|| if std::env::var("COMSPEC").is_ok() { Some("cmd".to_string()) } else { None })
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
state.env.env_fingerprints.modify(|fps| {
|
||||
fps.insert(
|
||||
ns.clone(),
|
||||
crate::models::EnvFingerprint {
|
||||
namespace: ns.clone(),
|
||||
os: std::env::consts::OS.to_string(),
|
||||
shell: std::env::var("SHELL").unwrap_or_else(|_| "unknown".to_string()),
|
||||
os: detected_os,
|
||||
shell: detected_shell,
|
||||
tool_versions,
|
||||
updated_at: crate::handlers::utils::now_secs(),
|
||||
..Default::default()
|
||||
|
||||
+800
-17
@@ -8,9 +8,229 @@ use serde_json::{Value, json};
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
static CLIPBOARD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
pub fn to_wsl_path(win_path: &str) -> String {
|
||||
let p = win_path.trim();
|
||||
if let Some(rest) = p.strip_prefix("C:\\").or_else(|| p.strip_prefix("c:\\")) {
|
||||
format!("/mnt/c/{}", rest.replace('\\', "/"))
|
||||
} else if let Some(rest) = p.strip_prefix("D:\\").or_else(|| p.strip_prefix("d:\\")) {
|
||||
format!("/mnt/d/{}", rest.replace('\\', "/"))
|
||||
} else {
|
||||
p.replace('\\', "/")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_html_fragment(raw: &str) -> &str {
|
||||
if let Some(start_idx) = raw.find("<!--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> {
|
||||
let _guard = CLIPBOARD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
for _ in 0..3 {
|
||||
@@ -25,6 +245,83 @@ pub fn get_native_clipboard_text() -> Option<String> {
|
||||
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")]
|
||||
fn get_windows_png_clipboard_image() -> Option<image::DynamicImage> {
|
||||
use windows_sys::Win32::System::DataExchange::{
|
||||
@@ -67,6 +364,7 @@ fn get_windows_png_clipboard_image() -> Option<image::DynamicImage> {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn get_native_clipboard_image() -> Option<image::DynamicImage> {
|
||||
let _guard = CLIPBOARD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -89,6 +387,242 @@ pub fn get_native_clipboard_image() -> Option<image::DynamicImage> {
|
||||
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;
|
||||
|
||||
#[async_trait]
|
||||
@@ -100,13 +634,154 @@ impl McpTool for ClipboardHandler {
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<ClipboardTool>(
|
||||
"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> {
|
||||
let req: ClipboardTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
match req.action {
|
||||
ClipboardAction::Image => {
|
||||
let cached_opt = {
|
||||
let cache = state.clipboard_cache.read().await;
|
||||
cache.last_image.clone()
|
||||
};
|
||||
|
||||
let item = if let Some(mut img) = cached_opt {
|
||||
img.age = crate::state::format_age(img.captured_at_epoch_ms);
|
||||
Some(img)
|
||||
} else {
|
||||
process_clipboard_snapshot(Arc::clone(&state)).await;
|
||||
let cache = state.clipboard_cache.read().await;
|
||||
cache.last_image.clone().map(|mut img| {
|
||||
img.age = crate::state::format_age(img.captured_at_epoch_ms);
|
||||
img
|
||||
})
|
||||
};
|
||||
|
||||
let item = if item.is_none() {
|
||||
let cache_dir = dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.join(".gemini/mcp_memory/clipboard");
|
||||
let file_path = cache_dir.join("clipboard_latest_image.jpg");
|
||||
if file_path.exists() {
|
||||
let path_str = file_path.to_string_lossy().to_string();
|
||||
let wsl_path = to_wsl_path(&path_str);
|
||||
let meta = std::fs::metadata(&file_path).ok();
|
||||
let size_bytes = meta.as_ref().map(|m| m.len() as usize).unwrap_or(0);
|
||||
let epoch_ms = meta
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or_default();
|
||||
Some(crate::state::CachedClipboardImage {
|
||||
file_path: path_str,
|
||||
file_path_wsl: wsl_path,
|
||||
captured_at_epoch_ms: epoch_ms,
|
||||
age: crate::state::format_age(epoch_ms),
|
||||
width: 0,
|
||||
height: 0,
|
||||
size_bytes,
|
||||
ocr_text: None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
item
|
||||
};
|
||||
|
||||
state.record_activity(
|
||||
"clipboard",
|
||||
"Retrieved screenshot image from clipboard cache",
|
||||
None,
|
||||
);
|
||||
|
||||
if let Some(img) = item {
|
||||
Ok(serde_json::to_string_pretty(&img)?)
|
||||
} else {
|
||||
Ok(json!({
|
||||
"status": "no_image_in_clipboard",
|
||||
"message": "No screenshot or image found in clipboard cache. Take a screenshot (Win+Shift+S on Windows) and call clipboard(action: \"image\") again."
|
||||
}).to_string())
|
||||
}
|
||||
}
|
||||
ClipboardAction::Text => {
|
||||
let cached_opt = {
|
||||
let cache = state.clipboard_cache.read().await;
|
||||
cache.last_text.clone()
|
||||
};
|
||||
|
||||
let item = if let Some(mut txt) = cached_opt {
|
||||
txt.age = crate::state::format_age(txt.captured_at_epoch_ms);
|
||||
Some(txt)
|
||||
} else {
|
||||
process_clipboard_snapshot(Arc::clone(&state)).await;
|
||||
let cache = state.clipboard_cache.read().await;
|
||||
cache.last_text.clone().map(|mut txt| {
|
||||
txt.age = crate::state::format_age(txt.captured_at_epoch_ms);
|
||||
txt
|
||||
})
|
||||
};
|
||||
|
||||
state.record_activity("clipboard", "Retrieved text from clipboard cache", None);
|
||||
if let Some(txt) = item {
|
||||
Ok(serde_json::to_string_pretty(&txt)?)
|
||||
} else {
|
||||
Ok(json!({
|
||||
"text": "",
|
||||
"status": "empty",
|
||||
"message": "Clipboard does not currently contain text."
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
}
|
||||
ClipboardAction::History => {
|
||||
let history = {
|
||||
let cache = state.clipboard_cache.read().await;
|
||||
cache
|
||||
.history
|
||||
.iter()
|
||||
.map(|item| match item {
|
||||
crate::state::ClipboardHistoryItem::Image(img) => {
|
||||
let mut img = img.clone();
|
||||
img.age = crate::state::format_age(img.captured_at_epoch_ms);
|
||||
crate::state::ClipboardHistoryItem::Image(img)
|
||||
}
|
||||
crate::state::ClipboardHistoryItem::Text(txt) => {
|
||||
let mut txt = txt.clone();
|
||||
txt.age = crate::state::format_age(txt.captured_at_epoch_ms);
|
||||
crate::state::ClipboardHistoryItem::Text(txt)
|
||||
}
|
||||
})
|
||||
.collect::<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 => {
|
||||
let (mut out, b64_opt) =
|
||||
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() {
|
||||
let mut img = dynamic_img;
|
||||
let max_dim = 1024;
|
||||
let max_dim = 1440;
|
||||
if img.width() > max_dim || img.height() > max_dim {
|
||||
img = img.resize(max_dim, max_dim, FilterType::Lanczos3);
|
||||
}
|
||||
let rgb_img = img.into_rgb8();
|
||||
|
||||
let mut jpeg_bytes = std::io::Cursor::new(Vec::new());
|
||||
if rgb_img.write_to(&mut jpeg_bytes, image::ImageFormat::Jpeg).is_ok() {
|
||||
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg_bytes, 88);
|
||||
if encoder.encode(&rgb_img, rgb_img.width(), rgb_img.height(), image::ExtendedColorType::Rgb8).is_ok() {
|
||||
let bytes = jpeg_bytes.into_inner();
|
||||
use base64::Engine;
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
@@ -136,18 +812,12 @@ impl McpTool for ClipboardHandler {
|
||||
.unwrap_or_default()
|
||||
.join(".gemini/mcp_memory/clipboard");
|
||||
let _ = std::fs::create_dir_all(&cache_dir);
|
||||
let file_path = cache_dir.join("clipboard_latest.jpg");
|
||||
let file_path = cache_dir.join("clipboard_latest_image.jpg");
|
||||
if std::fs::write(&file_path, &bytes).is_ok() {
|
||||
let path_str = file_path.to_string_lossy().to_string();
|
||||
out.insert("image_path".into(), json!(path_str));
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(rest) = path_str.strip_prefix("C:\\") {
|
||||
let wsl_path = format!("/mnt/c/{}", rest.replace('\\', "/"));
|
||||
let wsl_path = to_wsl_path(&path_str);
|
||||
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);
|
||||
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 => {
|
||||
let text_opt = req.text;
|
||||
@@ -258,7 +930,6 @@ impl McpTool for ClipboardHandler {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -266,7 +937,6 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_write_clipboard() {
|
||||
let dir = tempdir().unwrap();
|
||||
@@ -351,6 +1021,119 @@ mod tests {
|
||||
"Regression detected: vision.rs must not invoke xclip!"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wsl_path_translation() {
|
||||
assert_eq!(
|
||||
to_wsl_path(r"C:\Users\test\image.jpg"),
|
||||
"/mnt/c/Users/test/image.jpg"
|
||||
);
|
||||
assert_eq!(
|
||||
to_wsl_path(r"c:\projects\code\doc.txt"),
|
||||
"/mnt/c/projects/code/doc.txt"
|
||||
);
|
||||
assert_eq!(
|
||||
to_wsl_path(r"D:\Data\archive.tar"),
|
||||
"/mnt/d/Data/archive.tar"
|
||||
);
|
||||
assert_eq!(to_wsl_path("/tmp/scratch.png"), "/tmp/scratch.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_html_to_markdown_conversion() {
|
||||
let html_snippet = "Version:0.9\r\nStartHTML:00000001\r\n<!--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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +131,13 @@ pub async fn start_background_indexer(state: Arc<MemoryState>) {
|
||||
snippets.drain(0..overflow);
|
||||
}
|
||||
});
|
||||
let threshold: usize = std::env::var("MCP_MEMORY_CONDENSE_THRESHOLD")
|
||||
.unwrap_or_else(|_| "100".to_string())
|
||||
.parse()
|
||||
.unwrap_or(100);
|
||||
if state.code.snippets.read_with(|s| s.len()) > threshold {
|
||||
state.condense_notify.notify_one();
|
||||
}
|
||||
|
||||
for snippet in &snippets_to_index {
|
||||
let _ = idx.index_snippet(snippet).await;
|
||||
|
||||
+137
-14
@@ -96,7 +96,62 @@ pub struct AppState {
|
||||
|
||||
pub async fn ttl_sweeper_worker(state: Arc<MemoryState>) {
|
||||
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()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
@@ -139,12 +194,18 @@ pub async fn ttl_sweeper_worker(state: Arc<MemoryState>) {
|
||||
state.telemetry.session_summaries.modify(|summaries| {
|
||||
summaries.retain(|s| s.expires_at.is_none_or(|exp| exp > now));
|
||||
});
|
||||
state.env.gates.modify(|gates| {
|
||||
gates.retain(|g| g.expires_at.is_none_or(|exp| exp > now));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn index_committer_worker(state: Arc<MemoryState>) {
|
||||
loop {
|
||||
state.index_commit_notify.notified().await;
|
||||
tokio::select! {
|
||||
_ = state.shutdown_notify.notified() => break,
|
||||
_ = state.index_commit_notify.notified() => {},
|
||||
}
|
||||
let idx = state.search_index.read().await.clone();
|
||||
let _ = idx.commit().await;
|
||||
}
|
||||
@@ -152,7 +213,10 @@ pub async fn index_committer_worker(state: Arc<MemoryState>) {
|
||||
|
||||
pub async fn condense_graph_worker(state: Arc<MemoryState>) {
|
||||
loop {
|
||||
state.condense_notify.notified().await;
|
||||
tokio::select! {
|
||||
_ = state.shutdown_notify.notified() => break,
|
||||
_ = state.condense_notify.notified() => {},
|
||||
}
|
||||
|
||||
let threshold: usize = std::env::var("MCP_MEMORY_CONDENSE_THRESHOLD")
|
||||
.unwrap_or_else(|_| "100".to_string())
|
||||
@@ -224,6 +288,7 @@ pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::erro
|
||||
tokio::spawn(ttl_sweeper_worker(Arc::clone(&state)));
|
||||
tokio::spawn(condense_graph_worker(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 app_state = Arc::new(AppState {
|
||||
handler: Arc::new(MemoryHandler::new(Arc::clone(&state))),
|
||||
@@ -270,12 +335,18 @@ pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::erro
|
||||
tokio::spawn(async move {
|
||||
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 {
|
||||
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 event_rx = udp_state.handler.state.event_bus_tx.subscribe();
|
||||
|
||||
loop {
|
||||
if let Ok((len, _addr)) = socket.recv_from(&mut buf).await
|
||||
&& let Ok(payload) =
|
||||
serde_json::from_slice::<crate::models::TerminalHistory>(&buf[..len])
|
||||
{
|
||||
tokio::select! {
|
||||
recv_res = socket_rx.recv_from(&mut buf) => {
|
||||
if let Ok((len, addr)) = recv_res {
|
||||
if let Ok(payload) = serde_json::from_slice::<crate::models::TerminalHistory>(&buf[..len]) {
|
||||
udp_state
|
||||
.handler
|
||||
.state
|
||||
@@ -297,6 +368,34 @@ pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::erro
|
||||
for tx in senders {
|
||||
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);
|
||||
}
|
||||
|
||||
if payload.event.starts_with("agent_") {
|
||||
let topic = format!("nvim:ui:{}", payload.event);
|
||||
let event = crate::state::GenericEvent {
|
||||
topic,
|
||||
if payload.event.starts_with("agent_") || payload.event.starts_with("diff_") {
|
||||
let payload_val = serde_json::json!(&payload);
|
||||
// 1. General event topic (e.g. nvim:ui:agent_prompt_response, nvim:ui:agent_diff_accepted)
|
||||
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()),
|
||||
payload: serde_json::json!(&payload),
|
||||
};
|
||||
let _ = nvim_udp_state.handler.state.event_bus_tx.send(event);
|
||||
payload: payload_val.clone(),
|
||||
});
|
||||
|
||||
// 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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
"ping" => Some(crate::mcp::success(id, serde_json::json!({}))),
|
||||
_ => {
|
||||
|
||||
@@ -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<()>> {
|
||||
let writer = Arc::clone(&self.writer);
|
||||
let id_field = self.id_field;
|
||||
|
||||
+110
-1
@@ -42,6 +42,78 @@ pub struct TelemetryStores {
|
||||
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 base_dir: PathBuf,
|
||||
pub index_commit_notify: Arc<tokio::sync::Notify>,
|
||||
@@ -59,6 +131,7 @@ pub struct MemoryState {
|
||||
pub activity_tx: tokio::sync::broadcast::Sender<String>,
|
||||
pub event_bus_tx: tokio::sync::broadcast::Sender<GenericEvent>,
|
||||
pub ollama: Arc<crate::ollama::OllamaClient>,
|
||||
pub clipboard_cache: Arc<tokio::sync::RwLock<ClipboardCacheState>>,
|
||||
}
|
||||
|
||||
impl MemoryState {
|
||||
@@ -133,6 +206,7 @@ impl MemoryState {
|
||||
|
||||
activity_tx: tokio::sync::broadcast::channel(100).0,
|
||||
event_bus_tx: tokio::sync::broadcast::channel(1000).0,
|
||||
clipboard_cache: Arc::new(tokio::sync::RwLock::new(ClipboardCacheState::default())),
|
||||
};
|
||||
|
||||
// Normalize pre-existing graph entity and relation types
|
||||
@@ -162,6 +236,19 @@ impl MemoryState {
|
||||
|
||||
pub fn modify_graph<F: FnOnce(&mut KnowledgeGraph)>(&self, update_fn: F) {
|
||||
self.graph.modify(update_fn);
|
||||
let threshold: usize = std::env::var("MCP_MEMORY_CONDENSE_THRESHOLD")
|
||||
.unwrap_or_else(|_| "100".to_string())
|
||||
.parse()
|
||||
.unwrap_or(100);
|
||||
let count = self.graph.read_with(|g| g.entities.len());
|
||||
if count >= threshold {
|
||||
self.condense_notify.notify_one();
|
||||
}
|
||||
let _ = self.event_bus_tx.send(GenericEvent {
|
||||
topic: "resource:updated".to_string(),
|
||||
session_id: None,
|
||||
payload: serde_json::json!({ "uri": "memory://graph" }),
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn get_search_index(&self) -> MemoryIndex {
|
||||
@@ -283,7 +370,24 @@ impl MemoryState {
|
||||
}
|
||||
|
||||
pub fn broadcast_task_event(&self, event: TaskEvent) {
|
||||
let payload_val = serde_json::to_value(&event).unwrap_or_default();
|
||||
let mut payload_val = serde_json::to_value(&event).unwrap_or_default();
|
||||
if let Some(obj) = payload_val.as_object_mut() {
|
||||
let active_task = self.project.tasks.read_with(|tasks| {
|
||||
tasks
|
||||
.iter()
|
||||
.find(|t| t.is_active())
|
||||
.or_else(|| {
|
||||
tasks
|
||||
.iter()
|
||||
.find(|t| t.status == "pending" && t.parent_id.is_none())
|
||||
})
|
||||
.map(|t| t.title.clone())
|
||||
});
|
||||
obj.insert(
|
||||
"active_task".to_string(),
|
||||
serde_json::to_value(active_task).unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
|
||||
let summary_str = format!("Task {} -> {}", event.task_id, event.status);
|
||||
let details_str = payload_val.to_string();
|
||||
@@ -315,6 +419,11 @@ impl MemoryState {
|
||||
payload: payload_val,
|
||||
};
|
||||
let _ = self.event_bus_tx.send(generic_ev);
|
||||
let _ = self.event_bus_tx.send(GenericEvent {
|
||||
topic: "resource:updated".to_string(),
|
||||
session_id: None,
|
||||
payload: serde_json::json!({ "uri": "memory://tasks/active" }),
|
||||
});
|
||||
|
||||
let ws_notification = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
|
||||
+24
-5
@@ -984,24 +984,43 @@ pub struct EnvironmentTool {
|
||||
pub requires_vpn: Option<bool>,
|
||||
/// Optional namespace.
|
||||
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")]
|
||||
pub enum ClipboardAction {
|
||||
#[serde(alias = "image", alias = "IMAGE", alias = "Image", alias = "screenshot", alias = "SCREENSHOT")]
|
||||
Image,
|
||||
#[serde(alias = "text", alias = "TEXT", alias = "Text")]
|
||||
Text,
|
||||
#[serde(alias = "history", alias = "HISTORY", alias = "History")]
|
||||
History,
|
||||
#[serde(alias = "clear", alias = "CLEAR", alias = "Clear")]
|
||||
Clear,
|
||||
#[serde(alias = "read", alias = "READ", alias = "Read")]
|
||||
Read,
|
||||
#[serde(alias = "write", alias = "WRITE", alias = "Write")]
|
||||
Write,
|
||||
}
|
||||
/// OS Clipboard management (read, write).
|
||||
/// Smart OS Clipboard management with overwrite-immune screenshot caching and OCR.
|
||||
/// Actions:
|
||||
/// - 'read': Read current clipboard contents.
|
||||
/// - 'image': Get latest screenshot image path (Windows + WSL) and extracted verbatim OCR text.
|
||||
/// Returns cached screenshot even if text was copied afterwards!
|
||||
/// - 'text': Get latest clipboard text (or normalized Markdown if HTML was copied).
|
||||
/// - 'history': View recent clipboard history ring buffer (images and text with timestamps).
|
||||
/// - 'clear': Clear OS clipboard and memory cache.
|
||||
/// - 'read': Read current clipboard contents (legacy alias).
|
||||
/// - 'write': Write content to OS clipboard. Optional: text, html, files, image_path.
|
||||
/// Next steps on error: Provide text/html/files/image_path when calling 'write'.
|
||||
///
|
||||
/// Triggers: Call 'image' immediately when user says "look at image in clipboard", "see screenshot",
|
||||
/// "look at clipboard", "what I copied", or shares terminal error captures.
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct ClipboardTool {
|
||||
/// Action to perform: 'read' or 'write'.
|
||||
/// Action to perform: 'image', 'text', 'history', 'clear', 'read', or 'write'.
|
||||
pub action: ClipboardAction,
|
||||
/// Plain text content (for 'write').
|
||||
pub text: Option<String>,
|
||||
|
||||
+22
-1
@@ -116,6 +116,8 @@ async fn trigger_autonomous_review(path: &Path, state: Arc<MemoryState>) {
|
||||
let file_str = path.to_string_lossy().to_string();
|
||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||
|
||||
let chunks_count = chunks.len();
|
||||
let file_str_clone = file_str.clone();
|
||||
state.modify_graph(|g| {
|
||||
// Ensure File entity exists
|
||||
g.entities.entry(file_str.clone()).or_insert_with(|| {
|
||||
@@ -147,7 +149,8 @@ async fn trigger_autonomous_review(path: &Path, state: Arc<MemoryState>) {
|
||||
created_at: Some(now),
|
||||
updated_at: Some(now),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
let rel = crate::models::Relation {
|
||||
from: file_str.clone(),
|
||||
@@ -161,6 +164,24 @@ async fn trigger_autonomous_review(path: &Path, state: Arc<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"
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,8 +129,15 @@ fn test_dashboard_route_parity() {
|
||||
} else {
|
||||
full_call
|
||||
};
|
||||
let normalized = clean.replace("${id}", "{id}");
|
||||
endpoint = normalized.to_string();
|
||||
let mut normalized = clean.to_string();
|
||||
while let Some(start_var) = normalized.find("${") {
|
||||
if let Some(end_var) = normalized[start_var..].find('}') {
|
||||
normalized.replace_range(start_var..=start_var + end_var, "{id}");
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
endpoint = normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,3 +17,6 @@ serde_json = "1.0.151"
|
||||
mcp-stdio = { version = "1.0.0", path = "../mcp-stdio" }
|
||||
regex = "1.13.1"
|
||||
async-channel = "2.5.0"
|
||||
|
||||
[build-dependencies]
|
||||
chrono = "0.4.45"
|
||||
+20
-3
@@ -1,22 +1,39 @@
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
let git_hash = Command::new("git")
|
||||
let mut git_hash = Command::new("git")
|
||||
.args(["rev-parse", "--short", "HEAD"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|out| String::from_utf8(out.stdout).ok())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
git_hash = git_hash.trim().to_string();
|
||||
|
||||
let is_dirty = Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.output()
|
||||
.is_ok_and(|out| !out.stdout.is_empty());
|
||||
|
||||
if is_dirty {
|
||||
let dirty_ts = chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string();
|
||||
git_hash.push_str(&format!("-dirty-{dirty_ts}"));
|
||||
}
|
||||
|
||||
let git_date = Command::new("git")
|
||||
.args(["log", "-1", "--format=%cd", "--date=format:%y.%m.%d.%H%M%S"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|out| String::from_utf8(out.stdout).ok())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string());
|
||||
|
||||
let version = format!("{} ({})", git_date.trim(), git_hash.trim());
|
||||
let version = format!("v{git_date} ({git_hash})");
|
||||
println!("cargo:rustc-env=APP_VERSION={version}");
|
||||
println!("cargo:rerun-if-changed=../.git/HEAD");
|
||||
println!("cargo:rerun-if-changed=../.git/index");
|
||||
println!("cargo:rerun-if-changed=src");
|
||||
println!("cargo:rerun-if-changed=Cargo.toml");
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
}
|
||||
Reference in new issue
Block a user