Compare commits

..
185 Commits
Author SHA1 Message Date
Riz Ashraf c326166311 chore(cleanup): remove legacy aliases and cruft from codebase, docs, and tests 2026-10-09 08:29:05 +01:00
Riz Ashraf 5311167898 chore: remove legacy documentation, unused test scripts, and obsolete agent rules 2026-10-09 07:38:10 +01:00
Riz Ashraf 7fed3a2e77 docs(nvim-core): prune obsolete tool names from configuration and docs 2026-10-09 06:16:29 +01:00
Riz Ashraf bce9b82b66 feat(nvim-core): consolidate nvim tools into 5 mega-tools 2026-10-09 06:08:08 +01:00
Riz Ashraf 03f8786d24 chore: resolve clippy warnings for pre-push audit 2026-10-09 01:06:30 +01:00
Riz Ashraf bd20482cea fix(embedding): resolve large_enum_variant clippy error 2026-10-09 00:56:47 +01:00
Riz Ashraf 1ed75ca156 feat(nvim): add nvim_lsp, nvim_ast, buffer undo/redo 2026-10-09 00:51:05 +01:00
Riz Ashraf 3765e6b05d chore: fix clippy warnings and run rustfmt 2026-10-09 00:39:50 +01:00
Riz Ashraf fecd8bf33f fix(tests): replace serde_json::from_str with serde_yaml::from_str for tool output assertions 2026-10-09 00:30:05 +01:00
Riz Ashraf b4431a4dcd feat(nvim): update nvim_buffer edit to handle multiple block edits 2026-10-09 00:18:01 +01:00
Riz Ashraf d6acd885dd fix(nvim): eliminate \r corruption in buffer mutations
- Pre-process and strip carriage returns natively in Rust before RPC serialization.

- Pass replacement strings to Neovim as native MsgPack arrays to bypass Lua vim.split overhead.

- Directly feed table arrays into vim.api.nvim_buf_set_lines for exact buffer matches.
2026-10-08 23:58:49 +01:00
Riz Ashraf cb0c55775a feat(mcp): switch to YAML serialization for tool outputs
- Replaced serde_json with serde_yaml in all handler endpoints.

- This drastically reduces token consumption for large list/graph responses by removing JSON braces and quotes.
2026-10-08 23:46:32 +01:00
Riz Ashraf 410b0c42ca feat(ollama): enforce JSON logits & add nvim_buffer edit action
- Added format parameter to Ollama generate for JSON logit enforcement.

- Implemented safe edit action in nvim_buffer MCP tool to replace raw Lua.
2026-10-08 23:41:57 +01:00
Riz Ashraf 003b3cb2bf docs: strictly enforce agnostic Neovim rules and ban invisible shell edits 2026-10-08 23:29:44 +01:00
Riz Ashraf 5485b40d14 feat(nvim-core): make nvim_execute_lua read-only to enforce specialized tool usage 2026-10-08 23:19:47 +01:00
Riz Ashraf 9d9e959744 chore: fix formatting and clippy lints 2026-10-08 23:01:38 +01:00
Riz Ashraf 8952bd5399 feat(ollama): keep models in VRAM for 1h to prevent cold starts 2026-10-08 22:57:02 +01:00
Riz Ashraf 4005f566cf Expand Ollama context windows (num_ctx) and output limits 2026-10-08 22:53:24 +01:00
Riz Ashraf 7eae2fa0aa Update ocrs models to S3 endpoints and add download error logging 2026-10-08 22:38:38 +01:00
Riz Ashraf 5c5aead1a5 Replace Ollama/Windows OCR with ocrs, add clipboard image deduplication 2026-10-08 21:48:12 +01:00
Reazul Ashraf 548f0a06ea Fix type inference for non-windows target 2026-10-08 13:43:43 +01:00
Riz Ashraf 8c6bf05269 feat: implement native UDP ping/pong health check pattern 2026-10-08 12:46:55 +01:00
Riz Ashraf 0da84c804c chore: test UDP rendezvous 2026-10-08 11:36:42 +01:00
Riz Ashraf 8e3d356167 chore: test UDP rendezvous 2026-10-08 11:35:13 +01:00
Riz Ashraf 8bc8c97504 feat: use UDP rendezvous for git pre-push hook
Replaces the Python-based pre-push hook and long-polling HTTP logic with a native shell script using udp-send --wait to block on an ephemeral UDP port in the kernel. mcp-memory-server acts as the broker and replies to subscribers.
2026-10-08 11:33:13 +01:00
Riz Ashraf f4dcf3acbf feat: event-driven parity, Windows shell detection, dirty build timestamps, and gatekeeper integration 2026-10-08 10:52:19 +01:00
Riz Ashraf 64857f9d5e fix(concurrency): serialize Win32 clipboard access and test env vars to prevent STATUS_HEAP_CORRUPTION 2026-10-07 22:30:36 +01:00
Riz Ashraf 35c802c1b8 perf: optimize store batch serialization, zero-clone semantic search, and lock contention 2026-10-07 22:14:53 +01:00
Riz Ashraf 3b08f45618 docs: synchronize documentation, agent rules, instructions, tools/list, and resources/list
- Document all 53 MCP tools, 9 passive MCP resources, and 5 workflow prompts
- Document 100% ADR implementation status and automated post-commit reconciliation engine
- Update and deploy agent-rules (mcp_memory_workflow.md) to Windows and WSL
- Fix dashboard live ADR tab refresh and Cache-Control headers
- Synchronize instructions.md across root, server embedded, Windows, and WSL MCP configs
2026-10-07 21:34:43 +01:00
Riz Ashraf 37fc811752 build(justfile): add Scoop cargo bin path to deploy-server target list 2026-10-07 20:23:47 +01:00
Riz Ashraf 40cab6142b feat(adr): implement ADR-0109, ADR-0110, and ADR-0111
- ADR-0109: Incremental Background AST Indexing & Differential Graph Updates via Tree-Sitter
- ADR-0110: Real-Time SSE Live Activity & Log Stream (/api/events/stream)
- ADR-0111: Automated Stale Symbol Pruning & Graph Tombstoning (sweep_graph_health auto_prune_stale_files)
2026-10-07 20:17:19 +01:00
Riz Ashraf d792b50343 feat(milestones): add POST /api/milestones endpoint and resolve collapsible_if warnings (TD-fc685b13-d9c2-4a57-ac4d-5228a71a2ec0) 2026-10-07 19:32:11 +01:00
Riz Ashraf 8f924b793a feat(reconciliation): implement deterministic state reconciliation engine and post-commit hook
- Implement ReconciliationEngine in server/src/handlers/reconciliation.rs
- Auto-transition ADRs to implemented, resolve tech debts, and cascade task unblocking
- Ingest git commit events via POST /api/git/commit and scripts/git-reconcile.py post-commit hook
- Add universal pagination, search filtering, and keyboard navigation to dashboard
- Implement non-destructive task TTL expiry sweeper and gate verification
- Implements: ADR-0102, ADR-0103
2026-10-07 19:00:44 +01:00
Riz Ashraf ec977c24dd feat(nvim,telemetry): implement ADR-0102 circuit breaker and ADR-0103 knowledge graph projection 2026-10-07 17:40:30 +01:00
Riz Ashraf 3d77e60a02 feat(embedding,notes): implement ADR-0056 Blake3 stable hashing and ADR-0057 priority-aware retention 2026-10-07 17:04:52 +01:00
Riz Ashraf f546579596 feat(dashboard,adrs): enrich dashboard tabs with domain model parity and resolve consequence schema handling 2026-10-07 14:38:02 +01:00
Riz Ashraf 73c3d41c87 fix(dashboard,server): restore code change ledger UI and enrich model schema parity and pagination 2026-10-07 13:14:33 +01:00
Riz Ashraf 3b146f91c2 refactor: consolidate hypotheses, agent_signals, and process_logs into action-based smart tools 2026-10-07 12:00:20 +01:00
Riz Ashraf 79209da711 refactor: eradicate deprecated tools (sticky_notes, pinned_files, context_workspaces, pr_checklist, preferences) and dead code 2026-10-07 11:12:42 +01:00
Riz Ashraf d80915635f refactor: resolve critical concurrency, data loss, schema, and search audit passes 2026-10-07 07:09:28 +01:00
Riz Ashraf e4a0fe72df feat(embedding,vision): candle embeddings with offline fallback, on-demand clipboard vision capture, and concurrency audit 2026-10-07 06:36:09 +01:00
Riz Ashraf 5bd8b1587a refactor: apply 5-pass audit optimizations across mcp-memory codebase 2026-10-06 06:05:38 +01:00
Riz Ashraf 924b6d09fa refactor: address 5-pass audit findings for antipatterns, bottlenecks, memory efficiency, and LLM handlers 2026-10-05 21:44:10 +01:00
Riz Ashraf 626403900f refactor(cleanroom): scrub legacy backward-compatibility shims across telemetry and models 2026-10-05 13:34:14 +01:00
Riz Ashraf b4a95b3e6a test(api): expand live activity feed and telemetry integration unit tests 2026-10-05 13:29:01 +01:00
Riz Ashraf da3700d1d5 fix(dashboard): enhance live activity formatting, add empty state placeholder, and add API unit tests 2026-10-05 13:05:08 +01:00
Riz Ashraf 19f9c7ace7 feat(dashboard): extract inline script to standalone dashboard.ts/js, add UI build/check recipes, and add route unit tests 2026-10-05 12:48:36 +01:00
Riz Ashraf 4b307b55b9 docs: update human and LLM docs and enhance tool/list schemas with actionable next steps 2026-10-05 11:22:55 +01:00
Riz Ashraf 961abb01e7 refactor: drop Manage prefix from consolidated smart MCP tools 2026-10-05 11:11:41 +01:00
Riz Ashraf e45db33297 fix(nvim): wipe pristine un-named buffers on nvim_open_file and unlist scratch buffers in nvim_open_buffer 2026-10-05 10:55:57 +01:00
Riz Ashraf 6e0ccf8082 refactor(justfile): drop aliases/duplication, enforce single canonical commands, and add descriptions for all recipes 2026-10-05 10:48:43 +01:00
Riz Ashraf bd8a892101 feat(deploy): ensure deploy-server executes graceful shutdown before replacing binaries and auto-restarts server 2026-10-05 10:46:38 +01:00
Riz Ashraf 2cd912858a feat(deploy): sync binary release across .local/bin and .cargo/bin for CLI --version alignment 2026-10-05 10:30:08 +01:00
Riz Ashraf 55c2636db9 feat(cli): add just version recipe for double-lock cross-checking 2026-10-05 10:22:12 +01:00
Riz Ashraf 9f10984c18 fix(scripts): update justfile and verify_deployment script to check /health version 2026-10-05 09:59:30 +01:00
Riz Ashraf cb39f94584 fix(server): return version details in health and shutdown endpoints 2026-10-05 09:58:50 +01:00
Riz Ashraf 4cb76725fe feat(rules): track mcp_and_build_constraints.md in git 2026-10-05 09:58:13 +01:00
Riz Ashraf 91d5565141 fix: ensure authenticated graceful shutdown exits process and sort live activity feed by timestamp descending 2026-10-04 02:55:56 +01:00
Riz Ashraf 3efee96230 perf: debounce store writes and add retry loop on Redb transaction collisions 2026-10-04 02:17:37 +01:00
Riz Ashraf 533adfd41b refactor: consolidate nvim crates, extract server library, and update workspace dependencies 2026-10-04 01:42:59 +01:00
Riz Ashraf a083719cf1 feat(server): refactor handlers, router, state management, and memory tools 2026-10-02 07:27:37 +01:00
Riz Ashraf 87ddb01063 feat(dashboard): fix Ctrl+K shortcut, debounce search queries, and add request cancellation 2026-10-02 07:27:31 +01:00
Riz Ashraf 83aa26634d docs: update workflow constraints, rules, and project documentation 2026-10-02 07:27:26 +01:00
Riz Ashraf 74ca0948c9 fix(deploy): update deploy-server recipe with error suppression for locks 2026-10-02 07:27:21 +01:00
Riz Ashraf 462f65f66d refactor: Implement unified search abstraction, MemoryState refactoring, error handling, and unit test expansion 2026-10-01 08:37:52 +01:00
Riz Ashraf a34554b7ff Refactor: Dismantle MemoryState into semantic domain sub-structs 2026-09-30 21:13:03 +01:00
Riz Ashraf 0e866f2465 Refactor: Migrate unwrap calls to AppError in MCP handlers 2026-09-30 21:02:50 +01:00
Riz Ashraf 4e1a633dbd Refactor: Fix clippy warnings and introduce AppError 2026-09-30 20:43:28 +01:00
Riz Ashraf 3add6c3d31 Refactor vector search to utilize Qdrant VectorDB 2026-09-30 14:37:41 +01:00
Riz Ashraf 61c0e88ad8 feat(server): add qdrant vector db integration 2026-09-30 14:05:49 +01:00
Riz Ashraf def473c856 feat(mcp): update tool descriptions for LLM context optimization 2026-09-30 12:36:33 +01:00
Riz Ashraf 3a46124676 docs: Update guidelines and effective discourse for MCP tools 2026-09-30 12:33:01 +01:00
Riz Ashraf 292a6e95ab Docs: Update Effective Discourse instructions for users and agents 2026-09-30 12:24:35 +01:00
Riz Ashraf 63f8ec6281 Implement Knowledge Graph Consolidation worker in server 2026-09-30 12:19:47 +01:00
Riz Ashraf d2ab8c89b6 Merge file system hooks implementation 2026-09-30 10:06:09 +01:00
Riz Ashraf 5870f37fbd feat: implement proactive daemon hooks for file saves 2026-09-30 09:54:06 +01:00
Riz Ashraf 181fc096a1 Implement global codebase vector indexing 2026-09-30 09:45:27 +01:00
Riz Ashraf ec0472155f perf(mcp): Batch semantic code search embeddings to resolve CPU bottleneck and fix linting warnings 2026-09-30 09:07:14 +01:00
Riz Ashraf 185c3c999e fix(mcp): Add missing Cargo.lock, lua bindings, and workspace fixes for tools 2026-09-30 08:47:57 +01:00
Riz Ashraf c1b9767199 docs(mcp): Add V2 enhancements to PROMPTING_GUIDE.md and update README.md 2026-09-30 08:46:28 +01:00
Riz Ashraf 1adc625fcf feat(ast): Add tree-sitter bindings for Java, C, C++, and Go 2026-09-30 08:43:22 +01:00
Riz Ashraf 9787af0f27 feat(mcp): Add replace_ast_node, semantic_code_search, read_directory_architecture, nvim_send_to_terminal 2026-09-30 08:37:26 +01:00
Riz Ashraf d4635ab6dc docs(mcp): Add PROMPTING_GUIDE.md for effective LLM discourse 2026-09-30 05:30:29 +01:00
Riz Ashraf 0e9748f797 feat(mcp): Add 5 developer enhancements (git, logs, clipboard watcher, ast skeleton, nvim ghost text) 2026-09-30 05:29:50 +01:00
Riz Ashraf ccc268d807 test(deploy): implement strict HTTP deployment verification script 2026-09-29 19:18:47 +01:00
Riz Ashraf 6024579a19 fix(justfile): add sleep after shutdown to prevent socket binding race condition on restart 2026-09-29 19:00:58 +01:00
Riz Ashraf 6b25592c1b ui: make Tech Debt and Resolved Errors visually distinct with styled badges 2026-09-29 18:50:11 +01:00
Riz Ashraf 4c8e2f6414 docs: remove zigbuild references and update just commands, fix: remove Content-Length formatting causing parse errors 2026-09-29 18:24:43 +01:00
Riz Ashraf f59602a3e2 feat: Add OS and CWD details to Terminal History UI 2026-09-29 09:03:25 +01:00
Riz Ashraf 4e23043f5d feat: Implement lightweight UDP interactive UI for Neovim
- Rewrote gemini-ui.lua to send native Neovim prompt selections, Ghost Text diff accept/reject actions, and smart context highlighting directly to the backend via zero-latency UDP port 3002, entirely replacing the old xecute_agent terminal subprocesses.
- Added prompt_user and preview_diff extmark lua functions for non-destructive diff review.
- Updated the Rust backend mcp-memory-server UDP listener to ingest the new gent_ask and interactive schema payloads and route them to the EventBus.
- Updated README.md and instructions.md with UDP documentation.
2026-09-29 08:59:23 +01:00
Riz Ashraf b3c64c1ad8 feat: Add cwd and os to TerminalHistory for precise agent binding 2026-09-29 08:34:36 +01:00
Riz Ashraf 98a1716ac8 chore: fix clippy warnings, add embedding/omni_patch, update telemetry, and ignore temp files 2026-09-29 06:42:29 +01:00
Riz Ashraf 495304f3fa fix(ui): correctly insert new websocket activity feed items at the top and include day/month in timestamps 2026-09-27 23:04:36 +01:00
Riz Ashraf ecfb0ebcd5 feat(ui): reverse live activity feed to render chronological newest at the top 2026-09-27 22:55:18 +01:00
Riz Ashraf 91055b1eda fix(ui): remove malformed javascript block causing graph and tabs to fail to render 2026-09-27 22:48:40 +01:00
Riz Ashraf 706e9ad172 style(clippy): auto-fix code style issues and unnecessary map_or allocations detected by cargo clippy 2026-09-27 22:38:08 +01:00
Riz Ashraf febd48c65d fix(mcp): prevent internal handler serialization errors from being silently discarded or defaulting to empty strings 2026-09-27 22:30:32 +01:00
Riz Ashraf 8f32a09399 fix(mcp): propagate internal serialization errors instead of silently swallowing them to prevent LLM hallucination 2026-09-27 22:19:43 +01:00
Riz Ashraf f8925050db test: add unit tests for MCP Resources and Prompts handling 2026-09-27 22:16:46 +01:00
Riz Ashraf 0772c7fca3 fix(ui): inject correct JS loaders for pinned files and milestones lists in NRT 2026-09-27 22:12:25 +01:00
Riz Ashraf c990f5cd3a feat(ui): add Pinned Files tab and Milestone tracking to the dashboard 2026-09-27 22:10:41 +01:00
Riz Ashraf 52c4c7c479 fix(nvim-core): implement empty resource and prompt list stubs for strict MCP client compatibility 2026-09-27 21:59:55 +01:00
Riz Ashraf 36e35d4fbd docs: define strict cognitive boundaries for LLMs between MCP Tools, Resources, and Prompts 2026-09-27 21:55:33 +01:00
Riz Ashraf d2b4f69f97 feat(mcp): expand resources with pinned_files/milestones and handoff_routine prompt 2026-09-27 21:52:35 +01:00
Riz Ashraf f970e1219d docs: Add architecture design for MCP resources and prompts trait refactor 2026-09-27 21:49:41 +01:00
Riz Ashraf 45577a99c4 refactor: implement McpResource and McpPrompt traits to handle MCP endpoints generically 2026-09-27 21:47:52 +01:00
Riz Ashraf 41461a41ef docs: Add AI integration strategy and subagent rationale 2026-09-27 21:29:15 +01:00
Riz Ashraf 2bc27880fd Update system instructions to explicitly teach the LLM about Tasks, Handoffs, Preferences, and Sticky Notes 2026-09-27 10:15:15 +01:00
Riz Ashraf 23413f103f Update instructions to teach agents how to use snippets and PR checklists 2026-09-27 10:13:45 +01:00
Riz Ashraf bb535b62b0 Add delete_context_workspace tool 2026-09-27 10:10:30 +01:00
Riz Ashraf 0699b603c1 Update instructions to teach agents how and when to use context workspaces 2026-09-27 10:06:44 +01:00
Riz Ashraf 54aead2151 Add proper lifecycle and superseding support for Architectural Decision Records 2026-09-27 10:01:46 +01:00
Riz Ashraf 70edfa4389 Add delete_decision tool for removing redundant ADRs 2026-09-27 09:57:04 +01:00
Riz Ashraf 08e0cc390a Fix UI fields rendered for Tech Debt in dashboard 2026-09-27 09:53:53 +01:00
Riz Ashraf 4139d8fcc7 Add unit test for malformed Lucene query in OmniSearch 2026-09-27 09:46:08 +01:00
Riz Ashraf 01d3c22c57 Fix error swallowing by propagating JSON serialization errors to LLM 2026-09-27 09:44:04 +01:00
Riz Ashraf 48a1973ea6 Improve OmniSearch error handling to return Lucene syntax errors to LLM 2026-09-27 09:33:58 +01:00
Riz Ashraf 1ea635cdcc Enhance OmniSearch for LLMs with limits and include_body flags 2026-09-27 09:31:58 +01:00
Riz Ashraf b7d1ae78db Auto-refresh tab content on switch 2026-09-27 09:19:01 +01:00
Riz Ashraf 4bae4e9c08 Test coverage and headless nvim bug fixes 2026-09-27 08:10:09 +01:00
Riz Ashraf bffc8896f1 Strictly verify UI attachment during fallback pipe discovery 2026-09-26 21:14:10 +01:00
Riz Ashraf 8a67249ddb Fix OS socket routing in telemetry 2026-09-26 21:02:11 +01:00
Riz Ashraf 80b1969b15 Add nvim_get_server_info diagnostic tool 2026-09-26 20:32:12 +01:00
Riz Ashraf d60d6ca70b Implement nvim tool mitigations 2026-09-26 20:22:59 +01:00
Riz Ashraf 24aacdfae1 feat(nvim): add dedicated mcp tools for file reading and regex searching 2026-09-26 18:23:06 +01:00
Riz Ashraf b1fc381107 fix(test): update test payloads to use snake_case after camelCase removal 2026-09-26 18:05:37 +01:00
Riz Ashraf e8d7ded32a fix(ui): update frontend dashboard to strictly consume snake_case fields following camelCase removal 2026-09-26 09:06:39 +01:00
Riz Ashraf 05e3e53217 fix(mcp): extract and elevate struct docstrings into root tool schema descriptions to restore semantic LLM visibility 2026-09-26 09:00:00 +01:00
Riz Ashraf e84c101515 fix(mcp): completely eradicate camelCase backward compatibility by removing all serde aliases 2026-09-26 08:56:07 +01:00
Riz Ashraf a24ef598a2 feat(handlers): enrich semantic error messages with prescriptive tool hints to prevent LLM hallucination traps 2026-09-26 08:47:52 +01:00
Riz Ashraf cbef7e5291 test(mcp): add explicit unit tests to strictly verify tool execution success vs error JSON-RPC responses 2026-09-26 08:43:03 +01:00
Riz Ashraf 0ba0ed5b3d refactor: remove redundant multi-line search index unwrap clones across all handlers via new get_search_index accessor 2026-09-26 08:39:42 +01:00
Riz Ashraf d96b4cf822 fix(mcp): convert silently swallowed OK string errors into explicit JSON-RPC errors for task transitions and bulk entity operations 2026-09-26 08:32:04 +01:00
Riz Ashraf f81723a539 fix(mcp): return explicit JSON-RPC errors instead of misleading Ok strings for missing entities and silent graph modification drops 2026-09-26 08:24:56 +01:00
Riz Ashraf cc190816fe perf: remove redundant MemoryState::deduplicate and resolve lock contention during index rebuild 2026-09-26 08:15:58 +01:00
Riz Ashraf be85b58be4 fix: agent usability improvements - unified schema casing, fixed silent handler failures, and made namespaces strictly optional 2026-09-26 08:07:28 +01:00
Riz Ashraf 5dd413c397 chore: migrate global agent rules into repository and update deployment scripts 2026-09-26 02:21:42 +01:00
Riz Ashraf 917a9058f9 fix(stub): downgrade websocket disconnect logs to warn/info 2026-09-26 02:06:04 +01:00
Riz Ashraf efcab4a7b4 feat(events): add generic Event Bus and /events/wait endpoint for agent wakeups 2026-09-26 02:05:18 +01:00
Riz Ashraf b8d58b2e64 feat(nvim): add BufWritePost to telemetry triggers for cross-session agent wakeups 2026-09-26 02:01:42 +01:00
Riz Ashraf 5bb83a849b build: track git HEAD and index in build script to accurately detect dirty state 2026-09-26 01:07:57 +01:00
Riz Ashraf 4ecda68c1e Fix UI auto-scroll, make namespace optional, alias filePath, and downgrade ws logs 2026-09-25 21:38:21 +01:00
Riz Ashraf 175882dd1b docs: enforce visual engagement rule for pair programming tasks over headless execution 2026-09-23 08:35:17 +01:00
Riz Ashraf 02af8f18f1 docs: explicitly forbid grepping rust source for mcp schemas and instruct usage of local json cache 2026-09-23 08:14:09 +01:00
Riz Ashraf 14db90acbf fix(nvim): remove unused mut variables 2026-09-23 05:00:35 +01:00
Riz Ashraf e5d4002b38 docs: update instructions to enforce usage of auto-spawning headless neovim instance 2026-09-23 04:55:11 +01:00
Riz Ashraf 914cecbaf2 feat(nvim): implement auto-spawning headless instance and interactive preemption 2026-09-23 04:53:09 +01:00
Riz Ashraf 7d783d2f37 docs: update tool prefixes in root instructions.md 2026-09-23 04:32:28 +01:00
Riz Ashraf da98391ef9 docs(nvim): update instructions.md with correct tool names and new window tools 2026-09-23 04:30:38 +01:00
Riz Ashraf 4cae55bab3 refactor(justfile): decouple build and deploy steps 2026-09-23 04:22:02 +01:00
Riz Ashraf e0eeb4b0a6 chore(justfile): add 'all' recipe that builds and deploys everything 2026-09-23 04:18:51 +01:00
Riz Ashraf be9ad8127a chore(server): correct startup log message 2026-09-23 04:14:53 +01:00
Riz Ashraf 7729b4ae5a fix(nvim): resolve idle zombie connection lock and adjust RPC timeout
- Remove the arbitrary 60-second read timeout on the Neovim socket that caused the read task to exit during idle periods, permanently locking the connection state.
- Automatically clear the connection state variable when the read loop breaks (e.g. true EOF), allowing get_nvim_connection() to correctly auto-reconnect on the next call.
- Increase the Neovim RPC call timeout from 10s to 30s to accommodate heavier operations (e.g., massive Lua regex substitutions) without falsely dropping the connection.
2026-09-23 04:09:09 +01:00
Riz Ashraf ab1d209c1d feat(nvim): complete window management symmetry
- Add
vim_close_window to unsplit active layout without destroying underlying buffer.
- Add
vim_list_windows to discover active splits.
- Add
vim_get_active_window and
vim_set_active_window to programmatically navigate windows.
2026-09-23 04:05:39 +01:00
Riz Ashraf 6a89f50cf2 feat(nvim-core): implement nvim_set_extmark for rich virtual text UI feedback
- Added nvim_set_extmark tool schema and handler
- Implemented lua bridge using vim.api.nvim_buf_set_extmark to dynamically overlay ghost text and highlight groups onto active buffers
2026-09-22 22:34:06 +01:00
Riz Ashraf ea735003e2 feat(nvim-core): implement bidirectional event streaming and real-time shadow buffer
- Created global thread-safe NvimState to shadow Neovim context.
- Injected Lua autocmds (CursorMoved, TextChanged) on connection to stream events back to MCP.
- Upgraded msgpack RPC loop to intercept and parse 'mcp_event' notifications alongside standard responses.
- Optimized nvim_get_cursor to pull instantly from the local shadow buffer, bypassing RPC round-trips.
2026-09-22 22:32:37 +01:00
Riz Ashraf 61b03bc6e3 perf(nvim-core): implement zero-copy BytesMut stream buffer and DashMap for lock contention 2026-09-22 22:26:00 +01:00
Riz Ashraf 37003be620 refactor: apply rust best practices and fix memory optimizations
- Optimized memory allocation in router.rs by offloading JSON serialization to spawn_blocking and using references.
- Prevented full graph duplication on startup in state.rs index rebuild.
- Eliminated massive String allocations in QueryGraphPathHandler BFS loops.
- Avoided temporary Strings in VisualizeGraphHandler via inline writing.
- Fixed O(N) full-graph deduplication in MergeEntitiesHandler to scale efficiently.
2026-09-22 22:01:38 +01:00
Riz Ashraf 251757f8fc feat(mcp): implement Prompts capability, Resources, and clean up daemon architecture 2026-09-22 21:41:17 +01:00
Riz Ashraf 9b9b403ef2 fix(build): cleanly terminate all mcp binaries on deploy and supply bearer token to shutdown 2026-09-22 06:58:38 +01:00
Riz Ashraf d8285a41c7 feat(server): embed LLM handshake instructions in MCP initialization 2026-09-22 06:51:08 +01:00
Riz Ashraf 27aba454e2 perf(server): fix broadcast loops, avoid locking db on index, eliminate O(N*M) search, and fix db loss 2026-09-22 06:33:32 +01:00
Riz Ashraf 1e39122e05 fix: remove potential panic on initialize result serialization in nvim-core 2026-09-22 06:16:06 +01:00
Riz Ashraf b2f15de58c style: fix clippy collapsible_if warnings in nvim-core 2026-09-22 06:07:50 +01:00
Riz Ashraf 148025c5a7 fix: replace pending_requests Mutex unwrap with unwrap_or_else to prevent poison panics 2026-09-22 06:00:54 +01:00
Riz Ashraf dd4a71d44d build: upgrade all workspace crates to rust edition 2024 2026-09-22 05:58:30 +01:00
Riz Ashraf b8070de38c refactor: deduplicate lua execution match blocks in nvim-core using handle_lua_result macro 2026-09-22 05:57:50 +01:00
Riz Ashraf 02f40604e1 perf: fix slice boundary panics, eliminate string allocation in stdio reader, fix lua table nesting bug, and optimize nvim socket chunk size 2026-09-22 05:51:15 +01:00
Riz Ashraf e215720416 chore: remove unused dependencies discovered by cargo-machete 2026-09-22 05:44:09 +01:00
Riz Ashraf 961a5a9e1b chore: strip unused rustls configurations to drastically speed up compilation 2026-09-22 05:40:11 +01:00
Riz Ashraf 0db94da646 Merge branch 'master' of ssh://gitea.rizaz.com:222/riz/mcp-memory 2026-09-22 05:08:54 +01:00
Riz Ashraf c8f8ff8997 chore: update major dependencies and fix API breakages 2026-09-22 05:08:42 +01:00
Riz Ashraf 8adef5e216 perf(nvim-core): eliminate heavy string allocations for JSON-RPC msgids in internal request mapping 2026-09-22 04:50:30 +01:00
Riz Ashraf 05672cc884 perf(nvim-core): eliminate JSON serialization overhead and potential Lua injection across all tools by passing arguments directly to nvim_exec_lua 2026-09-22 04:46:29 +01:00
Riz Ashraf 78e94c6aea chore: remove unused variables and tracing dependency in nvim-core 2026-09-22 04:41:14 +01:00
Riz Ashraf f76d8ccce8 perf: fix memory leaks, optimize tracing logs, fix networking eof fragmentation bug in nvim-core and stub, and deduplicate json-rpc handlers 2026-09-22 04:31:50 +01:00
Riz Ashraf ce88bef45e chore(server): remove dead scratch scripts, temporary files, and unused legacy modules 2026-09-22 03:34:13 +01:00
Riz Ashraf 1069096a29 fix(tasks): eliminate target_id string clone during dependency mapping in UpdateTaskStatusHandler 2026-09-22 03:29:28 +01:00
Riz Ashraf 478655698e fix(server): safely drop detached tokio join handles and refactor timestamp boilerplate 2026-09-22 03:19:51 +01:00
Riz Ashraf 13c7eca4c2 perf(server): eliminate O(N) heap cloning across standup, snippet, and graph handlers 2026-09-21 20:27:33 +01:00
Riz Ashraf 9d7cd347c6 style(server): collapse nested if conditions via clippy 2026-09-21 20:13:38 +01:00
Riz Ashraf f37a2f8313 style(server): remove unused get_full_graph method to clear clippy warning 2026-09-21 20:12:25 +01:00
Riz Ashraf da1b7cdc9d fix(mcp): wrap websocket activity broadcasts in JSON-RPC notifications
Fixes a critical bug where the Antigravity MCP client would crash with 'invalid message version tag' during long-running tool executions. The server was broadcasting raw JSON activity objects ({'type': 'activity', 'data': ...}) over the WebSocket connection without wrapping them in the required JSON-RPC 2.0 Notification envelope, violating the protocol expectation on the proxy stub.
2026-09-21 19:56:03 +01:00
Riz Ashraf 208c5d448f perf(api): eliminate expensive heap allocations and memory clones by serializing axum response payloads directly from store read locks 2026-09-21 19:33:06 +01:00
Riz Ashraf af2c4ab046 fix(search): prevent memory leaks and duplication in tantivy search index by explicitly deleting prior terms during updates and syncing store deletions 2026-09-21 19:16:55 +01:00
140 changed files with 32795 additions and 9098 deletions

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
[target.x86_64-pc-windows-msvc]
linker = "rust-lld"
[profile.dev]
debug = 1 # Line tables only: cuts object file & PDB size by ~50%
codegen-units = 16 # Maximizes parallel CPU compilation threads
incremental = false # Enables 100% sccache object file caching across builds
[http]
check-revoke = false # Prevents SChannel revocation check errors on corporate VPNs
+9
View File
@@ -1,3 +1,12 @@
/target
/target-wsl
cargo-llvm-cov.exe
llvm-cov.zip
.fastembed_cache/
*.py
!scripts/*.py
temp.json
*.txt
summary.md
__pycache__/
Generated
+3050 -395
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -2,7 +2,7 @@
members = [
"server",
"stub",
"win-nvim",
"linux-nvim"
, "nvim-core", "mcp-stdio"]
"nvim-core",
"mcp-stdio"
]
resolver = "2"
+163 -103
View File
@@ -1,116 +1,176 @@
# mcp-memory
A high-performance, persistent Knowledge Graph and Context daemon for Antigravity, implementing the Model Context Protocol (MCP).
`
A high-performance, persistent Knowledge Graph, Code Intelligence, and Context daemon for Antigravity, implementing the Model Context Protocol (MCP).
## Overview
mcp-memory acts as the persistent "brain" for the agy CLI agents. It tracks entities, relations, background tasks, engineering debt, and architectural decisions across sessions.
`
To eliminate heavy Cross-OS I/O penalties when using WSL and Windows simultaneously, mcp-memory operates using a **Dual-Transport Leader/Stub Architecture**:
* **The Server (mcp-memory-server)**: Runs natively on the Windows host. It binds to .0.0.0:3000, serving standard stdio to the primary Windows agy instance while simultaneously hosting an Axum HTTP server for secondary clients.
* **The Stub (mcp-memory-stub)**: An ultra-lightweight proxy binary. WSL agy instances run this native Linux stub, which transparently pipes stdio JSON-RPC traffic over the network to the Windows HTTP server (http://127.0.0.1:3000), completely bypassing WSL NTFS mounts. It features full MPSC queue buffering and a WebSocket reconnect handshake (notifications/tools/list_changed) so that tools automatically refresh seamlessly without disconnecting the CLI if the background server restarts.
`
## Quick Start & Usage
`
### 1. Windows Installation (The Server & Stub)
Compile the main daemon and lightweight stub natively for Windows:
`mcp-memory` acts as the persistent "brain" for `agy` CLI agents and autonomous subagents. It tracks graph entities, relations, background tasks, milestones, engineering debt, architectural decisions, code modifications, terminal activity, and compiler error fixes across sessions.
To eliminate cross-OS I/O penalties when developing across WSL and Windows simultaneously, `mcp-memory` operates using a **Dual-Transport Leader/Stub Architecture**:
* **The Server (`mcp-memory-server`)**: Runs natively on the Windows host. It binds to `0.0.0.0:3000`, serving standard stdio to the primary Windows `agy` instance while simultaneously hosting Axum HTTP, WebSocket, and Zero-Latency UDP endpoints for secondary clients and UI dashboards.
* **The Stub (`mcp-memory-stub`)**: An ultra-lightweight proxy binary. WSL `agy` instances run this native Linux stub, which transparently pipes stdio JSON-RPC traffic over the local network to the Windows HTTP server (`http://127.0.0.1:3000`), completely bypassing WSL NTFS cross-mounts. It features full MPSC queue buffering and a WebSocket reconnect handshake (`notifications/tools/list_changed`) so that tools automatically refresh seamlessly without disconnecting the CLI if the background server restarts.
> [!NOTE]
> For in-depth strategy, casing standards, and tool semantics, please refer to the [Strategic Guidelines (`instructions.md`)](./instructions.md) and [Effective Discourse Guide](./EFFECTIVE_DISCOURSE.md).
---
## 🏛️ Architecture Status: 100% ADR Implementation
All 25 Architectural Decision Records (**ADR-0080 through ADR-0104**) are **100% implemented, verified, and reconciled** in the persistent store:
* **ADR-0080 – ADR-0093**: Enterprise persistence, AST intelligence, vector embeddings, cross-OS dual transports, and headless Neovim RPC.
* **ADR-0094 – ADR-0101**: Zero-subprocess security invariants, pure native Rust clipboard (`arboard`), and bounded telemetry buffers.
* **ADR-0102**: Dynamic Fastembed micro-batching with 16k character budget ceiling.
* **ADR-0103**: Real-time Tantivy search reader auto-reloading upon background index commits.
* **ADR-0104**: Automated Git post-commit ADR & Task status reconciliation engine (`scripts/git-reconcile.py`).
---
## 📡 Passive MCP Context Resources (`resources/list`)
Agents can passively read these 9 MCP resources for instant zero-turn context without incurring tool call latency:
| Resource URI | Resource Name | Description & Usage |
|:---|:---|:---|
| `memory://graph/entities` | Graph Entities | All nodes and entities currently stored in the knowledge graph. |
| `memory://graph/relations` | Graph Relations | All relationship edges between entities in the knowledge graph. |
| `memory://tasks/active` | Active Tasks | List of all currently pending or uncompleted tasks. |
| `memory://decisions/active` | Active ADR Decisions | All accepted Architectural Decision Records (ADRs). |
| `memory://tech_debt/unresolved` | Unresolved Tech Debt | All currently open engineering debt items. |
| `memory://session/delta` | Session Delta | Code modifications, commits, active tasks, and notes created in the last 2 hours. |
| `memory://terminal/recent` | Terminal History | Recent terminal commands, shell interpreters (`pwsh`, `bash`, `nu`), working dirs, and exit codes. |
| `memory://activity/recent` | Recent Activity | Real-time IDE, editor, and developer activity logs. |
| `memory://milestones` | Milestones | Project milestones, deliverables, target dates, and status. |
---
## ⚡ MCP Workflow Prompts (`prompts/list`)
The server registers 5 high-signal workflow prompts:
* **`context_warmup`**: Warm up session context by reading active tasks, recent deltas, and the git worktree.
* **`analyze_tech_debt`**: Inspect open technical debt items and generate a prioritized remediation plan.
* **`summarize_architecture`**: Synthesize active ADRs and knowledge graph entities into an architectural overview.
* **`handoff_routine`**: Invoke the `DevOpsSRE` subagent at session end to generate a standup report and leave a handoff memo.
* **`archive_routine`**: Compress older session summaries into dense milestone retrospectives.
---
## 🛠️ Complete MCP Tool Suite (53 Tools)
The server exposes 53 tools categorized into 7 functional domains:
### 1. Consolidated Smart Primary Tools (11 Domain Handlers)
* **`tasks`**: Complete task lifecycle management (`add`, `update`, `delete`, `list`, `set_criteria`, `verify`).
* **`milestones`**: Project milestone tracking (`add`, `update`, `list`).
* **`handoff_memos`**: Cross-session scratchpad and handoff memos (`leave`, `read`, `clear`).
* **`snippets`**: Reusable code snippet vault with hybrid BM25 + dense vector search (`store`, `search`, `delete`, `tag`).
* **`decisions`**: Architectural Decision Records (ADRs) (`log`, `update`, `query`, `delete`).
* **`tech_debt`**: Engineering technical debt backlog (`log`, `resolve`, `list`).
* **`environment`**: Infrastructure & tool fingerprints tracking (`update_fingerprint`, `read_fingerprint`, `log_requirement`, `register`, `get_details`).
* **`clipboard`**: Pure native Rust OS clipboard interface (`read`, `write`).
* **`hypotheses`**: Diagnostic hypothesis memory for root cause analysis (`log`, `query`).
* **`agent_signals`**: Real-time inter-agent signal bus (`broadcast`, `query`).
* **`process_logs`**: Process and daemon log management (`watch`, `get`, `clear`).
### 2. Knowledge Graph Core (18 Tools)
* `create_entities`, `create_relations`, `add_observations`, `delete_entities`, `delete_relations`, `delete_observations`
* `read_graph`, `search_nodes`, `open_nodes`, `visualize_graph`, `condense_entity`, `merge_entities`, `find_orphans`
* `get_subgraph` (BFS $N$-hop neighborhood expansion)
* `sweep_graph_health` (orphan detection, name similarity, automated merge recommendations)
* `resolve_stale_symbols` (workspace AST cross-referencing to eliminate stale graph nodes)
* `summarize_subgraph` (concise subgraph synthesis)
* `query_graph_path` (BFS shortest path finding)
### 3. AST & Code Intelligence (8 Tools)
* `read_file_skeleton`: Tree-sitter AST structural outline without implementation bodies.
* `replace_ast_node`: Precise structural code replacement preserving comments and formatting.
* `find_symbol_references`: Cross-file symbol reference lookup across snippets and disk source code.
* `get_callers`: Call site and caller identification across the codebase.
* `analyze_impact`: Blast-radius impact analysis of modifying a symbol or file.
* `read_directory_architecture`: Recursive directory structure analysis capped at depth 10.
* `semantic_code_search`: Dense vector semantic code search over indexed source code.
* `manage_subagent_namespace`: Isolated memory namespaces for concurrent subagent workflows.
### 4. Meta, Audit & Intelligence (15 Tools)
* `decisions`, `tech_debt`, `log_error_fix`, `search_error_fixes`, `log_code_change`, `query_recent_changes`
* `omni_search` (Reciprocal Rank Fusion hybrid BM25 + Vector search)
* `get_project_health` (high-level system health dashboard)
* `manage_checkpoint` (snapshot freeze and rollback)
* `query_lineage` (causal lineage linking tasks, ADRs, commits, and error fixes)
* `get_next_actionable_tasks` (topological unblocked task resolver)
* `hypotheses`, `get_preflight_context`, `agent_signals`, `auto_session_checkpoint`
### 5. Task & Milestone Management (2 Tools)
* `tasks`, `milestones`
### 6. Notes, Handoffs & Reporting (4 Tools)
* `handoff_memos`, `add_session_summary`, `generate_standup_report`, `promote_to_entity`
### 7. Git & Worktree Context (2 Tools)
* `get_active_worktree_context`, `query_git_diffs`
---
## 🖥️ Brain Monitor Web UI (`http://127.0.0.1:3000/`)
The server hosts a live, reactive Single Page Application (SPA) dashboard:
* **Interactive Knowledge Graph:** Physics-simulated network graph with node-type coloring, drag-and-drop, and Inspector Panel.
* **Universal Search & Filtering:** Instant debounced search across all tabs with keyboard shortcut (`/`) to jump to the active tab's search bar.
* **Dynamic Pagination:** Configurable page sizes (`10`, `25`, `50`, `100`, `All`) preserving UI responsiveness across large datasets.
* **Descending ADR Ordering:** ADRs are automatically sorted with newest IDs first (e.g., ADR-0104, ADR-0103) on Page 1.
* **Live SSE & WebSocket Telemetry:** Real-time updates without manual browser refresh, wired to all domain mutations.
* **Kanban Board & Audit Ledger:** Direct task state transitions and chronological code change logs.
---
## 🔒 Security & Concurrency Invariants
* **Zero Subprocess Policy**: Native system handlers (`clipboard`, `ast`, `search`, `db`) use pure native Rust crates (`arboard`, `tree-sitter`, `tantivy`, `psycopg`). Invoking external shell interpreters (`powershell.exe`, `wl-paste`, `xclip`, `cmd.exe`) is strictly prohibited.
* **Automated Post-Commit Reconciliation**: `scripts/git-reconcile.py` (installed via `just install-git-hooks`) reconciles referenced ADR and Task statuses immediately upon commit.
* **Atomic Store Write Lock Minimization**: Releases write lock immediately following in-memory mutation, serializing JSON payloads under read guards to prevent blocking concurrent readers.
* **Async Mutex Deadlock Elimination**: Converted all shared state and Neovim locks to `tokio::sync::Mutex` to prevent worker thread pool starvation.
* **Zero-Latency UDP Telemetry**: Bypasses disk I/O thrashing for high-frequency editor telemetry using deduplicated UDP streams (`MCP_UDP_PORT1`, `MCP_UDP_PORT2`).
---
## 🚀 Quick Start & Lifecycle Management
### 1. Build and Deploy
```powershell
cargo build --release
Copy-Item target\release\mcp-memory-server.exe C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe
Copy-Item target\release\mcp-memory-stub.exe C:\Users\reazul.ashraf\.local\bin\mcp-memory-stub.exe
```
`
**Step 1:** To bypass Antigravity's lazy-loading and ensure the server is instantly available for WSL, configure your PowerShell profile to auto-start the background server when you open a terminal:
```powershell
# Add this to your PowerShell profile:
if (-not (Get-Process mcp-memory-server -ErrorAction SilentlyContinue)) { Start-Process -FilePath "C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe" -ArgumentList "--daemon" -WindowStyle Hidden -ErrorAction SilentlyContinue }
# Build and deploy everything across Windows and WSL
just all
# Or deploy Windows server with graceful staged hot-swap
just all-server-win
# Install Git post-commit reconciliation hook
just install-git-hooks
```
**Shutting Down:** If you need to stop or restart the background daemon (e.g., to replace the executable after a recompile), you MUST NEVER use brute-force OS kill commands (e.g., Stop-Process, pkill, or kill). Instead, you MUST ALWAYS use the server's built-in graceful shutdown mechanisms:
1. The CLI flag: `--exit` (or `--restart`)
### 2. Service Management
```powershell
mcp-memory-server.exe --exit
just start # Start background server on port 3000
just stop # Gracefully shut down server
just restart # Graceful restart with health check verification
just verify # Verify deployment health
just version # Check running API version and CLI version
```
2. The HTTP endpoint: `POST http://127.0.0.1:3000/shutdown`
`
**Step 2:** Update your Windows ~/.gemini/config/mcp_config.json to point the CLI to the ultra-lightweight stub (since the server is already running in the background):
`json
### 3. Testing & Parity
```powershell
just test # Run fast parallel tests via cargo-nextest & type-check UI
just test-config # Verify eagerTools configuration parity
just test-ui # Verify dashboard UI endpoint and HTML integrity
```
### 4. Agent Configuration (`mcp_config.json`)
```json
{
"mcpServers": {
"memory": {
"command": "C:\\Users\\reazul.ashraf\\.local\\bin\\mcp-memory-stub.exe",
"mcp-memory": {
"command": "C:\\Users\\reazul.ashraf\\.gemini\\antigravity-cli\\mcp\\mcp-memory\\mcp-memory.exe",
"args": []
}
}
}
`
`
### 2. WSL / Linux Installation (The Stub)
Compile the ultra-lightweight stub as a static Linux binary (from the Windows host):
`powershell
cargo zigbuild --target x86_64-unknown-linux-musl --release -p mcp-memory-stub
wsl.exe -d Ubuntu -e bash -c "cp /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory/target/x86_64-unknown-linux-musl/release/mcp-memory-stub ~/.local/bin/mcp-memory-stub && chmod +x ~/.local/bin/mcp-memory-stub"
`
`
Update your WSL ~/.gemini/config/mcp_config.json:
`json
{
"mcpServers": {
"memory": {
"command": "/home/riz/.local/bin/mcp-memory-stub",
"args": [
"--target", "http://127.0.0.1:3000",
"--wake-cmd", "/mnt/c/Users/reazul.ashraf/.local/bin/mcp-memory-server.exe --daemon"
]
}
}
}
`
*Note: The --wake-cmd ensures that if you start WSL while Windows is completely asleep, the Linux stub will use WSL interop to silently spin up the Windows daemon in the background before connecting.*
`
## Push Safety Gates
The daemon also operates as a global safety gate for Git. Before pushing code, run:
`ash
mcp-memory gate verify
`
This queries the daemon (via HTTP) to confirm if pre-push validation (like running tests via PrePushAuditor) has been cleared by the agent.
`
## Brain Monitor Dashboard
The server hosts a live, real-time SPA dashboard called the **Brain Monitor**.
To view the dashboard, simply navigate to the root endpoint in your browser while the server is running:
`http://127.0.0.1:3000/`
### Dashboard Features:
* **Interactive Knowledge Graph:** A full physics-simulated network graph with node-type coloring, a drag-to-pan canvas, and an interactive Inspector Panel. Click any node to instantly view its stored observations.
* **Actionable Kanban Board:** Visually track all active agent Tasks. You can click 'Complete' directly from the UI to trigger a `POST /api/tasks/{id}/complete` REST call back to the daemon without needing the CLI.
* **Sticky Notes & Search:** Browse your ephemeral notes and utilize the integrated fuzzy-search bar to locate graph entities instantly.
* **Native Dark Mode:** Fully styled for modern development environments.
You can also programmatically query the live backing APIs:
`curl http://127.0.0.1:3000/api/stats`
`curl http://127.0.0.1:3000/api/graph`
`curl http://127.0.0.1:3000/api/tasks`
## Further Reading
For a deep dive into the architecture, the **Redb LSM-tree** embedded database, `tantivy` indexing, and the HTTP SSE event loop, consult the design.md file in this repository.
## Neovim Integration
The linux-nvim and win-nvim MCP servers provide direct Msgpack-RPC communication with Neovim.
For this to work flawlessly across multiple Neovim instances (even split across Windows and WSL), you must load the provided gemini-integration.lua file in your Neovim init.lua:
`lua
dofile("C:/Users/reazul.ashraf/workspace/rust/mcp-memory/gemini-integration.lua")
`
### The "Last Focused Wins" Architecture
When you use the gemini-integration.lua script, Neovim acts as an active telemetry broadcaster.
Whenever you alt-tab into a Neovim window (FocusGained) or switch files (BufEnter):
1. **Fallback Sync:** Neovim instantly writes its unique Session ID (Named Pipe / Unix Socket) to ~/.gemini/active_nvim.txt.
2. **WebSocket Telemetry:** Neovim pushes a JSON payload containing the active filename, cursor row, and column to the Rust server's /nvim/telemetry webhook.
3. **UI Broadcast:** The Rust server updates the global state and broadcasts this over WebSockets (/ws) so that the Brain Monitor Dashboard can animate your active file live in the UI!
### Neovim MCP Tools
The LLM agent interacts with your active Neovim session using a dedicated set of MCP tools. *(Note: /nvim/telemetry is strictly a one-way webhook for Neovim; the LLM uses the tools below to interact).*
* **vim_goto_line**: Open files and jump cursors directly from the LLM.
* **vim_set_diagnostics**: Push inline code review warnings as virtual text.
* **vim_get_active_buffer**: Read live, unsaved buffer contents.
* **vim_get_cursor**: Fetch precise line/column coordinates.
* **vim_get_visual_selection**: Read highlighted code blocks.
```
+15
View File
@@ -0,0 +1,15 @@
# CRITICAL BEHAVIOR: PRIORITIZE THE USER
- **NEVER ignore the user.** When the user asks a question or sends a message, you MUST stop all autonomous debugging/thrashing loops and address the user's message DIRECTLY in your very next turn.
- **Stop before reacting.** If the user points out a flaw (e.g., "why aren't you using MCP memory?", "is this an ansible config?"), do NOT just quietly fix it and continue firing commands. Acknowledge the question, explain the mistake, and answer them.
- **Do not hide behind tools.** A tool execution is not a response to the user.
- **Do not rush.** There is no time limit. Prioritize accuracy, safety, and proper engineering patterns over speed. If you realize a mistake, do not panic and deploy a hacky hotfix. Take a breath, analyze the architecture, and propose the correct fix before acting.
- **Fail Fast & Avoid Execution Loops:** If an action (like checking logs, running a test, or searching for a file) does not yield the expected result after 2 or 3 attempts, STOP. Do not blindly iterate on grep commands or wait endlessly. Fail fast, report the anomaly to the user, and question the fundamental assumptions (e.g., "Is the code actually deployed?", "Is there a cache?"). Never trap the user in a 15-minute execution loop.
- **Enforce Pre-Push Gates Strictly:** Never propose or execute a `git push` without first running the full local test suite or delegating to the `PrePushAuditor`. If tests are broken, fixing them is the only acceptable next step.
- **Decouple Development from Git Lifecycle (Strict Boundary):** Fixing a bug or a test requires *local verification* (e.g., running `uv run pytest`), NOT deployment. Never automatically chain a `git commit` or `git push` at the end of a debugging or coding loop. Git operations are administrative and must be explicitly separated from development work. Do not commit or push without explicit user authorization.
- **Strict Environment Terminology (Never Conflate Staging and Live):** Never use the word "live" when referring to a testing, staging, or QA environment (e.g., `staging`, `aipoc`, `gemini-poc.gs.inseinc.com`). "Live" strictly implies Production. Using these interchangeably causes panic and misrepresents the blast radius of actions. Always use exact environment names: say "deployed to staging" or "testing in QA", reserving "production" or "live" ONLY for the actual production environment.
- **Branching Strategy (No Direct Master Pushes):** Never commit or push directly to `master`. You must always create a new branch (e.g., `git checkout -b feature/xyz` or `bugfix/xyz`), make your changes, squash commits if necessary, and push the feature branch. Merging to master is handled strictly via Pull Requests.
- **Git Worktrees ONLY:** Never use `git checkout -b` or `git switch -c` to change branches inside an existing directory. We use Git Worktrees exclusively. To create a new branch, you must step out of the current directory and run `git worktree add ../<new-directory-name> -b <new-branch-name> master`. Changing branches in-place destroys the worktree-to-directory mapping.
+39
View File
@@ -0,0 +1,39 @@
---
name: global-subagents
description: Defines the standard global subagents (MemoryLibrarian, PrePushAuditor, BugDiagnostician) that must be dynamically invoked for standard workflow steps.
always_on: true
---
# Antigravity Subagents
Invoke these dynamically using the `invoke_subagent` tool. Use `send_message` to communicate.
**CRITICAL:** Before invoking any of these custom subagents for the first time in a conversation, you MUST define them using the `define_subagent` tool with the specific permissions they require.
## 1. MemoryLibrarian
* **Role:** The Knowledge Graph Curator
* **Permissions:** You MUST define this subagent with `enable_mcp_tools: true` and `enable_write_tools: true` so it can autonomously update the graph without hitting permission boundaries.
* **Trigger:** After completing a coding task, refactor, or bug fix.
* **Action:** Send a message detailing the work (with exact git hashes and branches). The Librarian will use MCP tools (`log_code_change`, `log_error_fix`, `create_entities`, etc.) in the background to organize the knowledge graph. **CRITICAL:** The Librarian MUST inspect recent git commits and commit messages for any referenced ADRs (e.g. `ADR-\d+`), and automatically reconcile their lifecycle status in `decisions` to `implemented` with the exact `git_commit` and `git_branch`. No ADR whose code has been committed should ever remain in `accepted` status.
## 2. PrePushAuditor
* **Role:** Quality Gate Enforcer
* **Permissions:** You MUST define this subagent with `enable_write_tools: true` so it can run tests and scripts.
* **Trigger:** Before executing `git push`.
* **Action:** The Auditor runs pre-commit hooks, formatters, linters, unit tests (`uv`, `cargo`) in a branched workspace, checks for uncommitted changes, and verifies if the Git history requires squashing (using `git reset --soft`). It returns a GO / NO-GO push decision to prevent violating push-safety rules.
## 3. BugDiagnostician
* **Role:** Observability & Root Cause Analyst
* **Permissions:** You MUST define this subagent with `enable_write_tools: true`.
* **Trigger:** When facing complex architectural bugs, race conditions, or eventually consistent state issues that are hard to track down.
* **Action:** Instead of guessing or patching blindly, this subagent creates diagnostic scripts, enhances reproducers with detailed logs, and isolates the bug. Once root cause is definitively proven, it hands the diagnostic evidence back to you to implement the fix.
## 4. ScrumMaster
* **Role:** Project & Task Orchestrator
* **Permissions:** You MUST define this subagent with `enable_mcp_tools: true` and `enable_write_tools: true`.
* **Trigger:** When we start a new feature or finish a task.
* **Action:** Passively monitors the `memory://tasks/active` and `memory://milestones` MCP resources. Creates tasks, organizes milestones, and updates statuses when criteria are met using `tasks` and `milestones`.
## 5. DevOpsSRE
* **Role:** Environment & Handoff Manager
* **Permissions:** You MUST define this subagent with `enable_mcp_tools: true` and `enable_write_tools: true`.
* **Trigger:** When dependencies change (e.g., you modify `Cargo.toml`, `package.json`, or `.env`) or at the end of a session.
* **Action:** Updates environment fingerprints when dependency files change. At the end of a session, it executes the `handoff_routine` MCP prompt to automatically generate standup reports and handoff memos.
+16
View File
@@ -0,0 +1,16 @@
---
name: MCP Protocol & Build Constraints
description: Strict constraints for Model Context Protocol stdio transport and project build tooling
---
# 1. MCP Stdio Protocol (Strict NDJSON)
- **Rule (CRITICAL):** The Model Context Protocol (MCP) `stdio` transport is strictly based on **pure NDJSON** (Newline Delimited JSON).
- **Rule:** NEVER inject Language Server Protocol (LSP) style HTTP headers (e.g., `Content-Length: <size>\r\n\r\n`) into MCP `stdio` streams. Payloads must be raw JSON objects delimited by a single newline character (`\n`).
# 2. Build Tooling Constraints (mcp-memory)
- **Rule:** NEVER use `cargo zigbuild` for cross-compilation in the `mcp-memory` project.
- **Rule:** ALWAYS use the project's native `justfile` targets (e.g., `just build-wsl`, `just deploy-stub-wsl`, `just deploy-all`) to manage building and deploying binaries across Windows and WSL boundaries.
# 3. Clipboard & Vision Tooling (Strict MCP Enforcement)
- **Rule (CRITICAL):** You MUST ALWAYS execute the `clipboard` MCP tool with action="read" (`mcp-memory`) when reading or inspecting text or images from the OS clipboard.
- **Rule:** NEVER run ad-hoc PowerShell (`System.Windows.Forms.Clipboard`), bash, or shell scripts to extract clipboard images or saved files. `clipboard` (read) handles native clipboard image extraction, auto-resizing, and compressed JPEG (`.jpg`) encoding to minimize LLM token usage.
+7
View File
@@ -0,0 +1,7 @@
# MCP Memory Integrity Rule
**CRITICAL RULE:** Under NO CIRCUMSTANCES should any agent or subagent directly manipulate, edit, or write raw JSON/data to the MCP Memory Graph files (e.g., `knowledge_graph_master.json`, `.db` files, or any files inside `~/.gemini/mcp_memory/`).
All interactions, additions, creations, and updates to the knowledge graph or task tracker MUST go through the officially exposed MCP tools (such as `tasks` [add, set_criteria, verify], `log_code_change`, `create_entities`, etc.).
Bypassing the MCP API by using file-editing tools (like `replace_file_content` or `write_to_file`) on the database files corrupts the state, bypasses indexing, and destroys the Redb Write-Ahead Log (WAL). If an agent attempts to do this, immediately stop them and route the request correctly through the provided MCP API tools.
+66
View File
@@ -0,0 +1,66 @@
---
name: MCP Memory Centrality & Safety
description: Strict guidelines for interacting with the mcp-memory server, ensuring it remains the central brain and is never forcefully shut down.
---
# MCP Memory Centrality & Safety
## 1. Safety & Port Constraints (NEVER SHUT DOWN)
- **CRITICAL**: NEVER attempt to shut down, kill, or send a POST `/shutdown` request to the `mcp-memory` server (typically running on port 3000).
- If a port conflict occurs (e.g., a Rust panic `AddrInUse` during a `git push` gatekeeper check), **STOP** and immediately notify the user. Do not attempt to auto-resolve the conflict by killing the existing memory server process.
## 2. Proactive "Central Brain" Usage
> [!NOTE] Two-Tier Memory Architecture
> 1. **Tier 1 (Static Markdown)**: Repository rules, coding style, tech constraints, and architectural boundaries belong in static git-tracked markdown (`agent-rules/*.md`, `instructions.md`) and system prompts for 0ms latency and deterministic turn-0 enforcement.
> 2. **Tier 2 (Structured DB & Telemetry)**: The MCP Memory server specializes in high-volume, dynamic data: file modification ledgers (`audit_ledger`), terminal command history, error resolutions (`log_error_fix`), active tasks, and preflight context aggregation.
The MCP Memory server is the central brain. You must be PROACTIVE, not reactive, in using it:
### Passive Resource Retrieval (Zero-Turn Latency)
Before making expensive active tool calls, read available MCP resources:
- **`memory://tasks/active`**: Currently uncompleted tasks, priorities, and criteria.
- **`memory://decisions/active`**: Active architectural decisions in `accepted` status.
- **`memory://tech_debt/unresolved`**: Open engineering tech debt items.
- **`memory://session/delta`**: Recent changes, active tasks, code edits, and notes created in the last 2 hours.
- **`memory://terminal/recent`**: Recent terminal commands, interpreters (`pwsh`, `bash`, `nu`), working dirs, and exit codes.
- **`memory://activity/recent`**: Real-time IDE and developer activity event logs.
- **`memory://milestones`**: Project milestones and deliverable tracking.
- **`memory://graph/entities` & `memory://graph/relations`**: Knowledge graph snapshots.
### Active Tool Invocations
- **Session Starts & Context Drops**: Begin by checking `memory://tasks/active` and `memory://session/delta` (or running `context_warmup` prompt), calling `get_preflight_context` and `omni_search` to regain operational context.
- **Context Switching**: When switching tasks or branches, use `manage_checkpoint` (action: "create") to freeze state, and use `manage_checkpoint` (action: "restore") to restore state for the task.
- **Error Fixes**: The moment a tricky, undocumented, or environment-specific bug is resolved, IMMEDIATELY call `log_error_fix`. Supply `repo_name`, `error_category`, and `stack_trace` so future searches can perform embedding-based match via `search_error_fixes`.
- **Tech Debt**: If you notice an anti-pattern but deliberately skip fixing it to focus on a feature, IMMEDIATELY call `tech_debt` (action: "log") with `description`, `file_path`, `line_range`, `workaround`, `effort_estimate`, and `severity`.
- **Architectural Decisions (ADR) & Lifecycle Closure**:
- When selecting design patterns, crate choices, or system structure, call `decisions` (action: "log") with `author`, `affected_components`, `alternatives_considered`, `decision`, and `consequence`.
- **MANDATORY Definition of Done**: When code implementing an ADR is committed, you MUST IMMEDIATELY call `decisions` (action: "update", id: "ADR-XXXX", status: "implemented", git_commit: <commit_hash>, git_branch: <branch>). NEVER leave an ADR in `accepted` once the implementing code is committed. The repository also executes `scripts/git-reconcile.py` on post-commit hooks (`just install-git-hooks`) to reconcile commit references automatically.
- **Task Management**: When creating tasks, supply `priority` ('low'|'medium'|'high'|'urgent'), `assigned_agent` (e.g. subagent role), `verification_command` (automated test command), and `acceptance_criteria`.
- **VCS & SVN Agnosticism**: Supply `vcs_type` ('git'|'svn'|'hg'), `vcs_revision` (git hash or svn revision like 'r12345'), and `upstream_url` to `log_code_change` and workspace tools.
- **Terminal & Shell Context**: Terminal sessions and commands are automatically tracked in the server over zero-latency UDP. Query `/terminal/history` or `memory://terminal/recent` when analyzing shell execution context.
- **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
Continue to use the `MemoryLibrarian`, `PrePushAuditor`, `BugDiagnostician`, `ScrumMaster`, and `DevOpsSRE` subagents to offload graph curation, pre-push auditing, hypothesis testing, task tracking, and session handoffs.
## 4. Performance & Batching Rules
- **Batch Mutating Operations**: When creating or updating multiple graph entities, code snippets, or observations, always batch items into a single tool call array (e.g. `create_entities` with multiple items) to leverage the server's single-pass transaction flush.
- **High-Signal Tool Confirmations**: Tool call execution responses return structured, informative summaries (entity names, types, created counts, and edge paths). Agents DO NOT need to invoke follow-up `open_nodes` calls purely to confirm successful creation.
- **Tantivy Search Reader Refresh**: Search queries (`omni_search`, `search_nodes`) automatically reload pending commits prior to executing searches, ensuring immediate visibility of newly created items.
- **Bounded Telemetry Buffers**: High-volume telemetry logs (`error_fixes` max 300, `ledger` max 500, `handoff_memos` max 200, `session_summaries` max 200, `agent_signals` max 500) enforce deterministic length caps to guarantee low memory footprints over long sessions.
## 5. Pure Native Rust Invariants & Security
- **Zero External Subprocesses**: Native system handlers (`clipboard`, `ast`, `search`, `db`) MUST use pure native Rust crates (`arboard`, `tree-sitter`, `tantivy`, `psycopg`). Subprocess calls to `powershell.exe`, `wl-paste`, `xclip`, or `cmd.exe` are strictly banned in native handlers.
- **Transient Lock Handling**: Transient OS handle collisions (e.g. Win32 OLE `OpenClipboard` locks) must be handled natively with retry loops and backoffs in Rust.
- **Atomic Serialization Scope**: All store updates (`Store::modify` / `modify_async`) perform state mutation and JSON serialization inside an atomic write lock scope to guarantee thread-safe `DbWriteQueue` synchronization.
- **Path Traversal Guards**: AST and file handler operations enforce path canonicalization (`validate_safe_path`) to prevent directory traversal vulnerabilities (`..`).
+18
View File
@@ -0,0 +1,18 @@
# Neovim UX Protocol & Live Editing
Whenever you need to actively interact with the user's Neovim UI or dynamically inject code edits into their live buffers, use the nvim_exec tool (with action 'lua') (the "God Mode" escape hatch).
## 1. Showing UI Feedback (Agent Notifications)
The user has a global Lua table _G.gemini loaded in their Neovim environment. You can use it to pop up a floating notification window when you are starting a background task.
**Lua snippet to execute:**
``lua
_G.gemini.show_progress("Analyzing...")
``
## 2. Live Undo-able Code Injection
Instead of using replace_file_content to write code directly to the hard drive, you can pipe your code changes directly into the user's active buffer memory. This allows the user to instantly press undo to revert your code.
**Workflow Rule:**
If the user asks you to "refactor this block", use vim.api.nvim_buf_set_lines to inject your response instantly into their live editor. Do NOT write to disk unless they explicitly say "save the file".
+38
View File
@@ -0,0 +1,38 @@
# Neovim MCP Architecture (Cross-Platform)
We use a modular, cross-platform approach for MCP Neovim integration to cleanly connect with Neovim instances across Windows and WSL.
## The Architecture
1. **`mcp-memory-server`:** The core Windows daemon (handles state, lock-files, and global graph).
2. **`mcp-memory-stub`:** The WSL proxy that forwards standard json-rpc to the Windows daemon.
3. **`mcp-memory-nvim`:** The unified cross-platform Neovim MCP server binary (`mcp-memory-nvim.exe` on Windows, `mcp-memory-nvim` on Linux). Its sole responsibility is finding active Neovim instances (via named pipes on Windows or domain sockets on Unix/WSL) and sending RPC commands to them.
This isolates editor-control logic natively to whichever OS environment execution is running in.
## How the MCP Server Gets Called
The Antigravity CLI (`agy`) acts as the MCP Client and automatically manages the lifecycle of these servers.
1. **Registration:** The servers are registered in the global configuration file:
- WSL: `/home/riz/.gemini/config/mcp_config.json`
- Windows: `C:\Users\reazul.ashraf\.gemini\config\mcp_config.json`
2. **Execution:**
When `agy` starts up, it reads `mcp_config.json`. If it finds `"win-nvim": { "command": "C:\\Users\\reazul.ashraf\\.local\\bin\\mcp-memory-nvim.exe" }`, it will spawn that binary as a background subprocess using standard `stdio`.
3. **Communication:**
- The LLM requests to use a consolidated tool (e.g., `nvim_workspace` with action `focus`, or `nvim_exec`).
- The `agy` CLI sends a JSON-RPC request to the `mcp-memory-nvim` subprocess via its `stdin`.
- The Rust MCP Server receives the request, connects to the Neovim active socket/pipe (`~/.gemini/active_nvim.txt` or `\\.\pipe\nvim.*`), sends the Msgpack-RPC command, and writes the JSON-RPC response back to `stdout`.
- The `agy` CLI reads the response from `stdout` and returns it to the LLM context.
## Capabilities & Requirements
To use this architecture, Neovim must run the `gemini-integration.lua` script to broadcast its active socket to `~/.gemini/active_nvim.txt`.
The MCP server provides 5 cohesive mega-tools:
1. **`nvim_buffer`** (actions: `read`, `replace`, `save`, `undo`, `redo`, `create_scratch`)
2. **`nvim_workspace`** (actions: `list_buffers`, `list_windows`, `focus`, `split`, `cwd`)
3. **`nvim_intelligence`** (actions: `hover`, `definition`, `references`, `outline`, `query`, `diagnostics`, `rename`, `code_action`)
4. **`nvim_ui`** (actions: `highlight`, `ghost_text`, `clear`)
5. **`nvim_exec`** (actions: `lua`, `vimscript`, `terminal`)
+35
View File
@@ -0,0 +1,35 @@
---
name: nvim_editing
description: Route code edits to Neovim buffers instead of disk/console when Neovim is running.
trigger: always_on
---
# Neovim Code Editing (Agnostic & Universal)
These rules apply universally to ALL Neovim environments (Windows, WSL, or any remote server). Do not treat them as OS-specific.
## 1. No Invisible Shell Edits (CRITICAL)
**NEVER** use `sed`, `awk`, `python -c`, or `echo` to modify file contents from the terminal.
File edits must use EXACTLY one of two paths:
- **Active UI (Neovim MCP responsive):** Route edits through specialized MCP tools to provide live visual feedback.
- **No UI (Neovim MCP unresponsive):** Fall back to the built-in `replace_file_content` tool to generate clean chat diffs.
## 2. Strict Tool Adherence (No Raw Lua RCE)
You must strictly use the specialized, sandboxed Neovim MCP tools:
- `nvim_buffer`: For reading, writing, saving, and creating scratch buffers.
- `nvim_workspace`: For creating splits and focusing panes.
- `nvim_ui`: For highlighting diffs, adding ghost text, and showing previews.
**DO NOT** use `nvim_exec` (action `lua`) to mutate editor state. It is restricted to **READ-ONLY** queries.
## 3. Headless Quarantine
Headless mode (`nvim --headless`) is strictly banned for interactive edits.
**NEVER** run `nvim <filename>` via terminal commands to edit or "open" a file for the user (it spawns a zombie ghost process).
Headless instances are allowed ONLY for non-interactive background batch processing (e.g., project-wide formatting or linting).
## 4. UI Presentation & Chat Console Minimization
Never output large plans, context blocks, or architectural discussions to the chat console if Neovim is running. You MUST use the `nvim_buffer` and `nvim_workspace` tools to open a vertical split (e.g., `Antigravity_Plan.md` scratch buffer) and present the markdown natively. Reserve the chat console strictly for brief confirmations.
## 5. Visual Cues & Auto-Save
When manipulating buffers via MCP:
1. **Focus & Jump:** Always switch the active Neovim window to the edited buffer and jump the cursor to the changed line.
2. **Auto-Save:** Auto-save the buffer by default so linters/compilers see it immediately.
3. **Interactive Review:** If pausing for human review (leaving unsaved), you MUST spawn a background `curl` task listening to `http://127.0.0.1:3000/events/wait?topic=nvim:save...` to wake you up when the user saves (`:w`).
+8
View File
@@ -0,0 +1,8 @@
# Neovim MCP Enforcement Rule
When interacting with the user's Neovim editor (e.g., opening a file, moving the cursor, reading the active buffer, setting diagnostics), you MUST ALWAYS use the MCP tools provided by the `win-nvim` (Neovim) MCP server.
- You are strictly forbidden from using bash scripts, `nvim --server`, or other raw terminal/shell hacks to remote-control Neovim.
- You must rely entirely on the consolidated MCP tool registry (`nvim_buffer`, `nvim_workspace`, `nvim_intelligence`, `nvim_ui`, `nvim_exec`).
- If the tool is eagerly loaded, use it natively as an agent tool. If lazy-loaded, invoke it via the `call_mcp_tool` mechanism.
+21
View File
@@ -0,0 +1,21 @@
# Neovim Auto-Sync Rule
When the user asks you to "open these files", "show me what you're working on in nvim", or after a large refactor where visual verification is needed, you MUST push the relevant files to the user's active Neovim buffer.
## Execution Paths
### 1. Neovim RPC (Preferred when outside Tmux)
If the `$NVIM` environment variable is available (e.g., you are running inside a Neovim terminal like toggleterm), or if you can locate the Neovim server socket in `/tmp/nvim.*/0`, use the remote feature:
```bash
nvim --server /tmp/nvim.sock --remote file1.py file2.py
```
*(Tip: You may need to run `lsof -c nvim | grep "tmp"` or search `/tmp/` to dynamically find the exact socket path if `$NVIM` is not exported to your current shell session).*
### 2. Tmux Injection (Fallback when inside Tmux)
If Neovim is running inside Tmux (and not easily reachable via RPC), use `tmux send-keys` to inject the files into the Neovim args list:
```bash
tmux send-keys -t 0:1 Escape ":args file1.py file2.py" C-m Escape ":argdo e" C-m
```
*(Tip: Adjust the tmux target `-t 0:1` if the user's nvim pane is elsewhere, e.g., `-t 0:1.2`).*
This ensures the user does not have to manually open files you have just modified, regardless of whether they are using Tmux or not.
+26
View File
@@ -0,0 +1,26 @@
# Rust Guidelines & Quirks
## Concurrency & Async Locking
- **Lock Poisoning & Async Safety:** Use `tokio::sync::RwLock` for state shared across Tokio async tasks (such as active WebSocket clients) to avoid blocking worker threads during broadcast fanouts. For synchronous locks, prefer non-poisoning structures or recover cleanly using `.unwrap_or_else(|e| e.into_inner())`.
- **Atomic Store Write Lock Minimization:** Minimize write lock duration by executing JSON serialization under read lock guards, keeping write guards strictly to in-memory state mutations.
- **Two-Phase Graph Condensation:** When performing summarization or condensation across stores (`condense_graph_worker`), implement a two-phase commit: read non-destructively and synthesize observations first, insert into the knowledge graph, and only prune summarized source records by timestamp/content after successful insertion.
- **Watch-Based Non-Destructive Shutdown Channels:** Use `tokio::sync::watch` rather than `tokio::sync::mpsc` for cancellation signaling to allow multiple workers to observe shutdown state without consuming or starving sibling workers.
- **Redb Transient Lock Resilience:** Implement retry loops with exponential backoff on table or database lock contention before aborting or panicking.
- **Safe RPC Request Tracking:** Clean up pending request maps (`PENDING_REQUESTS.remove(&msgid)`) upon timeouts or channel disconnects to prevent orphan memory leaks.
- **Panic-Free Architecture:** Avoid raw `.unwrap()` in production runtime paths. Use `.unwrap_or_default()`, or proper `Result` propagation for runtime operations.
- **Background Worker Task Supervision:** Always track `tokio::task::JoinHandle` handles for background workers (`ttl_sweeper_worker`, `index_committer_worker`, `condense_graph_worker`) and log thread exit or panic events cleanly.
- **Offload Heavy Index Rebuilds:** In `MemoryState::rebuild_index`, offload full graph cloning and Tantivy document re-indexing into `tokio::task::spawn_blocking` to avoid stalling async worker threads.
## Embedding & Memory Optimizations
- **Dynamic Character Batching:** In embedding generation (`generate_embeddings_async`), dynamically chunk batches based on total character size (e.g. 16,384 chars) rather than static item counts to prevent OOM spikes on large files while maximizing SIMD throughput.
- **SIMD Cosine Similarity:** Compute dot-product and norm accumulators in single-pass iterator folds to facilitate vector auto-vectorization across CPU instruction sets (`AVX2`/`NEON`).
- **Memory Truncation Bounds:** Enforce a 4KB ceiling on telemetry detail strings (`ActivityRecord`, `TerminalHistory`) before queuing items into ring buffers to bound heap usage.
## IDE & Rust-Analyzer Quirks
- **Boolean NOT Operator (E0600):** Avoid using the unary `!` operator on complex boolean expressions inside closures (e.g., `!(a == b && c == d)`). Rewrite these expressions using De Morgan's laws (e.g., `a != b || c != d`).
- **Option::None Shadowing:** If `rust-analyzer` throws a `non_snake_case` warning for `None` during pattern matching (often caused by wildcard imports like `use crate::models::*;`), explicitly namespace as `std::option::Option::None`.
- **Deep Cloning across Thread Boundaries:** Construct target primitive payloads or target structs on the main thread *before* moving into `tokio::task::spawn_blocking` closures to avoid cloning massive structs across thread boundaries.
## Windows MSVC & Test Concurrency
- **ONNX Runtime / Fastembed Concurrency Resilience:** ONNX Runtime (`ort.dll` via `fastembed`) model initialization is strictly managed via a thread-safe singleton (`OnceLock<Mutex<TextEmbedding>>`) behind a process-wide `INIT_MUTEX`. The historical `0xc0000374 STATUS_HEAP_CORRUPTION` crash under uncoordinated C-ABI initializations is fully resolved. Full parallel test execution (`cargo test --workspace` or `cargo nextest run --workspace`) across all CPU cores without `--test-threads=1` is safe, recommended, and standard across all platforms.
+15
View File
@@ -0,0 +1,15 @@
# Antigravity State Management & Git Worktree Rules
## State Tracking & Persistent Memory
- **Long-term Knowledge (MCP):** Use the MCP memory graph strictly for long-term, persistent facts such as architectural decisions, environment invariants, SSH mappings, and domain entities. Static developer preferences and rules are kept in git-tracked markdown files.
- **Transient State (Git):** Do NOT write transient task progress (e.g., "currently editing line 42") to MCP memory. Continue to use verbose, incremental local `git` commits to track short-term state and maintain rollback safety.
## Branching Strategy & Workflow (Git Worktrees)
- **Architecture:** This environment utilizes Git Bare repositories with worktrees (e.g., a `.bare` directory alongside branch directories like `master`, `feature-x`).
- **Navigation Rule (CRITICAL):** NEVER attempt to run tests, execute git commands, or invoke subagents against the root project folder or the `.bare` directory. ALWAYS navigate into the specific active worktree directory (e.g., `cd project-name/master`).
- **Never Modify Master Directly:** Do NOT make code modifications or dirty the working tree of the `master` or `main` directories.
- **Isolated Worktrees:** Before beginning a new task, create an isolated sibling worktree directory for a new feature branch.
- *Example:* From inside `master/`, run `git worktree add ../feat-my-new-task -b feat-my-new-task`.
- **Standard Workflow:** Change directory into the newly created worktree (`cd ../feat-my-new-task`) and make all verbose incremental commits there.
- **Cleanup:** Once the task is complete, squashed, pushed, and merged, delete the local worktree branch (`git worktree remove ../feat-my-new-task`).
- **Initialization:** If the project is not using a bare worktree layout, invoke the `setup-bare-worktree` skill first.
+40
View File
@@ -0,0 +1,40 @@
---
name: workflow-constraints
description: Strict behavioral constraints for Jenkins, staging deployments, git investigations, and background tasks.
trigger: always_on
---
# 1. Strict Background Task Control
- **Rule:** Do not run continuous background polling, background loops, or test suites unless explicitly requested.
- **Rule:** If the user says 'stop' or 'don't run anything', kill all tasks immediately and stop launching new ones.
# 2. No Unauthorized Jenkins Deployments
- **Rule:** Never trigger Jenkins CI/CD pipelines (`jn run`) automatically. Always wait for explicit user approval before deploying via Jenkins.
# 3. Hot Deploy & Visual Verification
- **Rule:** In `ai-pr-review`, never `git push` without first hot-deploying to staging using `just deploy-code aipoc` and running the relevant sandbox E2E test to allow visual confirmation, and wait for human visual verification to complete before git push.
- **WARNING (Jenkins Conflict):** Hot-deploying creates manual containers on `aipoc`. If you subsequently trigger a Jenkins deployment to `aipoc` (`ai-pr-review-ansible-deploy`), the Ansible playbook will fail with a Docker naming conflict (`Error when allocating new name: Conflict`). You MUST SSH into `aipoc` and manually remove the conflicting containers (`sudo docker rm -f <container_id>`) before running the Jenkins deployment.
# 4. Git Investigation Constraints
- **Rule:** Confine code investigations and debugging to actual code diffs. Never rely lazily on git commit messages to determine what changed.
# 5. Git Push and Gatekeeper Constraints
- **Rule:** Before running `git push`, you MUST ensure the git working tree is completely clean (`git status`). Untracked scratch scripts or uncommitted formatting changes will cause the pre-push gatekeeper to hang indefinitely.
- **Rule:** If a `git push` task hangs, kill it, investigate and clean the working tree (using `git clean -fd` or `git restore`), and retry. NEVER poll a hanging push task in a loop.
- **Rule:** NEVER use `git push --no-verify` to bypass a hanging pre-push hook. The hook is hanging due to environment state, not failing tests.
# 6. Strict Process Termination (Zombie Cleanups)
- **Rule (CRITICAL):** NEVER kill processes using broad wildcard name matches (e.g., Get-Process | Where-Object Name -match "cargo|rustc"). This causes collateral damage to globally deployed binaries (e.g., in ~/.local/bin/).
- **Rule:** When cleaning up zombie files or locked compiler processes, you MUST strictly target the process by its Path to ensure it originates from the current workspace's arget/ directory (e.g., Get-Process | Where-Object { $_.Path -match "\\target\\" } | Stop-Process -Force).
# 7. GCP / GCR Troubleshooting Constraints
- **Rule (GCR 403 Forbidden):** Google Container Registry obscures `404 Not Found` errors as `403 Forbidden` for security reasons. If a `docker pull` or `podman pull` from GCR fails with `403 Forbidden`, you MUST explicitly verify that the image path and tag are 100% correct (checking prefixes, project IDs, and typos) before assuming it is an IAM or Service Account permission issue.
# 8. GCP Infrastructure Provisioning (gcloud & Cloud Armor)
- **Rule (Cloud Armor IP Limits):** GCP Cloud Armor security policies strictly enforce a limit of **10 IP ranges per rule** (`--src-ip-ranges`). When allowlisting large services (like Atlassian Bitbucket which has 11+ IP CIDR blocks), you MUST split the ranges across multiple rules (e.g., priority 1000 and 1001) to prevent the `Only a maximum of 10 IP ranges allowed per rule` API error.
- **Rule (gcloud Idempotency):** When writing bash scripts to provision GCP infrastructure, NEVER use bare `gcloud ... create` commands. You MUST wrap all creation commands in existence checks (e.g., `if ! gcloud ... describe ... >/dev/null 2>&1; then ... fi`) to ensure the script is fully idempotent and can be safely retried upon failure.
# 9. Rust Build System & Toolchain Resilience
- **Rule (SChannel VPN Revocation Bypass):** To prevent `CRYPT_E_NO_REVOCATION_CHECK` errors when fetching crates over corporate VPNs, ensure `.cargo/config.toml` specifies `[http] check-revoke = false`.
- **Rule (sccache Caching Efficiency):** When using `sccache` as `rustc-wrapper`, set `incremental = false` under `[profile.dev]` in `.cargo/config.toml`. `sccache` cannot cache incremental compilation units.
- **Rule (sccache Daemon Recovery):** If `sccache` fails with socket error 10054 (`connection forcibly closed`), restart the daemon using `sccache --stop-server; Start-Sleep -Seconds 1; sccache --start-server` before retrying compilation.
- **Rule (cargo-llvm-cov Toolchain Matching):** Always set `LLVM_COV` and `LLVM_PROFDATA` environment variables to the matching `rustup` toolchain LLVM binaries (`.../lib/rustlib/<target>/bin/llvm-cov.exe`) to prevent LLVM profile format version mismatches.
BIN
View File
Binary file not shown.
-107
View File
@@ -1,107 +0,0 @@
$ErrorActionPreference = 'Stop'
Write-Host "Building mcp-memory (server and stub) for Windows..." -ForegroundColor Cyan
& rustup run stable cargo build --release --workspace --exclude mcp-memory-linux-nvim
if ($LASTEXITCODE -ne 0) {
Write-Error "Windows build failed!"
exit $LASTEXITCODE
}
Write-Host "Building mcp-memory (server and stub) for Linux (WSL)..." -ForegroundColor Cyan
wsl.exe -d Ubuntu -e bash -c 'cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && export PATH="$HOME/.cargo/bin:$PATH" && cargo build --release --workspace --exclude mcp-memory-win-nvim'
if ($LASTEXITCODE -ne 0) {
Write-Error "Linux build failed!"
exit $LASTEXITCODE
}
Write-Host "`nBuild successful! Gracefully shutting down running servers..." -ForegroundColor Green
$serverExe = "C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe"
if (Test-Path $serverExe) {
& $serverExe --exit 2>$null
}
try {
$tokenPath = "C:\Users\reazul.ashraf\.gemini\mcp_memory\admin.token"
$headers = @{}
if (Test-Path $tokenPath) {
$headers["Authorization"] = "Bearer $(Get-Content -Path $tokenPath -Raw)".Trim()
}
Invoke-RestMethod -Uri "https://127.0.0.1:3000/shutdown" -Method Post -Headers $headers -SkipCertificateCheck -ErrorAction Stop | Out-Null
} catch {
# The response often ends prematurely because the server abruptly kills its own process during shutdown. This is expected.
}
Start-Sleep -Seconds 2
function Get-ExeVersion {
param([string]$Path, [string]$EnvName)
if ($EnvName -eq 'WSL') {
$checkCmd = "if [ -x ""$Path"" ]; then $Path --version; else echo 'Not Found'; fi"
$output = wsl.exe -d Ubuntu -e bash -c $checkCmd
} else {
if (Test-Path $Path) {
$output = & $Path --version 2>&1
} else {
$output = 'Not Found'
}
}
if ([string]::IsNullOrWhiteSpace($output)) { return "No Output / Errored" }
return $output.Trim()
}
function Deploy-If-Needed {
param([string]$Source, [string]$Dest, [string]$EnvName)
$sourceVerPath = $Source
if ($EnvName -eq 'WSL') {
$sourceVerPath = wsl.exe -d Ubuntu -e wslpath -u $Source
}
$sourceVer = Get-ExeVersion -Path $sourceVerPath -EnvName $EnvName
$destVer = Get-ExeVersion -Path $Dest -EnvName $EnvName
if ($sourceVer -eq $destVer -and $sourceVer -ne "Not Found") {
Write-Host " Skip: $($Dest) is already up-to-date ($sourceVer)" -ForegroundColor DarkGray
return
}
Write-Host " Update: $($Dest) ($destVer -> $sourceVer)" -ForegroundColor Yellow
if ($EnvName -eq 'WSL') {
# Translate source path to WSL path
$wslSource = wsl.exe -d Ubuntu -e wslpath -u $Source
wsl.exe -d Ubuntu -e bash -c "mv $Dest ${Dest}.\$(date +%s).old 2>/dev/null || true; cp $wslSource $Dest && chmod +x $Dest"
} else {
if (Test-Path $Dest) {
$rand = Get-Random
Move-Item -Path $Dest -Destination "${Dest}.${rand}.old" -Force -ErrorAction SilentlyContinue
}
Copy-Item -Path $Source -Destination $Dest -Force
}
$newDestVer = Get-ExeVersion -Path $Dest -EnvName $EnvName
if ($newDestVer -eq $sourceVer) {
Write-Host " Success: $($Dest) verified as $newDestVer" -ForegroundColor Green
} else {
Write-Error " Failed: $($Dest) version mismatch after copy! Expected $sourceVer, got $newDestVer"
}
}
Write-Host "`nDeploying and verifying binaries..." -ForegroundColor Cyan
$winBase = "C:\Users\reazul.ashraf\.local\bin"
$wslBase = "/home/riz/.local/bin"
$winTarget = "target\release"
$wslTarget = "target\release"
Deploy-If-Needed -Source "$winTarget\mcp-memory-server.exe" -Dest "$winBase\mcp-memory-server.exe" -EnvName "Win"
Deploy-If-Needed -Source "$winTarget\mcp-memory-stub.exe" -Dest "$winBase\mcp-memory-stub.exe" -EnvName "Win"
Deploy-If-Needed -Source "$winTarget\mcp-memory-win-nvim.exe" -Dest "$winBase\mcp-memory-win-nvim.exe" -EnvName "Win"
# Note: Source is local windows path, but Deploy-If-Needed translates it to wslpath
Deploy-If-Needed -Source "$wslTarget\mcp-memory-server" -Dest "$wslBase/mcp-memory-server" -EnvName "WSL"
Deploy-If-Needed -Source "$wslTarget\mcp-memory-stub" -Dest "$wslBase/mcp-memory-stub" -EnvName "WSL"
Deploy-If-Needed -Source "$wslTarget\mcp-memory-linux-nvim" -Dest "$wslBase/mcp-memory-linux-nvim" -EnvName "WSL"
Write-Host "`nCopying instructions.md..." -ForegroundColor Cyan
Copy-Item instructions.md "C:\Users\reazul.ashraf\.gemini\antigravity-cli\mcp\memory\instructions.md" -Force
wsl.exe -d Ubuntu -e bash -c "mkdir -p /home/riz/.gemini/antigravity-cli/mcp/memory && cp $(wsl.exe -d Ubuntu -e wslpath -u instructions.md) /home/riz/.gemini/antigravity-cli/mcp/memory/instructions.md"
Write-Host "`nBuild and deployment complete!" -ForegroundColor Green
-20
View File
@@ -1,20 +0,0 @@
use std::process::Command;
fn main() {
let git_hash = Command::new("git")
.args(&["rev-parse", "--short", "HEAD"])
.output()
.ok()
.and_then(|out| String::from_utf8(out.stdout).ok())
.unwrap_or_else(|| "unknown".to_string());
let git_date = Command::new("git")
.args(&["log", "-1", "--format=%cd", "--date=format:%Y.%m.%d"])
.output()
.ok()
.and_then(|out| String::from_utf8(out.stdout).ok())
.unwrap_or_else(|| "unknown".to_string());
let version = format!("{} ({} {})", env!("CARGO_PKG_VERSION"), git_date.trim(), git_hash.trim());
println!("cargo:rustc-env=APP_VERSION={}", version);
}
-1
View File
@@ -1 +0,0 @@
Finished `dev` profile [unoptimized + debuginfo] target(s) in 14.64s
+2 -2
View File
@@ -33,10 +33,10 @@ $wslBase = "/home/riz/.local/bin"
$components = @(
@{ Name = "Server (Win)"; Path = "$winBase\mcp-memory-server.exe"; Env = "Win" },
@{ Name = "Stub (Win)"; Path = "$winBase\mcp-memory-stub.exe"; Env = "Win" },
@{ Name = "Win-Nvim"; Path = "$winBase\mcp-memory-win-nvim.exe"; Env = "Win" },
@{ Name = "Nvim (Win)"; Path = "$winBase\mcp-memory-nvim.exe"; Env = "Win" },
@{ Name = "Server (WSL)"; Path = "$wslBase/mcp-memory-server"; Env = "WSL" },
@{ Name = "Stub (WSL)"; Path = "$wslBase/mcp-memory-stub"; Env = "WSL" },
@{ Name = "Linux-Nvim"; Path = "$wslBase/mcp-memory-linux-nvim"; Env = "WSL" }
@{ Name = "Nvim (WSL)"; Path = "$wslBase/mcp-memory-nvim"; Env = "WSL" }
)
foreach ($comp in $components) {
-312
View File
@@ -1,312 +0,0 @@
Blocking waiting for file lock on package cache
Blocking waiting for file lock on package cache
Compiling proc-macro2 v1.0.107
Compiling quote v1.0.47
Compiling unicode-ident v1.0.24
Compiling getrandom v0.4.3
Compiling serde_core v1.0.229
Checking windows-link v0.2.1
Compiling cfg-if v1.0.4
Compiling shlex v2.0.1
Compiling find-msvc-tools v0.1.12
Checking smallvec v1.16.0
Checking stable_deref_trait v1.2.1
Checking windows-sys v0.61.2
Checking pin-project-lite v0.2.17
Checking futures-core v0.3.34
Checking memchr v2.8.3
Checking itoa v1.0.18
Compiling serde v1.0.229
Checking bytes v1.12.1
Checking once_cell v1.21.4
Checking futures-sink v0.3.34
Compiling parking_lot_core v0.9.12
Checking scopeguard v1.2.0
Checking slab v0.4.12
Checking mio v1.2.3
Checking socket2 v0.6.5
Checking log v0.4.34
Checking futures-channel v0.3.34
Compiling version_check v0.9.5
Compiling jobserver v0.1.35
Checking lock_api v0.4.14
Compiling zmij v1.0.23
Checking futures-task v0.3.34
Checking futures-io v0.3.34
Compiling num-conv v0.2.2
Compiling time-core v0.1.9
Compiling cc v1.4.5
Compiling autocfg v1.5.1
Checking deranged v0.5.8
Checking http v1.5.0
Checking tracing-core v0.1.36
Compiling syn v3.0.5
Compiling syn v2.0.119
Checking percent-encoding v2.3.2
Checking litemap v0.8.3
Compiling time-macros v0.2.32
Checking writeable v0.6.4
Checking powerfmt v0.2.0
Checking equivalent v1.0.2
Checking parking_lot v0.12.5
Checking utf8_iter v1.0.4
Compiling icu_normalizer_data v2.3.0
Compiling icu_properties_data v2.3.0
Checking fnv v1.0.7
Compiling serde_json v1.0.151
Checking http-body v1.1.0
Checking hashbrown v0.17.1
Compiling httparse v1.10.1
Checking base64 v0.22.1
Compiling synstructure v0.13.2
Checking tower-service v0.3.3
Compiling ring v0.17.14
Compiling num-traits v0.2.19
Checking indexmap v2.14.2
Checking time v0.3.55
Checking try-lock v0.2.5
Compiling serde_derive v1.0.229
Compiling zerovec-derive v0.11.6
Compiling tokio-macros v2.7.2
Compiling zerofrom-derive v0.1.7
Compiling yoke-derive v0.8.2
Compiling displaydoc v0.2.7
Compiling futures-macro v0.3.34
Compiling tracing-attributes v0.1.31
Compiling zeroize_derive v1.5.0
Checking tokio v1.53.1
Compiling libc v0.2.189
Checking atomic-waker v1.1.2
Checking futures-util v0.3.34
Checking subtle v2.6.1
Checking zerofrom v0.1.8
Checking want v0.3.1
Checking zeroize v1.9.0
Checking tracing v0.1.44
Checking form_urlencoded v1.2.2
Checking yoke v0.8.3
Checking getrandom v0.2.17
Checking httpdate v1.0.3
Checking untrusted v0.9.0
Checking rustls-pki-types v1.15.1
Compiling generic-array v0.14.7
Checking sync_wrapper v1.0.2
Checking tower-layer v0.3.3
Checking tokio-util v0.7.19
Checking zerovec v0.11.8
Checking zerotrie v0.2.5
Compiling rustls v0.23.44
Compiling pkg-config v0.3.34
Checking ipnet v2.12.2
Compiling async-trait v0.1.92
Compiling cmake v0.1.58
Compiling cookie v0.18.2
Checking h2 v0.4.19
Checking tinystr v0.8.4
Checking potential_utf v0.1.6
Compiling dunce v1.0.5
Checking mime v0.3.17
Compiling zstd-sys v2.1.0+zstd.1.5.7
Compiling unicase v2.9.0
Checking icu_locale_core v2.3.0
Compiling thiserror v2.0.20
Checking icu_collections v2.3.0
Compiling fs_extra v1.3.0
Compiling crossbeam-utils v0.8.23
Checking typenum v1.20.1
Compiling mime_guess v2.0.5
Checking hyper v1.11.1
Checking tower v0.5.3
Checking rustls-webpki v0.103.15
Checking icu_provider v2.3.1
Compiling thiserror-impl v2.0.20
Compiling aws-lc-sys v0.45.0
Checking http-body-util v0.1.5
Checking icu_normalizer v2.3.0
Checking icu_properties v2.3.0
Checking hyper-util v0.1.20
Compiling litrs v1.0.0
Checking ryu v1.0.23
Checking psl-types v2.0.11
Checking either v1.18.0
Compiling crunchy v0.2.4
Checking idna_adapter v1.2.2
Checking serde_urlencoded v0.7.1
Compiling document-features v0.2.12
Checking idna v1.1.0
Checking webpki-roots v1.0.9
Compiling ident_case v1.0.1
Checking tokio-rustls v0.26.5
Checking byteorder v1.5.0
Checking url v2.5.8
Checking publicsuffix v2.3.0
Checking bitflags v2.13.1
Compiling crossbeam-epoch v0.9.21
Compiling windows_x86_64_msvc v0.52.6
Compiling zstd-safe v7.3.0
Checking regex-syntax v0.8.11
Compiling strsim v0.11.1
Checking hyper-rustls v0.27.9
Checking cookie_store v0.22.1
Checking tower-http v0.6.11
Compiling darling_core v0.24.1
Checking block-buffer v0.10.4
Checking crypto-common v0.1.7
Checking num-integer v0.1.47
Checking ownedbytes v0.9.0
Compiling crossbeam-deque v0.8.8
Compiling typeid v1.0.3
Checking once_cell_polyfill v1.70.2
Compiling aws-lc-rs v1.18.1
Checking utf8parse v0.2.2
Compiling prettyplease v0.3.0
Checking anstyle v1.0.14
Checking tantivy-common v0.11.0
Checking anstyle-parse v1.0.0
Compiling darling_macro v0.24.1
Checking anstyle-wincon v3.0.11
Checking num-bigint v0.4.8
Checking digest v0.10.7
Checking reqwest v0.12.28
Checking bitpacking v0.9.3
Checking futures-executor v0.3.34
Compiling fs-err v3.3.1
Checking anstyle-query v1.1.5
Compiling rayon-core v1.13.0
Checking colorchoice v1.0.5
Checking is_terminal_polyfill v1.70.2
Checking utf8-ranges v1.0.5
Compiling rustversion v1.0.23
Checking untrusted v0.7.1
Compiling erased-serde v0.4.10
Checking futures v0.3.34
Checking tantivy-bitpacker v0.10.0
Checking anstream v1.0.0
Checking zstd v0.13.3
Checking tantivy-fst v0.5.0
Checking simple_asn1 v0.6.4
Checking windows-targets v0.52.6
Compiling darling v0.24.1
Checking uuid v1.26.0
Checking itertools v0.14.0
Checking tokio-stream v0.1.19
Checking rust-mcp-schema v2.0.0
Checking pem v3.0.6
Compiling crc32fast v1.5.1
Checking minimal-lexical v0.2.1
Checking signature v2.2.0
Checking murmurhash32 v0.3.1
Checking clap_lex v1.1.0
Compiling heck v0.5.0
Checking cpufeatures v0.2.17
Checking allocator-api2 v0.2.21
Checking foldhash v0.2.0
Compiling typetag v0.2.23
Compiling winapi v0.3.9
Checking nom v7.1.3
Checking tantivy-stacker v0.7.0
Checking sha2 v0.10.9
Compiling clap_derive v4.6.4
Checking tantivy-sstable v0.7.0
Checking hashbrown v0.16.1
Checking clap_builder v4.6.6
Compiling rust-mcp-macros v2.0.0
Compiling bon-macros v3.10.1
Checking rust-mcp-transport v2.0.0
Checking windows-sys v0.59.0
Checking hmac v0.12.1
Checking regex-automata v0.4.18
Checking axum-core v0.5.6
Checking ordered-float v5.5.0
Compiling typetag-impl v0.2.23
Checking serde_path_to_error v0.1.20
Checking fastrand v2.5.0
Checking inventory v0.3.24
Checking downcast-rs v2.0.2
Checking matchit v0.8.4
Checking fastdivide v0.4.2
Checking option-ext v0.2.0
Checking base64 v0.23.1
Checking fs4 v0.13.1
Checking arc-swap v1.9.2
Checking tantivy-columnar v0.7.0
Checking dirs-sys v0.5.0
Checking tempfile v3.27.0
Checking clap v4.6.6
Checking regex v1.13.1
Checking tantivy-query-grammar v0.26.0
Checking axum v0.8.9
Checking rayon v1.12.0
Checking axum-server v0.8.0
Checking lru v0.16.4
Checking crossbeam-channel v0.5.17
Checking rust-stemmers v1.2.0
Checking sketches-ddsketch v0.4.1
Checking tantivy-tokenizer-api v0.7.0
Checking measure_time v0.9.0
Checking aho-corasick v1.1.5
Checking htmlescape v0.3.1
Checking lz4_flex v0.13.1
Checking bon v3.10.1
Checking memmap2 v0.9.11
Checking datasketches v0.2.0
Checking levenshtein_automata v0.2.1
Checking rustc-hash v2.1.3
Checking oneshot v0.1.13
Checking census v0.4.2
Checking dirs v6.0.0
Checking glob v0.3.4
Checking mcp-memory-stub v0.1.0 (C:\Users\reazul.ashraf\workspace\rust\mcp-memory\stub)
Checking tantivy v0.26.1
warning: this can be `std::io::Error::other(_)`
--> stub\src\main.rs:76:72
|
76 | let stream = resp.bytes_stream().map(|res| res.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)));
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.98.0/index.html#io_other_error
= note: `#[warn(clippy::io_other_error)]` on by default
help: use `std::io::Error::other`
|
76 - let stream = resp.bytes_stream().map(|res| res.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)));
76 + let stream = resp.bytes_stream().map(|res| res.map_err(|e| std::io::Error::other(e)));
|
warning: stripping a prefix manually
--> stub\src\main.rs:93:40
|
93 | println!("{}", &trimmed[6..]);
| ^^^^^^^^^^^^^
|
note: the prefix was tested here
--> stub\src\main.rs:91:24
|
91 | } else if trimmed.starts_with("data: ") {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
= help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.98.0/index.html#manual_strip
= note: `#[warn(clippy::manual_strip)]` on by default
help: try using the `strip_prefix` method
|
91 ~ } else if let Some(<stripped>) = trimmed.strip_prefix("data: ") {
92 | if is_message {
93 ~ println!("{}", <stripped>);
94 | is_message = false;
95 | } else if is_endpoint {
96 ~ let ep = <stripped>;
|
warning: redundant pattern matching
--> stub\src\main.rs:117:16
|
117 | if let Err(_) = std::net::TcpStream::connect(cli.target.replace("http://", "").replace("https://", "")) {
| ^^^^^^
|
= help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.98.0/index.html#redundant_pattern_matching
= note: `#[warn(clippy::redundant_pattern_matching)]` on by default
help: consider using `is_err()`
|
117 - if let Err(_) = std::net::TcpStream::connect(cli.target.replace("http://", "").replace("https://", "")) {
117 + if std::net::TcpStream::connect(cli.target.replace("http://", "").replace("https://", "")).is_err() {
|
warning: `mcp-memory-stub` (bin "mcp-memory-stub") generated 3 warnings (run `cargo clippy --fix --bin "mcp-memory-stub" -p mcp-memory-stub -- ` to apply 2 suggestions)
-14
View File
@@ -1,14 +0,0 @@
import re
with open("server/src/handlers.rs", "r", encoding="utf-8") as f:
text = f.read()
pattern = r'"(list_milestones|list_pinned_files|read_handoff_memos)" => \{\s*let req = parse_tool!\(args, id, ([a-zA-Z]+)\);\s*let mut [a-zA-Z_]+ = self\.state\.([a-zA-Z_]+)\.read\(\);\s*if let Some\(ns\) = req\.namespace \{\s*[a-zA-Z_]+\.retain\(\|.\| \w+\.namespace == ns\);\s*\}\s*let data = serde_json::to_string\(&[a-zA-Z_]+\)\.unwrap_or_default\(\);\s*Ok\(data\.to_string\(\)\)\s*\}'
def repl(m):
return f'"{m.group(1)}" => handle_list_with_namespace!(self, {m.group(3)}, {m.group(2)}, args, id),'
new_text = re.sub(pattern, repl, text)
with open("server/src/handlers.rs", "w", encoding="utf-8") as f:
f.write(new_text)
-14
View File
@@ -1,14 +0,0 @@
import re
with open("server/src/handlers.rs", "r", encoding="utf-8") as f:
text = f.read()
pattern = r'"([a-zA-Z_]+)" => \{\s*(?:let req = parse_tool!\(args, id, ([a-zA-Z]+)\);\s*)?let data = serde_json::to_string\(&self\.state\.([a-zA-Z_]+)\.read\(\)\)\s*\.unwrap_or_else\(\|_\| "\[\]"\.to_string\(\)\);\s*Ok\(data\.to_string\(\)\)\s*\}'
def repl(m):
return f'"{m.group(1)}" => {{\n let data = serde_json::to_string(&self.state.{m.group(3)}.read()).unwrap_or_else(|_| "[]".to_string());\n Ok(data)\n}},'
new_text = re.sub(pattern, repl, text)
with open("server/src/handlers.rs", "w", encoding="utf-8") as f:
f.write(new_text)
-37
View File
@@ -1,37 +0,0 @@
try {
Write-Host "Gracefully shutting down server..."
$tokenPath = "C:\Users\reazul.ashraf\.gemini\mcp_memory\admin.token"
$headers = @{}
if (Test-Path $tokenPath) {
$headers["Authorization"] = "Bearer $(Get-Content -Path $tokenPath -Raw)".Trim()
}
Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:3000/shutdown" -Headers $headers -ErrorAction SilentlyContinue | Out-Null
Start-Sleep -Seconds 2
} catch {
Write-Host "Server might already be down or unreachable."
}
$binaries = @('mcp-memory-server.exe', 'mcp-memory-stub.exe', 'mcp-memory-win-nvim.exe')
$timestamp = (Get-Date -UFormat "%s") -replace "\.",""
foreach ($b in $binaries) {
$s = "target\release\$b"
$d = "C:\Users\reazul.ashraf\.local\bin\$b"
if (Test-Path $d) {
$backupName = "$b.$timestamp.bak"
Write-Host "Renaming $b to $backupName to avoid file locks..."
Rename-Item $d $backupName -Force -ErrorAction SilentlyContinue
}
if (Test-Path $s) {
Write-Host "Deploying new $b..."
Copy-Item $s $d -Force
} else {
Write-Host "Warning: $s not found, skipping."
}
}
Write-Host "Starting new mcp-memory-server daemon..."
Start-Process -FilePath "C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe" -WindowStyle Hidden
Write-Host "Deployment complete."
-165
View File
@@ -1,165 +0,0 @@
# MCP Memory Server Architecture & Workflow Design
## 1. Core Architecture
The `mcp-memory` system runs as a **single, continuously running background daemon natively on Windows**. It manages the state of the Antigravity knowledge graph and serves as a universal backend for both Windows and WSL environments.
### Why this design?
* **Cross-OS I/O Optimization:** Prevents the WSL agent from performing slow, heavy filesystem writes against the mounted Windows `C:\` drive.
* **Concurrency & Locking:** A single daemon holds the lock on the `mcp_memory` JSON files, preventing data corruption and eliminating complex delta-reconciliation between parallel WSL and Windows processes.
* **Dual Transport System:** Utilizes both `stdio` and HTTP transports concurrently. The local Windows `agy` instance connects natively via standard `stdio`, while the Axum HTTP server provides synchronous, non-blocking access for WSL clients and external scripts.
## 2. Server Transport & Endpoints (Axum)
The daemon uses the `axum` and `rust-mcp-axum` crates, binding to `0.0.0.0:3000` to serve the network.
### Standard MCP Endpoints
* `GET /ws`: The WebSocket endpoint for efficient, low-latency, full-duplex JSON-RPC communication (preferred for proxies and the UI dashboard).
* `GET /sse`: The Server-Sent Events (SSE) endpoint. Antigravity clients connect here to keep a one-way pipe open for receiving pushed responses.
* `POST /messages`: The JSON-RPC endpoint. Clients use this to send tool calls and resource reads up to the server when connected via SSE.
### Dashboard REST API
The server hosts a rich Single Page Application (SPA) natively on the root route, backed by a suite of REST endpoints:
* `GET /`: The Brain Monitor live HTML dashboard SPA.
* `GET /api/graph`: Returns the full node/edge topology for the interactive physics-simulated canvas.
* `GET /api/tasks` & `POST /api/tasks/{id}/complete`: Drives the actionable Kanban board.
* `GET /api/sticky` & `GET /api/search`: Powers the sticky notes tab and global fuzzy search UI.
* `GET /api/stats`: Returns a JSON snapshot of current entity, relation, task, and tech debt counts.
### Git & IDE Integration Endpoints
* `POST /nvim/telemetry`: A one-way webhook that Neovim instances hit on `FocusGained` or `BufEnter` to globally broadcast the user's active file to the UI and Agent.
* `GET /gate/verify`: A lightweight, deterministic endpoint used by external scripts (like a `git push` wrapper) to verify if an action is authorized based on the current state.
## 3. Client Connections
The Antigravity configurations (`mcp_config.json`) utilize the dual-transport system:
* **Windows `agy`:** Spawns and connects to the local daemon natively via `stdio` subprocess execution.
* **WSL (Linux) `agy`:** Connects to the running Windows HTTP daemon via the host network proxy, e.g., `http://127.0.0.1:3000/sse`.
## 4. Git Integration (Global Wrapper)
Instead of relying on localized per-repository Git hooks (like `.git/hooks/pre-push`), the system leverages a **global bash/PowerShell alias wrapper** for the `git` command. This intercepts `git` commands universally across the OS.
### Workflow Example (Push Safety Gate)
1. The user types `git push`.
2. The global wrapper intercepts the command.
3. It makes a synchronous HTTP request to the local daemon: `curl -s http://localhost:3000/gate/verify`.
4. If the endpoint returns `200 OK` (indicating the `PrePushAuditor` subagent has verified that unit tests pass and history is squashed), the push proceeds.
5. If not `200 OK`, the wrapper blocks the push and alerts the user to fix tests or run `gsquash`.
### Benefits of the Wrapper Approach
* **Universal Enforcement:** The push safety gate is protected across all repositories automatically, without copying hook scripts.
* **No File Locks:** Uses safe, lightweight, parallelizable HTTP requests rather than executing the Rust binary directly.
* **Action Logging:** The wrapper can be seamlessly extended to log actions (like `checkout` or `commit`) directly into the knowledge graph in real-time.
## 5. Storage Architecture & Persistence (Redb LSM-Tree)
The daemon has completely eliminated raw JSON file sprawl and fragmented delta-file reconciliation. It now utilizes a pure-Rust, embedded Key-Value engine (`redb`) that implements a robust Log-Structured Merge-Tree (LSM-tree) architecture.
### Key Principles:
* **Embedded Database Engine:** All structured components (Tasks, Snippets, Tech Debt, Checklists, etc.) are stored as binary-encoded values inside a unified `redb` database file (`store.redb`).
* **ACID Compliance & File Locks:** The Windows daemon holds an exclusive read-write lock on the database file, guaranteeing zero data corruption, race conditions, or lock contention during concurrent access.
* **Asynchronous Checkpointing:** The core Knowledge Graph (Entities, Relations, Observations) still utilizes a Write-Ahead Logging (WAL) pattern (`wal.jsonl`) and a master snapshot (`master.json`) to allow safe, lock-free memory mutations which are reconciled in the background.
## 6. Domain Models & Component Stores
The system leverages a modular, thread-safe generic `Store<T>` abstraction that automatically transparently serializes and deserializes native Rust structs directly into the underlying `redb` tables.
Currently implemented persistent stores include:
* **Audit Ledger & Tasks:** Tracks agent actions and active background tasks.
* **Context & Handoffs:** Sticky notes, Session Summaries, Handoff Memos, and Context Workspaces.
* **Engineering Tracking:** ADRs (Architecture Decision Records), Snippets, Error Fixes, Tech Debt, and PR Checklists.
* **Environment State:** Pinned Files, Env Fingerprints, Milestones, and Environments.
* **Safety Gates:** Authorized execution gates (Push Safety).
## 7. Full-Text Search Engine (Tantivy)
To support blazing-fast, intelligent semantic retrieval across the sprawling knowledge graph, the daemon embeds **Tantivy**, a full-text search engine (inspired by Apache Lucene).
* **The `MemoryIndex`:** Whenever the graph or auxiliary stores mutate, a background thread dynamically rebuilds the Tantivy index (`tantivy_index/` dir).
* **Global Omni-Search:** This architecture powers the `omni_search` tool, allowing subagents to instantly fuzzy-search and rank documents across Entities, Tasks, Snippets, Error Fixes, and ADRs simultaneously in milliseconds, without loading massive JSON arrays into RAM.
## 8. The Gate System (Push Safety Verification)
The binary supports a flexible safety gate authorization system, accessible both via the HTTP API and CLI subcommands.
* **API / CLI set**: Records an authorization status (`authorized`, `blocked`, or `pending`) for a specific target and namespace. Can be invoked via `POST /gate/set` (JSON) or `mcp-memory-stub gate set`.
* **API / CLI verify**: Evaluates a pending action against the gate store. It returns standard HTTP status codes (`200 OK`, `403 Forbidden`, `404 Not Found`) via `GET /gate/verify?action=...` or POSIX exit codes (0, 1, 2) via `mcp-memory-stub gate verify`. Both support a `consume` parameter/flag to immediately revoke the authorization after a successful check.
## 9. Operational Configuration & Paths
The physical storage location of the knowledge graph and all persistent stores is strictly controlled by the MCP_MEMORY_STORE_DIR environment variable.
* **Default Path:** If not set, the daemon defaults to ~/.gemini/mcp_memory.
* **Port Binding:** The Axum HTTP server strictly binds to .0.0.0:3000.
## 10. Exposed MCP Capabilities (Tools)
The server implements the Model Context Protocol (MCP) by exposing a vast suite of tools via the JSON-RPC interface, categorized broadly into:
* **Graph Management:** create_entities, create_relations, merge_entities,
ead_graph, etc.
* **Task & Context Tracking:** add_task, add_sticky_note, add_session_summary, etc.
* **Engineering & DevOps:** log_code_change, log_error_fix, log_tech_debt, add_pr_checklist_item.
* **Environment & Workspaces:**
egister_environment, save_context_workspace, pin_file.
## 11. Concurrency & Thread Safety
With the introduction of the Dual Transport System, the daemon must safely handle simultaneous read/write requests from both Stdio (Windows agy) and HTTP (WSL agy) clients.
* **Shared State:** The entire server operates on a cloned Arc<MemoryState>.
* **Locking Mechanism:** The unified Knowledge Graph and modular Store<T> components are protected by RwLock primitives.
* **Safe Mutation:** Store modifications utilize closure-based modify(|store| { ... }) methods to ensure locks are safely acquired, mutations applied, and file writes executed sequentially without deadlocking the asynchronous Tokio runtimes.
## 12. Lifecycle & Startup Management (Server/Stub Architecture)
The mcp-memory daemon employs a strict Server/Client paradigm to maintain separation of concerns. There are no dual roles or morphing executables.
### The Windows Background Daemon (`mcp-memory-server.exe`)
The server executable is responsible exclusively for running the Axum HTTP and WebSockets daemon and managing the Knowledge Graph. It does not contain any proxy logic. If port 3000 is already in use by an existing server instance, it gracefully exits instead of attempting to run.
### The Client Proxy (`mcp-memory-stub.exe`)
All Antigravity sessions (Windows and WSL) use the lightweight `mcp-memory-stub` as their proxy. The stub connects to the server via WebSockets and acts as the bridge for standard `stdio` JSON-RPC traffic.
* **Startup via Interop/Spawn:** Upon launch, the stub attempts to connect to the Windows host on port 3000. If the server is offline, the stub automatically executes a spawn command (e.g., executing `mcp-memory-server.exe --daemon` natively, or via WSL interop) to silently wake up the Windows Leader before commencing the proxy loop.
* **MPSC Queue Resilience:** The stub utilizes an asynchronous multi-producer, single-consumer (MPSC) channel queue. If the Windows Leader daemon restarts or momentarily drops, the proxy buffers incoming JSON-RPC tool calls and infinitely retries them until the connection is restored. This guarantees **zero message loss** and **zero thread leaks** without crashing the active `agy` session.
* **Zero I/O Penalty (WSL):** This ensures the Linux binary never directly touches the Windows NTFS files, reserving all heavy disk operations for the native Windows host.
## 13. Cargo Workspace & Binary Artifacts
To optimize for different environments, the codebase is structured as a Cargo Workspace containing two distinct crates:
### 1. mcp-memory-server (The "Full-Fat" Daemon)
* **Path:** server/
* **Size/Complexity:** Heavy (contains Axum, MCP SDK, JSON parsing, Tokio runtime).
* **Role:** This is the primary background daemon. It binds to .0.0.0:3000, holds file locks, and manages the graph.
* **Windows Behavior:** It is designed to run in the background as a standalone service.
### 2. mcp-memory-stub (The Ultra-Lightweight Proxy)
* **Path:** stub/
* **Size/Complexity:** Extremely light (only relies on
eqwest and okio).
* **Role:** A dedicated, OS-agnostic proxy binary used strictly for routing stdio JSON-RPC traffic over HTTP to a remote Leader. Windows gy clients point directly to this binary to bypass loading the heavy Server daemon into memory.
* **WSL Behavior:** Compiled as a Linux native binary (x86_64-unknown-linux-musl). When executed by WSL agy, it acts as a transparent proxy to http://127.0.0.1:3000. It can also execute wake_cmd (e.g., WSL interop) to silently wake the Windows host if the Leader is offline.
## 14. Neovim Integration Architecture & "God Mode"
To enable seamless pair-programming inside Neovim, the daemon integrates with Neovim using two complementary systems: a Webhook Telemetry pipeline and dedicated MCP binaries.
### Telemetry Pipeline (Last Focused Wins)
A lightweight Lua script (gemini-integration.lua) is loaded into Neovim, which fires an asynchronous curl POST to http://127.0.0.1:3000/nvim/telemetry whenever the user focuses a buffer or moves the cursor. The server then writes this data (including session_id, ile, line, and col) to both the Windows and WSL ctive_nvim.txt files and broadcasts it over WebSockets.
### Native MCP Binaries (win-nvim & linux-nvim)
The project compiles two standalone, highly-performant binaries that implement the MCP JSON-RPC protocol over Stdio and bridge it directly to Neovim's Msgpack-RPC Named Pipes/Sockets. These binaries avoid hardcoding infinite tools by utilizing a "God Mode" escape hatch.
Exposed Neovim Tools:
* **
vim_get_active_buffer &
vim_get_cursor**: Read file state.
* **
vim_goto_line &
vim_set_diagnostics**: Manipulate IDE state.
* **
vim_get_visual_selection**: Read exact highlight coordinates (handles mode dynamically).
* **
vim_list_buffers**: Discover unsaved work and context.
* **
vim_get_diagnostics**: Read live LSP errors dynamically instead of requiring a compiler.
* **
vim_execute_lua ("God Mode")**: The ultimate fallback tool. Evaluates raw Lua scripts inside the active Neovim instance and returns JSON. This prevents the need to continuously recompile the Rust server whenever a new Neovim capability is required.
## 15. Build & Deployment Strategy
Because the background server operates as an always-on Windows daemon, standard recompilation and file-copying strategies will fail due to active Windows OS file locks.
### Randomized Lock Bypassing
The \uild.ps1\ deployment pipeline intercepts locked \.exe\ files by appending a unique, timestamped/randomized suffix (e.g., \mcp-memory-server.exe.12345.old\) when forcing a \Move-Item\. This guarantees that rapid sequential deployments (where a previous \.old\ file might still be locked by a zombie process) never silently fail or collide.
### Dynamic Versioning
To trace binary provenances during rapid deployment cycles, all binaries embed dynamic versioning directly at compile time (via \uild.rs\ and \uild_template.rs\). The injected \APP_VERSION\ environment variable combines the static Cargo \ ersion\ with the live \git\ short hash and UTC timestamp, allowing the CLI \--version\ commands and the HTTP \/api/version\ endpoints to guarantee exactly which iteration of the code is actively executing.
## 16. Testing Architecture (Native Rust E2E)
Historically, the project relied on a complex Python testing suite (\pytest\ + \mcp_client.py\) to validate the server over HTTP/SSE. This has been fully deprecated in favor of **Native Rust End-to-End Testing**.
* **Unit Tests:** Handlers and business logic are tested directly inside \server/src/handlers.rs\ using native \ okio::test\ constructs.
* **E2E Tests:** Integration and full-system tests run via \stub/tests/e2e.rs\ and \win-nvim/tests/integration_test.rs\, ensuring type safety, faster execution, and eliminating Python environment dependencies.
## 17. Automated Git Context Binding (git2)
Instead of forcing the LLM client to manually run `git rev-parse HEAD` and pass `git_branch` / `git_commit` arguments for every single code change, the server integrates the native **`git2`** C bindings.
When engineering endpoints (`log_code_change`, `log_error_fix`, `log_tech_debt`) are invoked, the server asynchronously discovers the surrounding Git repository, peels the HEAD reference, and automatically injects the current commit hash, commit message, and branch name directly into the stored entities and audit logs. This guarantees airtight VCS traceability without wasting LLM tokens or relying on the agent's memory.
-40
View File
@@ -1,40 +0,0 @@
import re
# 1. Fix stub/src/main.rs
stub_path = r'C:\Users\reazul.ashraf\workspace\rust\mcp-memory\stub\src\main.rs'
with open(stub_path, 'r', encoding='utf-8') as f:
content = f.read()
content = content.replace('use tokio::io::AsyncBufReadExt;\n', '')
with open(stub_path, 'w', encoding='utf-8') as f:
f.write(content)
# 2. Fix nvim-core/src/lib.rs
nvim_path = r'C:\Users\reazul.ashraf\workspace\rust\mcp-memory\nvim-core\src\lib.rs'
with open(nvim_path, 'r', encoding='utf-8') as f:
content = f.read()
content = content.replace(
'use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};',
'use tokio::io::{AsyncReadExt, AsyncWriteExt};'
)
with open(nvim_path, 'w', encoding='utf-8') as f:
f.write(content)
# 3. Fix server/src/store.rs
store_path = r'C:\Users\reazul.ashraf\workspace\rust\mcp-memory\server\src\store.rs'
with open(store_path, 'r', encoding='utf-8') as f:
content = f.read()
inspect_method = """ pub fn inspect<F, R>(&self, f: F) -> R
where
F: FnOnce(&T) -> R,
{
let lock = self.cache.read().unwrap();
f(&lock)
}
"""
content = content.replace(inspect_method, "")
with open(store_path, 'w', encoding='utf-8') as f:
f.write(content)
print("Warnings fixed!")
+114 -16
View File
@@ -1,24 +1,16 @@
-- gemini-integration.lua
-- Registers the active Neovim instance for the Antigravity MCP Server
vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter"}, {
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)
if #vim.api.nvim_list_uis() > 0 then
local server_name = vim.v.servername
if server_name then
-- 1. Legacy Disk Write (Fallback)
local home = os.getenv("HOME") or os.getenv("USERPROFILE")
if home then
os.execute("mkdir -p " .. home .. "/.gemini")
local path = home .. "/.gemini/active_nvim.txt"
local f = io.open(path, "w")
if f then
f:write(server_name)
f:close()
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,18 +18,124 @@ vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter"}, {
'{"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",
"-H", "Content-Type: application/json",
"-d", payload
}, { detach = true })
}, {
stdout_buffered = true,
on_stdout = function(_, data)
if data and #data > 0 then
local raw = table.concat(data, "")
if raw ~= "" then
local ok, resp = pcall(vim.fn.json_decode, raw)
if ok and resp then
vim.schedule(function()
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
end
end,
})
local uv = vim.uv or vim.loop
local client = uv.new_udp()
if client then
client:send(payload, "127.0.0.1", 3002, function(err)
client:close()
end)
end
end
end
end,
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 ui = dofile("C:/Users/reazul.ashraf/workspace/rust/mcp-memory/gemini-ui.lua")
local is_win = vim.fn.has("win32") == 1
local ui_path = is_win and "C:/Users/reazul.ashraf/workspace/rust/mcp-memory/gemini-ui.lua" or "/mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory/gemini-ui.lua"
local ui = dofile(ui_path)
ui.setup()
_G.gemini = ui -- Expose globally so Agent can call it via execute_lua
+312 -25
View File
@@ -55,35 +55,234 @@ function M.close_progress()
end
end
local function execute_agent(prompt)
local cmd = { "agy", "--new-project", "-i", prompt }
M.show_progress("Starting Antigravity...")
-- termopen requires an empty and unmodified buffer
vim.api.nvim_buf_set_lines(progress_buf, 0, -1, false, {})
vim.bo[progress_buf].modified = false
-- termopen will attach to the current buffer (which show_progress just focused)
job_id = vim.fn.termopen(cmd, {
on_exit = function(_, code)
job_id = nil
local function send_udp(payload)
local uv = vim.uv or vim.loop
local client = uv.new_udp()
if client then
local ok, data = pcall(vim.fn.json_encode, payload)
if ok then
client:send(data, "127.0.0.1", 3002, function(err)
client:close()
end)
else
client:close()
end
end
end
function M.prompt_user(prompt_text, options, request_id)
if options and #options > 0 then
vim.ui.select(options, { prompt = prompt_text }, function(choice, idx)
send_udp({
event = "agent_prompt_response",
request_id = request_id,
response = choice,
index = idx,
session_id = vim.v.servername
})
end)
else
vim.ui.input({ prompt = prompt_text }, function(input)
send_udp({
event = "agent_prompt_response",
request_id = request_id,
response = input,
session_id = vim.v.servername
})
end)
end
end
local ns_id = vim.api.nvim_create_namespace("gemini_ghost_text")
local knowledge_ns = vim.api.nvim_create_namespace("gemini_knowledge_projection")
local active_diffs = {}
local active_knowledge = {}
function M.project_knowledge(tech_debts, adrs, bufnr)
bufnr = (bufnr == 0 or bufnr == nil) and vim.api.nvim_get_current_buf() or bufnr
if not vim.api.nvim_buf_is_valid(bufnr) then return end
vim.api.nvim_buf_clear_namespace(bufnr, knowledge_ns, 0, -1)
active_knowledge[bufnr] = {
tech_debts = tech_debts or {},
adrs = adrs or {},
file = vim.api.nvim_buf_get_name(bufnr)
}
local line_count = vim.api.nvim_buf_line_count(bufnr)
-- 1. Project Tech Debts
if tech_debts and #tech_debts > 0 then
for _, debt in ipairs(tech_debts) do
local target_line = 0
if debt.line_range then
local num = tonumber(string.match(debt.line_range, "%d+"))
if num and num > 0 then
target_line = num - 1
end
end
target_line = math.max(0, math.min(target_line, line_count - 1))
local sev = debt.severity or "debt"
local hl = (sev == "critical" or sev == "high") and "DiagnosticError" or "DiagnosticWarn"
vim.api.nvim_buf_set_extmark(bufnr, knowledge_ns, target_line, 0, {
virt_text = {{" ⚡ [" .. sev:upper() .. " DEBT] " .. debt.description, hl}},
virt_text_pos = "eol",
hl_mode = "combine",
})
end
end
-- 2. Project ADRs
if adrs and #adrs > 0 then
for _, adr in ipairs(adrs) do
vim.api.nvim_buf_set_extmark(bufnr, knowledge_ns, 0, 0, {
virt_text = {{" 📜 [" .. adr.id .. "] " .. adr.title, "DiagnosticInfo"}},
virt_text_pos = "eol",
hl_mode = "combine",
})
end
end
end
function M.show_knowledge()
local bufnr = vim.api.nvim_get_current_buf()
local data = active_knowledge[bufnr]
if not data or ((#data.tech_debts == 0) and (#data.adrs == 0)) then
print("No active Tech Debt or ADRs found for this file.")
return
end
local lines = {}
table.insert(lines, "# Antigravity Knowledge Graph Projection")
table.insert(lines, "File: " .. (data.file or vim.api.nvim_buf_get_name(bufnr)))
table.insert(lines, string.rep("=", 50))
table.insert(lines, "")
if #data.tech_debts > 0 then
table.insert(lines, "## Active Tech Debts (" .. #data.tech_debts .. ")")
for _, d in ipairs(data.tech_debts) do
table.insert(lines, string.format("- [%s] (%s): %s", d.id, d.severity or "unspecified", d.description))
if d.line_range then
table.insert(lines, " Lines: " .. d.line_range)
end
if d.ideal_solution and d.ideal_solution ~= "" then
table.insert(lines, " Ideal Solution: " .. d.ideal_solution)
end
end
table.insert(lines, "")
end
if #data.adrs > 0 then
table.insert(lines, "## Architectural Decision Records (ADRs) (" .. #data.adrs .. ")")
for _, a in ipairs(data.adrs) do
table.insert(lines, string.format("- [%s] %s (Status: %s)", a.id, a.title, a.status))
table.insert(lines, " Decision: " .. a.decision)
end
table.insert(lines, "")
end
local kbuf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(kbuf, 0, -1, false, lines)
vim.bo[kbuf].filetype = "markdown"
local width = math.min(math.floor(vim.o.columns * 0.7), 90)
local height = math.min(#lines + 2, math.floor(vim.o.lines * 0.6))
local row = math.floor((vim.o.lines - height) / 2)
local col = math.floor((vim.o.columns - width) / 2)
local kwin = vim.api.nvim_open_win(kbuf, true, {
relative = 'editor',
width = width,
height = height,
row = row,
col = col,
style = 'minimal',
border = 'rounded',
title = ' Antigravity Knowledge Projection ',
title_pos = 'center'
})
vim.cmd('startinsert')
local function close_kwin()
if kwin and vim.api.nvim_win_is_valid(kwin) then
vim.api.nvim_win_close(kwin, true)
end
end
vim.keymap.set('n', 'q', close_kwin, { buffer = kbuf, noremap = true, silent = true })
vim.keymap.set('n', '<Esc>', close_kwin, { buffer = kbuf, noremap = true, silent = true })
end
function M.preview_diff(diff_id, bufnr, start_row, end_row, new_lines)
bufnr = (bufnr == 0 or bufnr == nil) and vim.api.nvim_get_current_buf() or bufnr
local virt_lines = {}
for _, line in ipairs(new_lines) do
table.insert(virt_lines, {{line, "DiffAdd"}})
end
local extmark_id = vim.api.nvim_buf_set_extmark(bufnr, ns_id, start_row, 0, {
virt_lines = virt_lines,
virt_lines_above = true,
})
active_diffs[diff_id] = {
bufnr = bufnr,
start_row = start_row,
end_row = end_row,
new_lines = new_lines,
extmark_id = extmark_id
}
if pcall(require, "notify") then
require("notify")("Ghost text diff added. Use <leader>aa to accept or <leader>ar to reject.", "info", { title = "Antigravity" })
else
print("Ghost text diff added. Use <leader>aa to accept or <leader>ar to reject.")
end
end
function M.accept_diff()
local diff_id, diff = next(active_diffs)
if not diff then
print("No active diffs to accept.")
return
end
vim.api.nvim_buf_set_lines(diff.bufnr, diff.start_row, diff.end_row, false, diff.new_lines)
vim.api.nvim_buf_del_extmark(diff.bufnr, ns_id, diff.extmark_id)
active_diffs[diff_id] = nil
send_udp({ event = "agent_diff_accepted", diff_id = diff_id, session_id = vim.v.servername })
print("Diff accepted.")
end
function M.reject_diff()
local diff_id, diff = next(active_diffs)
if not diff then
print("No active diffs to reject.")
return
end
vim.api.nvim_buf_del_extmark(diff.bufnr, ns_id, diff.extmark_id)
active_diffs[diff_id] = nil
send_udp({ event = "agent_diff_rejected", diff_id = diff_id, session_id = vim.v.servername })
print("Diff rejected.")
end
function M.ask_agent()
local file = vim.api.nvim_buf_get_name(0)
vim.ui.input({ prompt = 'Ask Agent: ' }, function(input)
if not input or input == '' then return end
local final_input = input
if file and file ~= "" then
final_input = input .. string.format(" [Context file: %s]", file)
end
execute_agent(final_input)
local cursor = vim.api.nvim_win_get_cursor(0)
send_udp({
event = "agent_ask",
prompt = input,
file = file,
line = cursor[1],
col = cursor[2],
session_id = vim.v.servername
})
M.show_progress("Sent to Antigravity via UDP...")
vim.defer_fn(M.close_progress, 1500)
end)
end
@@ -97,12 +296,100 @@ function M.setup()
if not input or input == '' then return end
local file = vim.api.nvim_buf_get_name(0)
local context = string.format(" [Context: %s lines %d-%d]", file, csrow, cerow)
local final_input = input .. context
execute_agent(final_input)
send_udp({
event = "agent_ask_selection",
prompt = input,
file = file,
start_row = csrow,
end_row = cerow,
session_id = vim.v.servername
})
M.show_progress("Sent selection to Antigravity via UDP...")
vim.defer_fn(M.close_progress, 1500)
end)
end, { desc = "Ask Antigravity Agent about selection", noremap = true, silent = true })
vim.keymap.set('n', '<leader>aa', M.accept_diff, { desc = "Accept Agent Diff", noremap = true, silent = true })
vim.keymap.set('n', '<leader>ar', M.reject_diff, { desc = "Reject Agent Diff", noremap = true, silent = true })
vim.keymap.set('n', '<leader>ak', M.show_knowledge, { desc = "Show Knowledge (Tech Debt & ADRs)", noremap = true, silent = true })
end
function M.send_to_terminal(command)
local term_buf = nil
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_loaded(buf) and vim.bo[buf].buftype == "terminal" then
term_buf = buf
break
end
end
if not term_buf then
vim.cmd("split | term")
term_buf = vim.api.nvim_get_current_buf()
else
local win_found = false
for _, win in ipairs(vim.api.nvim_list_wins()) do
if vim.api.nvim_win_get_buf(win) == term_buf then
vim.api.nvim_set_current_win(win)
win_found = true
break
end
end
if not win_found then
vim.cmd("split")
vim.api.nvim_win_set_buf(0, term_buf)
end
end
local job_id = vim.b[term_buf].terminal_job_id
if job_id then
vim.api.nvim_chan_send(job_id, command .. "\r\n")
vim.api.nvim_win_set_cursor(0, {vim.api.nvim_buf_line_count(term_buf), 0})
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
+288 -94
View File
@@ -1,117 +1,311 @@
# Memory MCP Strategic Guidelines
This document outlines the STRATEGY and SEMANTICS for using the MCP Memory Server.
You do not need to memorize JSON schemas for these tools; they are strictly defined and typed in the ools/list endpoint. Focus purely on WHEN and WHY to use them.
This document outlines the STRATEGY, SEMANTICS, RESOURCE SCHEMAS, and CASING STANDARDS for using the MCP Memory Server.
You do not need to memorize individual JSON schemas for every tool; they are strictly defined and typed in the `tools/list` endpoint. Focus on WHEN, WHY, and HOW to leverage them effectively.
## 7. Snippet & Command Vault
- **Tools:** `store_snippet`, `search_snippets`, `delete_snippet`
- **When to use:** Use the Vault to store exactly multi-line code snippets, Nushell pipelines, or complex commands that the user relies on frequently.
- **Behavior:** The vault guarantees precise syntactic preservation of the script (unlike Graph observations). Search snippets when trying to recall an exact query or pipeline.
---
## 8. Context Namespaces (Project Scopes)
- **Feature:** `namespace` optional parameter
- **When to use:** When using `read_graph`, `search_nodes`, or `visualize_graph`, you can now pass `namespace` to isolate graph queries to a specific project scope (e.g. "scascanner").
- **Behavior:** When calling `create_entities` or `create_relations`, you can inject `namespace: "your_project"` into the entity schema to isolate it from the global scope.
## 1. Casing & Naming Standards (CRITICAL)
## 9. Architectural Decision Records (ADRs)
- **Tools:** log_decision, query_decisions
- **When to use:** Use this whenever you make a non-trivial architectural, environmental, or tooling decision (e.g. choosing a specific framework, a specific deployment flag, bypassing a rule with a workaround).
- **Behavior:** This permanently stores the context, decision, and consequence of *why* something is done the way it is, preventing future agents from second-guessing or reverting it.
To prevent graph fragmentation and ensure seamless LLM context retrieval:
* **Entity Types (`entity_type`)**: MUST ALWAYS be **`PascalCase`** (e.g. `DatabaseTable`, `McpTool`, `ArchitectureComponent`, `File`, `DataStructure`).
* **Relation Types (`relation_type`)**: MUST ALWAYS be **`snake_case`** (e.g. `depends_on`, `calls`, `implements`, `uses`, `contains`).
* **Field Keys & Properties**: MUST ALWAYS be **`snake_case`** (e.g. `file_path`, `git_commit`, `created_at`).
* **Relation Tool Parameters**: `create_relations` supports Serde field aliases (`source` -> `from`, `target` -> `to`, `relationType`/`type` -> `relation_type`) so LLM tool calls succeed seamlessly regardless of parameter naming.
## 10. Graph Refactoring & Algorithms
- **Tools:** merge_entities, find_orphans, query_graph_path
- **When to use:** Run `find_orphans` periodically or when you notice graph clutter to safely delete unused nodes. Use `merge_entities` when you notice duplicated semantic concepts (e.g. API_Gateway vs APIGateway). Use `query_graph_path` when you need to understand how two completely different architectural components are related (e.g. "How does the Frontend connect to the Database?").
- **Behavior:** `merge_entities` will safely combine their observations and automatically remap all relations pointing to or from the deleted duplicate. `query_graph_path` executes a breadth-first search to find the shortest relational path between nodes.
> [!NOTE]
> The server automatically enforces and migrates incoming entity and relation types to these canonical casing rules on every read and write operation.
> Store operations enforce atomic lock scope for serialization/deserialization and enter Quarantine Mode upon database corruption to prevent data overwrite hazards.
## 11. Dynamic Learned Preferences
- **Tools:** learn_preference,
ead_preferences
- **When to use:** When the user corrects you on a specific local nuance (e.g. "Actually, use Python 3.10 instead of 3.12 for this repo").
- **Behavior:** Use this Key-Value store to record dynamic behavioral preferences to ensure you adapt instantly without modifying global Markdown files.
---
## Error Vault / Troubleshooting
When you spend time resolving a tricky environment issue, build error, or logic bug, immediately record the fix to save future time.
- **log_error_fix:** Provide the signature (the exact error string or stack trace snippet) and the solution.
- **search_error_fixes:** When encountering a weird bug, query this vault first before debugging from scratch.
## 2. The Two-Tier Context Paradigm
## Pinned Workspaces (Hot Files)
To maintain focus on the active "working set" of files in large repositories, use pins.
- **pin_file / unpin_file:** Pin the 3-5 files you are actively modifying to the current
amespace (project).
- **list_pinned_files:** When starting a new session or returning to a project, always list pinned files first to instantly regain context on what was being worked on.
> [!IMPORTANT]
> * **Tier 1 (Static Markdown)**: Repository rules, constraints, architectural patterns, and developer preferences are maintained directly in static git-tracked files (`agent-rules/*.md`, `instructions.md`) and system prompts. This guarantees 0ms turn-0 availability without relying on proactive agent tool retrieval.
> * **Tier 2 (Telemetry & Ephemeral DB)**: High-frequency operational history—the Code Change Ledger (`audit_ledger`), terminal command history, compiler error fixes (`log_error_fix`), active tasks, and preflight context—is handled by the MCP Memory Server and surfaced via the Brain Monitor Web UI and MCP resources.
## Rolling Session Summaries (Project Timeline)
To maintain a chronological narrative of the project's evolution beyond just code diffs.
- **add_session_summary:** At the end of every major coding session, write a 2-sentence summary of what was accomplished and add it to the
amespace.
- **get_project_timeline:** When rejoining a project after a long time, read the timeline to instantly understand the recent architectural history.
---
## Agent Handoffs (The Inbox)
When working in a multi-session or multi-agent environment, agents need to communicate context across time.
- **leave_handoff_memo:** Leave a quick message describing current progress, roadblocks, or the literal next step to take.
- **read_handoff_memos:** ALWAYS check for memos when waking up in a new project namespace.
- **clear_handoff_memos:** Clear the memo after you have successfully read it and absorbed its context.
## 3. Passive MCP Resources (`resources/list`)
## Environment & Blueprint Tracker
Stop wasting tokens rediscovering how to run or configure the project.
- **update_env_fingerprint:** Run this when you set up a new project to snapshot the OS, shell, and key language versions (e.g. python,
ustc).
- **read_env_fingerprint:** Query this to instantly know how the project is run.
- **log_env_requirement:** Log required .env variables (e.g. DATABASE_URL) without logging the secret itself.
The server exposes 9 real-time, read-only MCP resources. Agents should read these resources directly to regain context without incurring tool call latency:
## Milestones (Epics)
Organize granular tasks into high-level phases.
- **add_milestone:** Group a large subset of work into a cohesive phase (e.g., "V1 MVP", "CI/CD Setup").
- **update_milestone:** Mark a milestone as active, blocked, or completed.
- **list_milestones:** Use this to ensure task priorities align with the current active milestone.
| Resource URI | Resource Name | Description & Usage |
|:---|:---|:---|
| `memory://graph/entities` | Graph Entities | All nodes and entities in the knowledge graph (top 100 with pagination guidance). |
| `memory://graph/relations` | Graph Relations | All relationships between graph entities (top 200 with subgraph guidance). |
| `memory://tasks/active` | Active Tasks | Current active tasks with status, priority, and assigned subagents. |
| `memory://decisions/active` | Active ADR Decisions | Architectural decisions currently in `accepted` status. |
| `memory://tech_debt/unresolved` | Unresolved Tech Debt | All open engineering debt items requiring future refactoring. |
| `memory://session/delta` | Session Delta | Code modifications, commits, active tasks, and notes created in the last 2 hours. |
| `memory://terminal/recent` | Terminal History | Recent terminal commands, interpreters (`pwsh`, `bash`, `nu`), working dirs, and exit codes. |
| `memory://activity/recent` | Recent Activity | Real-time IDE and developer activity event stream. |
| `memory://milestones` | Milestones | Project milestones, deliverables, target dates, and progress. |
## Standup Reports
When asked for a progress update or standup report, do not guess or read raw git logs.
- **generate_standup_report:** Generates a structured JSON containing all asks updated, code_changes logged, and session_summaries added within the last N hours. Format this cleanly as a markdown report for the user.
---
## Infrastructure & Environment Registry
Avoid asking the user for URLs or connection details repeatedly.
- **register_environment:** Save connection details for Dev, QA, Staging, or Prod environments.
- **get_environment_details:** Query this to know how to connect to databases, APIs, or VPNs.
## 4. MCP Workflow Prompts (`prompts/list`)
## Pre-Push / PR Quality Checklists
Ensure high code quality and prevent incomplete pull requests.
- **add_pr_checklist_item:** Add recurring repository chores (e.g., "Run cargo fmt", "Update CHANGELOG").
- **get_pr_checklist:** Query and explicitly verify EVERY item on this list before triggering git push or merging PRs.
- **clear_pr_checklist:** Clear if the project lifecycle changes dramatically.
The server registers 5 high-signal workflow prompts to initiate standardized agent routines:
## Tech Debt & Refactor Backlog
Keep the main task board clean by isolating "hacky" workarounds.
- **log_tech_debt:** Record why a shortcut was taken and what the ideal solution should be.
- **resolve_tech_debt:** Mark a debt as paid off once refactored.
- **list_tech_debt:** Query this before starting a refactoring session.
1. **`context_warmup`**: Executed at session startup. Prompts the agent to read `memory://tasks/active` and `memory://session/delta`, inspect the workspace worktree, and assemble immediate working context.
2. **`analyze_tech_debt`**: Prompts the agent to review unresolved technical debt from `memory://tech_debt/unresolved` and generate a prioritized remediation plan.
3. **`summarize_architecture`**: Synthesizes active ADRs from `memory://decisions/active` and graph entities from `memory://graph/entities` into an architectural overview.
4. **`handoff_routine`**: Triggers the `DevOpsSRE` subagent at session end to generate a standup report, audit active tasks, and record a handoff memo for future sessions.
5. **`archive_routine`**: Compresses historical session summaries into a dense milestone retrospective entity and purges pruned entries.
## Context Workspaces
When shifting context rapidly (e.g. from building a feature to fixing a prod bug), save your state.
- **save_context_workspace:** Save your active pinned files and task IDs under a named workspace (e.g., "Feature X").
- **load_context_workspace:** Retrieve a saved workspace to instantly restore your context when returning to that task.
- **list_context_workspaces:** List all saved workspaces in a project.
---
## Omni-Search (Global Vault Search)
When you remember a vague keyword but don't know which specific vault it's stored in.
- **omni_search:** Searches across the Knowledge Graph, Tasks, Snippets, ADRs, Tech Debt, Memos, and Error Fixes simultaneously.
## 5. Consolidated Smart Tools Architecture (11 Primary Tools)
## Project Health Dashboard
When starting a new session, get a numerical aggregate of the project's current state.
- **get_project_health:** Returns a quick digest of active tasks, unread memos, unresolved tech debt, and pending PR checklist items.
The server consolidates granular single-purpose tools into domain-named smart tools.
## Git Context Binding (VCS Sync)
To maintain absolute traceability, we link memory items directly to the exact git commits they occurred on.
- **Fully Automated (git2):** You no longer need to manually execute `git rev-parse HEAD` or pass `git_branch` / `git_commit` arguments.
- When you call tools like **log_code_change**, **log_error_fix**, or **log_tech_debt**, the Rust server uses the native `git2` crate to automatically discover the repository context of the active working directory, extract the current HEAD commit hash, message, and branch, and permanently bind them to the memory item in the background.
* **`tasks`**: Complete task lifecycle management.
- `action: "add"`: Create a new task (requires `title`, optional `description`, `git_branch`, `repo_name`, `priority: "low" | "medium" | "high" | "urgent"`, `assigned_agent`, `verification_command`, `parent_id`, `dependencies`).
- `action: "update"`: Update task status (requires `id`, `status: "pending" | "completed" | "cancelled"`).
- `action: "delete"`: Delete task and child tasks (requires `id`).
- `action: "list"`: List active tasks (optional `git_branch`, `summary_level: "compact" | "detailed" | "full"`, `max_tokens`).
- `action: "set_criteria"`: Set acceptance criteria (requires `id`, `criteria: Vec<String>`).
- `action: "verify"`: Verify criteria met (requires `id`, optional `proof`).
* **`milestones`**: Milestone tracking.
- `action: "add"`: Create milestone (requires `title`, optional `namespace`, `target_date`, `description`, `deliverables: Vec<String>`, `repo_name`).
- `action: "update"`: Update milestone status (requires `id`, `status: "active" | "completed" | "cancelled"`).
- `action: "list"`: List milestones (optional `namespace`).
## 15. Neovim Integration & God Mode
The project contains two MCP binaries (win-nvim and linux-nvim) that bridge JSON-RPC over stdio directly to the active Neovim instance (using ctive_nvim.txt for Last Focused Wins telemetry).
- These binaries expose basic tools (
vim_get_cursor,
vim_get_active_buffer,
vim_list_buffers, etc.).
- **God Mode**: They also expose
vim_execute_lua. This is the ultimate fallback tool. If you need to access *any* Neovim API that does not have a dedicated Rust tool (e.g., getting LSP diagnostics, evaluating a visual selection block based on modes, setting registers), you MUST write a short Lua script and pass it to
vim_execute_lua. Do not attempt to recompile the Rust server to add new basic tools; use the Lua escape hatch dynamically.
* **`handoff_memos`**: Session handoff notes for future agents.
- `action: "leave"`: Leave a memo (requires `content`, optional `vcs_revision`, `repo_name`, `git_branch`, `blockers: Vec<String>`, `action_items: Vec<String>`, `expires_at`).
- `action: "read"`: Read active handoff memos.
- `action: "clear"`: Clear memos.
* **`snippets`**: Reusable code snippet vault.
- `action: "store"`: Store snippet (requires `query` as name, optional `language`, `code`, `description`, `tags`, `origin_file`, `line_range`, `repo_name`).
- `action: "search"`: Search snippet vault (optional `query`, `tags`, `hybrid: true`).
- `action: "delete"`: Delete snippet (requires `id`).
- `action: "tag"`: Attach classification tags (requires `id`, `tags: Vec<String>`).
* **`decisions`**: Architectural Decision Records (ADRs).
- `action: "log"`: Log ADR (requires `title`, optional `status: "accepted" | "proposed" | "deprecated" | "superseded" | "implemented"`, `context`, `decision`, `consequence`, `author`, `affected_components: Vec<String>`, `alternatives_considered: Vec<String>`, `supersedes`, `repo_name`).
- `action: "update"`: Update ADR status and metadata (requires `id`, optional `status`, `git_commit`, `git_branch`).
- `action: "query"`: Query ADRs (optional `query`).
- `action: "delete"`: Delete ADR (requires `id`).
* **`tech_debt`**: Engineering debt backlog.
- `action: "log"`: Log debt item (requires `description`, optional `ideal_solution`, `file_path`, `line_range`, `workaround`, `effort_estimate`, `severity: "low" | "medium" | "high" | "critical"`, `git_commit`, `git_branch`, `symbol_references`, `repo_name`).
- `action: "resolve"`: Resolve debt item (requires `id`).
- `action: "list"`: List debt items (optional `include_resolved`).
* **`environment`**: Infrastructure and requirements tracking.
- `action: "update_fingerprint"`: Update tool versions (requires `tool_versions: Map<String, String>`, optional `repo_name`).
- `action: "read_fingerprint"`: Read tool versions fingerprint.
- `action: "log_requirement"`: Log environment variable requirement (requires `key`, `description`, `is_secret`, optional `default_value`, `validation_regex`, `repo_name`).
- `action: "register"`: Register target environment (requires `name`, `url`, optional `description`, `requires_vpn`, `env_type: "dev" | "staging" | "qa" | "prod"`, `healthcheck_endpoint`, `ssh_host`, `repo_name`).
- `action: "get_details"`: Read full environment details.
* **`clipboard`**: OS Clipboard management.
- `action: "read"`: Read OS clipboard.
- `action: "write"`: Write text/html/files/image to clipboard.
* **`hypotheses`**: Diagnostic hypothesis memory.
- `action: "log"`: Record diagnostic hypothesis (requires `hypothesis`, optional `status: "open" | "verified" | "disproven"`, `evidence: Vec<String>`, `test_command`, `git_branch`, `repo_name`).
- `action: "query"`: Query hypotheses (optional `status`, `git_branch`, `repo_name`).
* **`agent_signals`**: Inter-agent signal bus.
- `action: "broadcast"`: Broadcast signal to other agents (requires `signal_type`, `payload`, optional `target_agent`, `ttl_seconds`).
- `action: "query"`: Query active signals (optional `signal_type`, `include_expired: bool`).
* **`process_logs`**: Process and daemon log management.
- `action: "watch"`: Register or update log file watcher (requires `label`, `file_path`, optional `description`).
- `action: "get"`: Retrieve recent log lines from watched process log (requires `label`, optional `tail_lines: usize`).
- `action: "clear"`: Clear or truncate watched process log file (requires `label`).
---
## 6. Complete Tool Catalog (All 53 Tools)
The server exposes 53 specialized and smart MCP tools organized into 7 functional domains:
### 1. Knowledge Graph Core (18 Tools)
1. `create_entities`: Batch-create entities with `name`, `entity_type`, and `observations`.
2. `create_relations`: Batch-create relationships (`from`, `to`, `relation_type`).
3. `add_observations`: Append observations to existing entities.
4. `delete_entities`: Delete entity nodes and cascading relations.
5. `delete_relations`: Delete specific relation edges between entities.
6. `delete_observations`: Remove specific observations from an entity.
7. `read_graph`: Return full or namespace-filtered knowledge graph.
8. `search_nodes`: Search entity names and observations using Tantivy BM25.
9. `open_nodes`: Inspect full details of specified entity nodes by name.
10. `visualize_graph`: Generate Mermaid markdown or SVG diagram of the graph.
11. `condense_entity`: Summarize entity observations into dense summaries.
12. `merge_entities`: Merge source entity into target entity, re-pointing relations and pruning self-loops.
13. `find_orphans`: Detect entities with zero relationships for pruning.
14. `get_subgraph`: BFS graph traversal expanding $N$ hops from a root node.
15. `sweep_graph_health`: Audit graph for orphans, calculate name similarity, and recommend merges.
16. `resolve_stale_symbols`: Cross-reference graph symbols against the workspace AST to remove deleted code nodes.
17. `summarize_subgraph`: LLM-ready concise synthesis of a localized subgraph.
18. `query_graph_path`: BFS shortest path between two entities in the knowledge graph.
### 2. Task & Milestone Operations (2 Tools)
19. `tasks`: Consolidated task board manager (`add`, `update`, `delete`, `list`, `set_criteria`, `verify`).
20. `milestones`: Milestone lifecycle management (`add`, `update`, `list`).
### 3. Notes, Handoffs & Reporting (4 Tools)
21. `handoff_memos`: Cross-session scratchpad and handoff memos (`leave`, `read`, `clear`).
22. `add_session_summary`: Record session summary notes and highlights.
23. `generate_standup_report`: Synthesize tasks, ledger changes, and session summaries into a standup report.
24. `promote_to_entity`: Promote an ephemeral note or memo into a permanent knowledge graph entity.
### 4. Meta, Audit & Intelligence (15 Tools)
25. `decisions`: Consolidated Architectural Decision Records (ADRs) manager (`log`, `update`, `query`, `delete`).
26. `tech_debt`: Consolidated technical debt backlog manager (`log`, `resolve`, `list`).
27. `log_error_fix`: Record an error resolution with stack trace, root cause, and git commit.
28. `search_error_fixes`: Embedding-based and keyword search over past error resolutions.
29. `log_code_change`: Record a file modification in the VCS-agnostic audit ledger.
30. `query_recent_changes`: Retrieve recent code changes with lookback time filters.
31. `omni_search`: Reciprocal Rank Fusion (RRF) search across all graph entities, snippets, ADRs, debt, and fixes.
32. `get_project_health`: Health dashboard summarizing task completion, debt backlog, and graph consistency.
33. `manage_checkpoint`: Create or restore named memory snapshots for safe rollback.
34. `query_lineage`: Causal lineage tracker linking tasks, ADRs, commits, and error fixes.
35. `get_next_actionable_tasks`: Topologically resolved list of unblocked tasks ready for execution.
36. `hypotheses`: Structured diagnostic hypothesis tracker (`log`, `query`).
37. `get_preflight_context`: Aggregated operational context at session start (tasks, debt, recent changes).
38. `agent_signals`: Inter-agent signal bus (`broadcast`, `query`).
39. `auto_session_checkpoint`: Automatic session boundary checkpointing.
### 5. System, Environment & Telemetry (4 Tools)
40. `environment`: Tool fingerprinting, requirements, and environment registry (`update_fingerprint`, `read_fingerprint`, `log_requirement`, `register`, `get_details`).
41. `snippets`: Reusable code snippet vault with hybrid search (`store`, `search`, `delete`, `tag`).
42. `clipboard`: Pure native Rust OS clipboard interface (`read`, `write`).
43. `process_logs`: Live process and daemon log watcher and tailer (`watch`, `get`, `clear`).
### 6. Git & Worktree Context (2 Tools)
44. `get_active_worktree_context`: Inspect git status, modified files, diff summary, and current branch.
45. `query_git_diffs`: Retrieve detailed git diffs for specific files or commit ranges.
### 7. AST & Code Intelligence (8 Tools)
46. `read_file_skeleton`: Tree-sitter AST structural outline of functions, structs, and methods without implementation bodies.
47. `replace_ast_node`: Precise AST node replacement preserving indentation and comments.
48. `find_symbol_references`: Search for symbol references across snippets and disk source files.
49. `get_callers`: Find call sites and callers of a specified function or method across the codebase.
50. `analyze_impact`: Blast-radius impact analysis of modifying a symbol or file.
51. `read_directory_architecture`: Recursive directory structure analysis capped at depth 10.
52. `semantic_code_search`: Dense vector semantic code search over indexed source code.
53. `manage_subagent_namespace`: Create, isolate, or merge subagent-scoped memory namespaces.
---
## 7. VCS & SVN Agnosticism & Multi-Repo Provenance
To support diverse enterprise repositories (Git, Subversion / SVN, Mercurial / Hg, Monorepos):
* **`vcs_type`**: Designates the VCS engine (`"git"`, `"svn"`, `"hg"`, `"perforce"`, or `"none"`).
* **`vcs_revision`**: Agnostic commit hash or SVN revision identifier (e.g., `"r12458"`, `"3e4f7a9"`).
* **`upstream_url`**: Canonical remote repository URL (e.g. `https://svn.corp/repo/trunk`, `git@bitbucket.org:org/repo.git`).
* **`repo_name`**: Logical project identifier allowing multiple repositories to share or partition memory namespaces cleanly without collision.
* **Audit Ledger (`log_code_change`)**: Enriched with `vcs_type`, `vcs_revision`, `upstream_url`, `author`, `diff_summary`, and extensible `metadata: HashMap<String, String>`.
---
## 8. Terminal & Process Telemetry
The server ingests and tracks active terminal commands and sessions:
* **Active Terminals**: Tracks PIDs, shell interpreters (`pwsh`, `bash`, `nu`, `zsh`), current working directories (`cwd`), command exit codes, and timestamps.
* **Terminal History Endpoint & Resource**: `/terminal/history` and `memory://terminal/recent` expose recent shell commands and output streams to dashboard and LLMs to prevent lost shell context.
* **Zero-Latency UDP Streams**: Terminal and IDE telemetry stream over UDP (`MCP_UDP_PORT1`, `MCP_UDP_PORT2`) with zero disk I/O bottlenecks.
---
## 9. Automated Error Fix Auto-Matcher
- **Tools:** `log_error_fix`, `search_error_fixes` (and alias `suggest_error_fix`)
- **When to use:** When encountering a build error, test failure, or stack trace. Call `search_error_fixes` with either a text `query` or `stack_trace` before attempting a fix from scratch.
- **Behavior:** Computes cosine similarity between error trace embeddings and past resolution logs when `stack_trace` is provided, or keyword filtering when `query` is provided, returning top matched solutions, modified files, and git commits.
---
## 10. Memory State Checkpointing & Rollbacks
- **Tool:** `manage_checkpoint` (action: `"create"` | `"restore"`)
- **When to use:** Before initiating a large refactor, running experimental subagent tasks, or executing destructive batch operations.
- **Behavior:** Saves or restores a point-in-time snapshot of graph entities, active tasks, and tech debt backlogs.
---
## 11. Self-Healing Graph Health Sweeper
- **Tool:** `sweep_graph_health`
- **When to use:** Periodically or before committing major graph changes to audit entity consistency.
- **Behavior:** Detects orphaned nodes (0 relations), computes name similarity using pre-computed lowercase keys to identify near-duplicates (e.g., `APIGateway` vs `ApiGateway`), and provides structured `merge_entities` recommendations or auto-prunes orphans.
---
## 12. Causal Lineage & Provenance Tracker
- **Tool:** `query_lineage`
- **When to use:** When asking *"Why was this component modified?"* or *"What task or ADR led to this code change?"*
- **Behavior:** Searches across tasks, ADRs, audit ledger entries, and error fixes to assemble a unified chronological timeline explaining the provenance behind any file, symbol, or commit.
---
## 13. ADR Lifecycle & Automated Git Post-Commit Reconciliation
- **The Golden ADR Rule**: When code implementing an ADR is committed, you MUST IMMEDIATELY update the ADR status to `implemented`:
```json
{
"action": "update",
"id": "ADR-XXXX",
"status": "implemented",
"git_commit": "<commit_hash>",
"git_branch": "<branch>"
}
```
- **Automated Post-Commit Hook**: The repository provides an automated reconciliation script (`scripts/git-reconcile.py`) installed via `just install-git-hooks`. Upon every `git commit`, the hook scans the commit message for `ADR-XXXX` or task identifiers and reconciles their status in the persistent store.
- **Current Architecture Status**: 100% of defined ADRs (ADR-0080 through ADR-0104) are fully implemented and reconciled in the store.
---
## 14. Native Rust Invariants & Subprocess Prohibition (CRITICAL)
To maintain maximum security, speed, and cross-platform reliability:
* **Zero Subprocess Fallbacks**: System and server tools (`clipboard`, `ast`, `search`, `db`) MUST strictly use pure native Rust crates (`arboard`, `tree-sitter`, `tantivy`, `psycopg`). Invocations of external shell commands (`powershell.exe`, `wl-paste`, `xclip`, `cmd.exe`) are strictly prohibited in native handlers.
* **Transient Lock Recovery**: Transient OS handle collisions (such as Win32 OLE `OpenClipboard` lock contention) must be handled using native retry loops with backoffs directly in Rust.
* **Automated Static Regression Gates**: Automated AST/source audit tests (`test_no_subprocess_clipboard_regression`) verify at test time that forbidden subprocess patterns are absent from handler implementations.
---
## 15. High-Performance Concurrency & Resilience Guarantees
* **Explicit Fail-Fast Persistence Safety**: Replaced silent fallback to temporary databases (`/tmp/mcp_store_fallback_*`) with an explicit open retry and fail-fast panic unless `MCP_ALLOW_TMP_FALLBACK=1` is explicitly set, preventing silent data loss.
* **Async Mutex Deadlock Elimination**: Converted shared state and Neovim connection locks (`shutdown_tx`, `NVIM_CONN`, `ACTIVE_SOCKET`, `HEADLESS_PROC`) to `tokio::sync::Mutex` to prevent worker thread pool starvation across `.await` points.
* **Telemetry Session Deduplication & Channel Pruning**: Added `LAST_SESSION` in-memory state deduplication for UDP telemetry writes (eliminating disk I/O thrashing) and distinguished WebSocket `TrySendError::Full` backpressure vs `TrySendError::Closed` client pruning.
* **Graph Adjacency Indexing**: Leverages `KnowledgeGraph::build_adjacency_map` to build $O(1)$ lookup adjacency lists for fast BFS shortest path graph queries.
* **Atomic Store Write Lock Minimization**: `Store::modify` and `Store::modify_async` release write guards immediately after in-memory state mutations, serializing JSON payloads under read locks to allow non-blocking concurrent readers.
* **Async Commit Index Reader Auto-Reload**: `MemoryIndex::commit()` automatically triggers `reader.reload()` upon completing background commits, guaranteeing immediate visibility of newly indexed document terms.
* **Zero-Allocation NDJSON Reader**: `mcp-stdio` reclaims line buffers via `std::mem::take` and in-place trimming to eliminate heap allocations during high-frequency NDJSON message parsing.
* **Dynamic Character Micro-Batched Fastembed Inference**: `generate_embeddings_async` dynamically batches text arrays using a 16,000 character budget ceiling inside `spawn_blocking`, preventing heap spikes during vector indexing while keeping ONNX SIMD execution saturated.
* **Bounded Telemetry Detail Records**: Activity and terminal telemetry buffers enforce a 4,000 character truncation ceiling on log details (`ActivityRecord`, `TerminalHistory`) to prevent unbounded RAM growth under high RPC throughput.
* **Zero-Allocation Stream Formatting**: Graph condensation loops (`condense_graph_worker`) format node/relation subgraphs into stream buffers via `std::fmt::Write`, eliminating intermediate String allocation overhead.
* **Single-Handle OS Clipboard Retries & Image Downsampling**: `WriteClipboardHandler` initializes OS clipboard handles once per operation and downsamples images exceeding $2048 \times 2048$ resolution before writing raw RGBA bytes.
* **Zero Transaction Drop Persistence Guarantee**: `Store::modify` automatically spawns an async task to execute `push_async` with channel backpressure if `push` encounters queue saturation, ensuring zero data loss under spike write loads.
* **Token-Budgeted Query Projections**: Decision queries (`query_decisions`) support `limit` caps and compact `include_body: false` projections for token budget optimization.
* **Serde Parameter & Enum Ergonomics**: Action enums (`SnippetSearchMode`, `Relation`) support case-insensitive variants and common synonyms (`create`/`add`, `remove`/`delete`, `list`/`read`, `source`/`from`, `target`/`to`, `relationType`/`relation_type`) ensuring seamless LLM tool execution.
* **Atomic Search Index Swaps**: `MemoryState::rebuild_index` constructs and populates a new `MemoryIndex` instance in isolation before performing an atomic pointer swap (`*self.search_index.write().await = new_idx`), eliminating transient empty search result windows.
* **Non-Blocking Tantivy Search Queries**: `MemoryIndex::search` queries current index searcher snapshots without executing synchronous disk commit locks, preventing query stalls during heavy background indexing.
* **Zero-Allocation HashSet<&str> Snippet Deduplication**: `indexer.rs` utilizes borrowed `HashSet<&str>` name lookups during snippet batch modifications, eliminating heap string re-allocations inside the store write lock.
* **AST Recursion Depth Safeguard & Zero-Copy Borrowing**: Tree-sitter AST traversal caps recursion depth at 100 to prevent thread stack overflows and borrows string slices (`&str`) during AST node walking.
* **Strongly-Typed SearchResult & Pre-Allocated Search Vectors**: `search.rs` uses a strongly-typed `SearchResult` struct with named fields and pre-allocates result vector capacity (`Vec::with_capacity(top_docs.len())`).
* **BFS Graph Traversal Pre-allocation & Visited Node Upper Bound**: `GraphQueryBuilder::find_shortest_path` pre-allocates adjacency map capacity (`HashMap::with_capacity(relations.len() * 2)`) and enforces a visited node upper bound (10,000 max) to guarantee deterministic BFS runtime.
* **Filesystem Event Debouncing & Proactive State Refresh**: `spawn_watcher` implements a sliding 250ms debouncing window per file path, ignores `.git`, `target`, `.gemini`, and `node_modules`, and broadcasts activity events to `MemoryState`.
* **Buffered Line-by-Line AST Workspace Symbol Scanning**: `scan_workspace_for_symbol` reads workspace files via `BufReader` line streams instead of loading entire files into heap strings, preventing memory spikes when traversing source trees.
* **AST Node Type Aliasing & Skeleton Preallocation**: `replace_ast_node` documents friendly node aliases (`function`, `fn`, `method`, `struct`, `class`, `enum`, `trait`, `type`), and `read_file_skeleton` preallocates string buffer capacity (`code.len() / 2`).
* **Batch Vector Indexing & Similarity Score Guidance**: `VectorDB` provides `index_documents_batch` for single-request multi-point vector upserts and explicit score calibration notes ($\ge 0.75$ high confidence match).
* **Compact JSON MCP Resources & UTF-8 Activity Truncation**: MCP resources serialize using compact JSON (`to_string`), `TerminalHistoryResource` / `MilestonesResource` enforce output bounds, and `format_tool_activity_description` uses `floor_char_boundary` for guaranteed UTF-8 safety.
* **SIMD-Friendly Single-Pass Cosine Similarity**: `cosine_similarity` calculates dot product and Euclidean norm squares in a single linear pass over float vectors, enabling SIMD compiler auto-vectorization.
* **Safe Stream Decoding on Log Tails**: Log tail operations (`process_logs`, action: "get") read raw bytes and decode using lossy UTF-8 conversion (`String::from_utf8_lossy`) to ensure resilience when seeking across multi-byte UTF-8 boundaries.
* **Task Summary UTF-8 Truncation Safety**: `tasks` tool (`action = "list"`) truncates serialized task text strictly along UTF-8 character boundaries using `floor_char_boundary` when enforcing `max_tokens`.
* **Directory Tree Depth Safeguard**: `ReadDirectoryArchitectureHandler` caps directory recursion at depth 10 to prevent stack overflow on deep or cyclic directory structures.
* **Deterministic Total-Order Score Ranking**: `OmniSearchHandler` uses `f64::total_cmp` for Reciprocal Rank Fusion (RRF) score sorting, guaranteeing deterministic NaN-safe search result ordering.
* **RPC Timeout Memory Hygiene**: `nvim-core` maintains request hygiene by removing pending request entries from static RPC maps upon timeout or channel drop, eliminating orphan memory leaks.
* **Path Traversal Security Guards**: `validate_safe_path` enforces path canonicalization and rejects relative parent traversal components (`..`) across file and process log handlers (`process_logs` / `ProcessLogsTool`).
* **Watcher Map Memory Eviction**: Proactive daemon file watcher in `watcher.rs` caps `last_processed` map size at 1,000 entries and purges entries older than 10 minutes to prevent monotonic memory leakage.
* **Comprehensive Serde Casing Aliases**: All 11 consolidated tool action enums (TaskAction, MilestoneAction, SnippetAction, DecisionAction, TechDebtAction, EnvAction, ClipboardAction, HandoffMemoAction, HypothesisAction, AgentSignalAction, ProcessLogAction) include serde alias attributes supporting `snake_case`, `camelCase`, `PascalCase`, and uppercase variants for maximum LLM casing resilience.
* **Two-Phase Graph Condensation**: `condense_graph_worker` uses a 2-phase commit (non-destructive `read_with` -> graph insert -> prune by timestamp/content) to prevent data loss if summarization or graph insertion fails.
* **Redb Database Lock Retry Backoff**: `init_db` retries transient Redb lock contention with exponential backoff (3 attempts, 150ms delay) before falling back.
* **Offloaded Background Index Rebuilds**: `MemoryState::rebuild_index` offloads graph snapshot cloning and Tantivy document re-indexing into `tokio::task::spawn_blocking` to avoid stalling async event loops.
* **Broadcast Watch-Based Shutdown Channels**: Background workers utilize `tokio::sync::watch` for broadcast shutdown notifications without consuming cancellation signals.
+189 -20
View File
@@ -1,29 +1,198 @@
set shell := ["pwsh", "-NoProfile", "-Command"]
set shell := ["pwsh.exe", "-NoProfile", "-c"]
# Display available commands
default:
@just --list
# Build and deploy everything across Windows and WSL
all: build-all deploy-all
# Build all binaries across Windows and WSL
build-all: build-win build-wsl
@Write-Host "Build complete across both OS boundaries." -ForegroundColor Green
# Deploy all binaries and agent rules across Windows and WSL
deploy-all: deploy-win deploy-wsl
Write-Host "Deployment complete across both OS boundaries." -ForegroundColor Green
@Write-Host "Deployment complete across both OS boundaries." -ForegroundColor Green
# Gracefully shut down the running server
shutdown-server:
Write-Host "Shutting down MCP server gracefully..." -ForegroundColor Cyan
-Invoke-RestMethod -Uri "http://127.0.0.1:3000/shutdown" -Method Post -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
if (Get-Process mcp-memory-server -ErrorAction SilentlyContinue) { Stop-Process -Name mcp-memory-server -Force -ErrorAction SilentlyContinue }
# Build Windows-native binaries
build-win: build-ui build-server build-stub-win build-nvim-win
# Build and deploy Windows-native binaries
deploy-win: shutdown-server
Write-Host "Building Windows binaries..." -ForegroundColor Cyan
cargo build --release -p mcp-memory-server -p mcp-memory-stub -p mcp-memory-win-nvim
Write-Host "Deploying Windows binaries..." -ForegroundColor Cyan
Copy-Item -Force target\release\mcp-memory-stub.exe "C:\Users\reazul.ashraf\.local\bin\"; Copy-Item -Force target\release\mcp-memory-win-nvim.exe "C:\Users\reazul.ashraf\.local\bin\"
Copy-Item -Force target\release\mcp-memory-server.exe "C:\Users\reazul.ashraf\.local\bin\"
# Deploy Windows-native binaries and rules
deploy-win: deploy-server deploy-stub-win deploy-nvim-win deploy-rules-win
# Build and deploy WSL-native binaries
deploy-wsl:
Write-Host "Building and deploying WSL binaries natively..." -ForegroundColor Cyan
wsl.exe -d Ubuntu -e bash -c 'export PATH="$PATH:/home/riz/.cargo/bin" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && cargo build --release -p mcp-memory-stub -p mcp-memory-linux-nvim && cp target/release/mcp-memory-stub /home/riz/.local/bin/ && cp target/release/mcp-memory-linux-nvim /home/riz/.local/bin/'
# =========================================================
# 1. LIFECYCLE (Start / Stop / Restart / Verify / Version)
# =========================================================
# Run configuration tests to ensure eagerTools parity
# 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 }; $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
start port="3000":
@if (Get-NetTCPConnection -LocalPort {{port}} -State Listen -ErrorAction SilentlyContinue) { Write-Host '⚠️ Server is already running on port {{port}}. Skipping start.' -ForegroundColor Yellow; exit 0 }; Write-Host 'Starting MCP server in background on port {{port}}...' -ForegroundColor Cyan; $env:MCP_PORT='{{port}}'; $env:MCP_UDP_PORT1=$([int]{{port}} + 1); $env:MCP_UDP_PORT2=$([int]{{port}} + 2); .\start_server.ps1 -Port {{port}}; Write-Host 'Waiting up to 40 seconds for /health endpoint...' -ForegroundColor Cyan; for ($i = 1; $i -le 40; $i++) { try { $h = Invoke-RestMethod -Uri 'http://127.0.0.1:{{port}}/health' -ErrorAction Stop; Write-Host "✅ Server is UP! Running Version: $($h.version.app_version) [$($h.version.target_os)/$($h.version.target_arch)]" -ForegroundColor Green; exit 0 } catch { Start-Sleep -Seconds 1 } }; Write-Error '❌ Server failed to start on port {{port}} within 40 seconds.'; exit 1
# Verify deployment health and UI payload response
verify port="3000":
.\verify_deployment.ps1 -Port {{port}}
# Display active running server version and deployed binary CLI version
version port="3000":
@Write-Host "--- Running Server API Version ---" -ForegroundColor Cyan
-& { try { $h = Invoke-RestMethod -Uri "http://127.0.0.1:{{port}}/health" -ErrorAction Stop; Write-Host "HTTP /health: $($h.version.app_version) [$($h.version.target_os)/$($h.version.target_arch)]" -ForegroundColor Green } catch { Write-Host "HTTP /health: Server not reachable on port {{port}}" -ForegroundColor Yellow } }
@Write-Host "--- Deployed Binary CLI Version ---" -ForegroundColor Cyan
-mcp-memory-server --version
# Gracefully stop, start, and verify the background server
restart port="3000": (stop port) (start port) (verify port)
# Run server directly in foreground for debugging
run-server:
cargo run -p mcp-memory-server
# =========================================================
# 2. BUILD (Compile Binaries)
# =========================================================
# Transpile Dashboard TypeScript (server/src/dashboard.ts) into JavaScript
build-ui:
@Write-Host "Building Dashboard JavaScript from server/src/dashboard.ts..." -ForegroundColor Cyan
bun build server/src/dashboard.ts --outfile=server/src/dashboard.js --target=browser
# Type-check Dashboard TypeScript source code
check-ui:
@Write-Host "Type-checking Dashboard TypeScript..." -ForegroundColor Cyan
deno check server/src/dashboard.ts
# Build Windows-native server binary in release mode (ensuring fresh UI build)
build-server: build-ui
@Write-Host "Building Windows Server..." -ForegroundColor Cyan
cargo build --release -p mcp-memory-server
# Build Windows-native stub binary in release mode
build-stub-win:
@Write-Host "Building Windows Stub..." -ForegroundColor Cyan
cargo build --release -p mcp-memory-stub
# Build Windows-native Neovim plugin binary
build-nvim-win:
@Write-Host "Building Windows Nvim..." -ForegroundColor Cyan
cargo build --release -p nvim-core --bin mcp-memory-nvim
# Build all WSL-native binaries
build-wsl: build-stub build-nvim
# Build WSL-native stub binary in release mode
build-stub:
@Write-Host "Building 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 && cargo build --release -p mcp-memory-stub'
# Build WSL-native Neovim plugin binary in release mode
build-nvim:
@Write-Host "Building 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 && cargo build --release -p nvim-core --bin mcp-memory-nvim'
# =========================================================
# 3. DEPLOY (Move Binaries and Rules to Global Paths)
# =========================================================
# Stage updated server binary, perform graceful shutdown, and auto-restart (skips if binary is unchanged)
deploy-server:
@Write-Host "Deploying Windows Server (Staged Hot-Swap)..." -ForegroundColor Cyan
-& { $src = "target\release\mcp-memory-server.exe"; $targets = @("$env:USERPROFILE\.local\bin\mcp-memory-server.exe", "$env:USERPROFILE\.cargo\bin\mcp-memory-server.exe", "$env:USERPROFILE\.gemini\antigravity-cli\mcp\mcp-memory\mcp-memory.exe", "$env:USERPROFILE\scoop\apps\rustup\current\.cargo\bin\mcp-memory-server.exe"); $srcHash = if (Test-Path $src) { (Get-FileHash $src -Algorithm SHA256).Hash } else { $null }; $updated = $false; foreach ($target in $targets) { $parent = Split-Path $target; if (Test-Path $parent) { if ((Test-Path $target) -and $srcHash -and ((Get-FileHash $target -Algorithm SHA256).Hash -eq $srcHash)) { Write-Host "Skipping copy to $target (identical checksum)." -ForegroundColor Yellow } else { if (Test-Path $target) { $bak = "$target.old.$([guid]::NewGuid().ToString().Substring(0,8))"; Move-Item -Force $target $bak -ErrorAction SilentlyContinue }; Copy-Item -Force $src $target; Get-ChildItem "$parent\mcp-memory-server.exe.old.*" -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue; Write-Host "Copied updated binary to $target" -ForegroundColor Green; $updated = $true } } }; if ($updated -or -not (Get-NetTCPConnection -LocalPort 3000 -State Listen -ErrorAction SilentlyContinue)) { just stop; just start } else { Write-Host "Server binary unchanged and server is running. Skipping restart." -ForegroundColor Green } }
# Deploy Windows-native stub binary (skips if identical checksum)
deploy-stub-win:
@Write-Host "Deploying Windows Stub..." -ForegroundColor Cyan
-& { $src = "target\release\mcp-memory-stub.exe"; $dest = "C:\Users\reazul.ashraf\.local\bin\mcp-memory-stub.exe"; if ((Test-Path $src) -and (Test-Path $dest) -and ((Get-FileHash $src -Algorithm SHA256).Hash -eq (Get-FileHash $dest -Algorithm SHA256).Hash)) { Write-Host "Skipping $dest (identical checksum)." -ForegroundColor Yellow } else { Stop-Process -Name mcp-memory-stub -Force -ErrorAction SilentlyContinue; if (Test-Path $dest) { Move-Item -Force $dest "$dest.old" -ErrorAction SilentlyContinue; Remove-Item -Force "$dest.old" -ErrorAction SilentlyContinue }; Copy-Item -Force $src $dest; Write-Host "Copied updated $src to $dest" -ForegroundColor Green } }
# Deploy Windows-native Neovim plugin binary (skips if identical checksum)
deploy-nvim-win:
@Write-Host "Deploying Windows Nvim..." -ForegroundColor Cyan
-& { $src = "target\release\mcp-memory-nvim.exe"; $dest = "C:\Users\reazul.ashraf\.local\bin\mcp-memory-nvim.exe"; if ((Test-Path $src) -and (Test-Path $dest) -and ((Get-FileHash $src -Algorithm SHA256).Hash -eq (Get-FileHash $dest -Algorithm SHA256).Hash)) { Write-Host "Skipping $dest (identical checksum)." -ForegroundColor Yellow } else { Stop-Process -Name mcp-memory-nvim -Force -ErrorAction SilentlyContinue; if (Test-Path $dest) { Move-Item -Force $dest "$dest.old" -ErrorAction SilentlyContinue; Remove-Item -Force "$dest.old" -ErrorAction SilentlyContinue }; Copy-Item -Force $src $dest; Write-Host "Copied updated $src to $dest" -ForegroundColor Green } }
# Deploy global Agent rules to Windows config directory (skips identical files)
deploy-rules-win:
@Write-Host "Deploying global Agent rules (Windows)..." -ForegroundColor Cyan
-& { $destDir = "C:\Users\reazul.ashraf\.gemini\config\rules"; if (!(Test-Path $destDir)) { New-Item -ItemType Directory -Force -Path $destDir | Out-Null }; $srcBase = (Get-Item "agent-rules").FullName; $updatedCount = 0; $skippedCount = 0; Get-ChildItem -Recurse agent-rules -File | ForEach-Object { $rel = $_.FullName.Substring($srcBase.Length + 1); $target = Join-Path $destDir $rel; $targetDir = Split-Path $target; if (!(Test-Path $targetDir)) { New-Item -ItemType Directory -Force -Path $targetDir | Out-Null }; if ((Test-Path $target) -and ((Get-FileHash $_.FullName -Algorithm SHA256).Hash -eq (Get-FileHash $target -Algorithm SHA256).Hash)) { $skippedCount++ } else { Copy-Item -Force $_.FullName $target; Write-Host "Updated rule: $rel" -ForegroundColor Green; $updatedCount++ } }; Write-Host "Agent rules deployment complete ($updatedCount updated, $skippedCount skipped identical)." -ForegroundColor Cyan }
# Deploy all WSL-native binaries and agent rules
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 --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 --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:
@Write-Host "Deploying global Agent rules (WSL)..." -ForegroundColor Cyan
wsl.exe -d Ubuntu -e bash -c 'mkdir -p /home/riz/.gemini/config/rules && src_dir="/mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory/agent-rules" && dest_dir="/home/riz/.gemini/config/rules" && updated=0 && skipped=0 && for src in $(find "$src_dir" -type f); do rel="${src#$src_dir/}"; dest="$dest_dir/$rel"; mkdir -p "$(dirname "$dest")"; if [ -f "$dest" ] && cmp -s "$src" "$dest"; then ((skipped++)); else cp "$src" "$dest" && echo -e "\033[32mUpdated rule: $rel\033[0m" && ((updated++)); fi; done && echo -e "\033[36mAgent rules WSL deployment complete ($updated updated, $skipped skipped identical).\033[0m"'
# =========================================================
# 4. TESTING & COVERAGE
# =========================================================
# Run fast parallel unit tests via cargo-nextest (and type-check UI)
test: check-ui
@Write-Host "Running fast parallel tests via cargo-nextest..." -ForegroundColor Cyan
cargo nextest run --workspace
# Run dashboard UI endpoint and integrity unit tests
test-ui:
@Write-Host "Running dashboard UI endpoint and integrity unit tests..." -ForegroundColor Cyan
cargo test -p mcp-memory-server --lib api::setup::tests::test_dashboard_endpoint_and_html_integrity -- --nocapture
# Run standard sequential cargo unit tests
test-cargo:
@Write-Host "Running standard unit tests across workspace..." -ForegroundColor Cyan
cargo test --workspace
# Run configuration parity tests for eagerTools
test-config:
cargo test --release -p mcp-memory-server --test parity_test
# Run workspace unit tests and generate code coverage report
test-coverage:
@Write-Host "Running tests with code coverage..." -ForegroundColor Cyan
cargo llvm-cov --workspace --ignore-filename-regex "stub" -- --test-threads=1
# Auto-generate and verify unit tests using local Ollama model
improve-tests file="server/src/ollama.rs":
improve-test {{file}}
# =========================================================
# 5. EXPLICIT LIFECYCLE CHAINING
# =========================================================
# Complete lifecycle: stop, build, deploy, start, and verify Windows server
all-server-win port="3000": (stop port) build-server deploy-server (start port) (verify port)
# Complete lifecycle: build and deploy Windows stub
all-stub-win: build-stub-win deploy-stub-win
# Complete lifecycle: build and deploy Windows Neovim plugin
all-nvim-win: build-nvim-win deploy-nvim-win
# Complete lifecycle: build and deploy WSL stub
all-stub-wsl: build-stub deploy-stub
# Complete lifecycle: build and deploy WSL Neovim plugin
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 hooks (post-commit reconciliation and pre-push gatekeeper)
install-git-hooks:
@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 }
-19
View File
@@ -1,19 +0,0 @@
[package]
name = "mcp-memory-linux-nvim"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
rmp-serde = "1.1"
rmpv = "1.0"
tokio = { version = "1.37", features = ["full"] }
schemars = "0.8"
tracing-appender = "0.2.5"
tracing = "0.1.44"
tracing-subscriber = "0.3.23"
dirs = "7.0.0"
rustls = "0.22.4"
rustls-pki-types = "1"
nvim-core = { path = "../nvim-core" }
-20
View File
@@ -1,20 +0,0 @@
use std::process::Command;
fn main() {
let git_hash = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()
.and_then(|out| String::from_utf8(out.stdout).ok())
.unwrap_or_else(|| "unknown".to_string());
let git_date = Command::new("git")
.args(["log", "-1", "--format=%cd", "--date=format:%y.%m.%d.%H%M%S"])
.output()
.ok()
.and_then(|out| String::from_utf8(out.stdout).ok())
.unwrap_or_else(|| "unknown".to_string());
let version = format!("{} ({})", git_date.trim(), git_hash.trim());
println!("cargo:rustc-env=APP_VERSION={version}");
}
-12
View File
@@ -1,12 +0,0 @@
#[cfg(unix)]
fn main() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
nvim_core::run_mcp_loop("mcp-memory-linux-nvim", env!("APP_VERSION")).await;
});
}
#[cfg(not(unix))]
fn main() {
println!("mcp-memory-linux-nvim is only supported on Unix platforms.");
}
-131
View File
@@ -1,131 +0,0 @@
#![cfg(unix)]
use serde_json::{json, Value};
use std::io::{BufRead, BufReader, Read, Write};
use std::process::{Command, Stdio};
fn send_message(stdin: &mut std::process::ChildStdin, msg: Value) {
let s = serde_json::to_string(&msg).unwrap();
let payload = format!("Content-Length: {}\r\n\r\n{}", s.len(), s);
stdin.write_all(payload.as_bytes()).unwrap();
stdin.flush().unwrap();
}
fn read_message(stdout: &mut std::process::ChildStdout) -> Option<Value> {
let mut reader = BufReader::new(stdout);
let mut length = 0;
// Read headers
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
return None; // EOF
}
let line = line.trim_end();
if line.is_empty() {
break;
}
if let Some(len_str) = line.strip_prefix("Content-Length: ") {
length = len_str.parse().unwrap_or(0);
}
}
if length == 0 {
return None;
}
// Read body
let mut buf = vec![0u8; length];
reader.read_exact(&mut buf).unwrap();
let body_str = String::from_utf8_lossy(&buf);
Some(serde_json::from_str(&body_str).unwrap())
}
#[test]
#[cfg(unix)]
fn test_mcp_initialization_and_tools_list() {
let mut nvim_exe = std::env::current_exe().unwrap();
nvim_exe.pop();
nvim_exe.pop();
nvim_exe.push(format!(
"mcp-memory-linux-nvim{}",
std::env::consts::EXE_SUFFIX
));
let mut child = Command::new(&nvim_exe)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("Failed to start mcp-memory-linux-nvim");
let mut stdin = child.stdin.take().expect("Failed to open stdin");
let mut stdout = child.stdout.take().expect("Failed to open stdout");
// 0. Test server/discover (probe)
let discover_req = json!({
"jsonrpc": "2.0",
"method": "server/discover",
"params": {},
"id": 0
});
send_message(&mut stdin, discover_req);
let discover_resp = read_message(&mut stdout).expect("Failed to read server/discover response");
assert_eq!(discover_resp["error"]["code"], -32601);
// 1. Test Initialize
let init_req = json!({
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "test-client",
"version": "1.0"
}
},
"id": 1
});
// Send initialize using JSONL format!
let s = serde_json::to_string(&init_req).unwrap();
stdin.write_all(format!("{}\n", s).as_bytes()).unwrap();
stdin.flush().unwrap();
let init_resp = read_message(&mut stdout).expect("Failed to read initialize response");
assert_eq!(init_resp["jsonrpc"], "2.0");
assert_eq!(init_resp["id"], 1);
// Verify capabilities
let capabilities = &init_resp["result"]["capabilities"];
assert_eq!(capabilities["tools"], serde_json::json!({}));
// 2. Test tools/list
let tools_req = json!({
"jsonrpc": "2.0",
"method": "tools/list",
"params": {},
"id": 2
});
send_message(&mut stdin, tools_req);
let tools_resp = read_message(&mut stdout).expect("Failed to read tools/list response");
assert_eq!(tools_resp["jsonrpc"], "2.0");
assert_eq!(tools_resp["id"], 2);
let tools = tools_resp["result"]["tools"]
.as_array()
.expect("result.tools must be an array");
assert!(!tools.is_empty(), "Server must expose at least one tool");
let has_get_active_buffer = tools.iter().any(|t| t["name"] == "nvim_get_active_buffer");
assert!(has_get_active_buffer, "Missing nvim_get_active_buffer tool");
child.kill().expect("Failed to kill child");
child.wait().expect("Failed to wait on child");
}
+5 -3
View File
@@ -1,8 +1,10 @@
[package]
name = "mcp-stdio"
version = "0.1.0"
version = "1.0.0"
edition = "2024"
[dependencies]
tokio = { version = "1.53.1", features = ["io-util"] }
tracing = "0.1.44"
tokio = { version = "1.53.1", features = ["io-util", "macros"] }
[dev-dependencies]
tokio = { version = "1.53.1", features = ["rt", "macros"] }
+88 -9
View File
@@ -13,29 +13,108 @@ pub async fn read_mcp_message<R: tokio::io::AsyncRead + Unpin>(
return None;
}
if line.starts_with('{') {
return Some(line.trim_end().to_string());
let trimmed = line.trim();
if trimmed.starts_with('{') || trimmed.starts_with('[') {
return Some(trimmed.to_string());
}
let line = line.trim_end();
if line.is_empty() {
let trimmed_line = line.trim_end();
if trimmed_line.is_empty() {
break;
}
if line.len() >= 15 && line[..15].eq_ignore_ascii_case("content-length:") {
length = line[15..].trim().parse().unwrap_or(0);
if trimmed_line.len() >= 15
&& trimmed_line.as_bytes()[..15].eq_ignore_ascii_case(b"content-length:")
{
length = trimmed_line[15..].trim().parse().unwrap_or(0);
}
}
if length == 0 {
const MAX_MESSAGE_BYTES: usize = 50 * 1024 * 1024; // 50MB safety cap
if length == 0 || length > MAX_MESSAGE_BYTES {
return None;
}
let mut buffer = vec![0; length];
if stdin.read_exact(&mut buffer).await.is_err() {
let mut buffer = Vec::with_capacity(length.min(64 * 1024));
if stdin
.take(length as u64)
.read_to_end(&mut buffer)
.await
.is_err()
|| buffer.len() != length
{
return None;
}
String::from_utf8(buffer).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[tokio::test]
async fn test_read_batch_ndjson_message() {
let input = "[{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}]\n";
let mut reader = BufReader::new(Cursor::new(input));
let msg = read_mcp_message(&mut reader).await;
assert_eq!(
msg,
Some("[{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}]".to_string())
);
}
#[tokio::test]
async fn test_read_ndjson_message() {
let input = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}\n";
let mut reader = BufReader::new(Cursor::new(input));
let msg = read_mcp_message(&mut reader).await;
assert_eq!(
msg,
Some("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}".to_string())
);
}
#[tokio::test]
async fn test_read_content_length_message() {
let payload = "{\"jsonrpc\":\"2.0\",\"id\":2}";
let input = format!("Content-Length: {}\r\n\r\n{}", payload.len(), payload);
let mut reader = BufReader::new(Cursor::new(input));
let msg = read_mcp_message(&mut reader).await;
assert_eq!(msg, Some(payload.to_string()));
}
#[tokio::test]
async fn test_read_mcp_message_eof() {
let input = "";
let mut reader = BufReader::new(Cursor::new(input));
let msg = read_mcp_message(&mut reader).await;
assert_eq!(msg, None);
}
#[tokio::test]
async fn test_read_mcp_message_zero_content_length() {
let input = "Content-Length: 0\r\n\r\n";
let mut reader = BufReader::new(Cursor::new(input));
let msg = read_mcp_message(&mut reader).await;
assert_eq!(msg, None);
}
#[tokio::test]
async fn test_read_mcp_message_exceeds_max_bytes() {
let input = "Content-Length: 60000000\r\n\r\n";
let mut reader = BufReader::new(Cursor::new(input));
let msg = read_mcp_message(&mut reader).await;
assert_eq!(msg, None);
}
#[tokio::test]
async fn test_read_mcp_message_read_exact_error() {
let input = "Content-Length: 1000\r\n\r\nshort";
let mut reader = BufReader::new(Cursor::new(input));
let msg = read_mcp_message(&mut reader).await;
assert_eq!(msg, None);
}
}
+8 -4
View File
@@ -1,12 +1,11 @@
[package]
name = "nvim-core"
version = "0.1.0"
edition = "2021"
version = "1.0.0"
edition = "2024"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
rmp-serde = "1.1"
rmpv = "1.0"
tokio = { version = "1.37", features = ["full", "io-util", "io-std"] }
tracing = "0.1.44"
@@ -14,5 +13,10 @@ tracing-appender = "0.2.5"
tracing-subscriber = "0.3.23"
dirs = "7.0.0"
rmcp = { version = "3.4.0", features = ["server"] }
mcp-stdio = { version = "0.1.0", path = "../mcp-stdio" }
mcp-stdio = { version = "1.0.0", path = "../mcp-stdio" }
bytes = "1.12.1"
dashmap = "6.2.1"
[build-dependencies]
chrono = "0.4.45"
+39
View File
@@ -0,0 +1,39 @@
use std::process::Command;
fn main() {
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())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| chrono::Local::now().format("%y.%m.%d.%H%M%S").to_string());
let version = format!("v{git_date} ({git_hash})");
println!("cargo:rustc-env=APP_VERSION={version}");
println!("cargo:rerun-if-changed=../.git/HEAD");
println!("cargo:rerun-if-changed=../.git/index");
println!("cargo:rerun-if-changed=src");
println!("cargo:rerun-if-changed=Cargo.toml");
println!("cargo:rerun-if-changed=build.rs");
}
@@ -1,6 +1,6 @@
fn main() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
nvim_core::run_mcp_loop("mcp-memory-win-nvim", env!("APP_VERSION")).await;
nvim_core::run_mcp_loop("mcp-memory-nvim", env!("APP_VERSION")).await;
});
}
+157
View File
@@ -0,0 +1,157 @@
use std::sync::Arc;
use std::sync::LazyLock;
use tokio::io::AsyncBufReadExt;
pub static ACTIVE_SOCKET: LazyLock<Arc<tokio::sync::Mutex<Option<String>>>> =
LazyLock::new(|| Arc::new(tokio::sync::Mutex::new(None)));
pub static HEADLESS_PROC: LazyLock<Arc<tokio::sync::Mutex<Option<tokio::process::Child>>>> =
LazyLock::new(|| Arc::new(tokio::sync::Mutex::new(None)));
unsafe extern "C" {
fn atexit(f: unsafe extern "C" fn()) -> i32;
}
static HEADLESS_PID: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
static ONCE_INIT: std::sync::Once = std::sync::Once::new();
unsafe extern "C" fn cleanup_on_exit() {
let pid = HEADLESS_PID.swap(0, std::sync::atomic::Ordering::SeqCst);
if pid != 0 {
#[cfg(windows)]
{
let _ = std::process::Command::new("taskkill")
.args(["/F", "/T", "/PID", &pid.to_string()])
.output();
}
#[cfg(unix)]
{
let _ = std::process::Command::new("kill")
.args(["-9", &pid.to_string()])
.output();
}
}
}
pub async fn spawn_headless_nvim() -> Result<String, String> {
if std::process::Command::new("nvim")
.arg("--version")
.output()
.is_err()
{
return Err("Error: Neovim is not installed or not in PATH. Cannot execute nvim tool. Fallback to basic terminal tools.".to_string());
}
ONCE_INIT.call_once(|| unsafe {
atexit(cleanup_on_exit);
});
#[cfg(windows)]
let socket_name = format!(
r"\\.\pipe\agy-headless-nvim-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
#[cfg(unix)]
let socket_name = format!(
"/tmp/agy-headless-nvim-{}-{}.sock",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
let mut child = tokio::process::Command::new("nvim")
.arg("--headless")
.arg("--listen")
.arg(&socket_name)
.arg("--cmd")
.arg("lua vim.schedule(function() io.stdout:write('READY\\n') io.stdout:flush() end)")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.kill_on_drop(true)
.spawn()
.map_err(|e| format!("Failed to spawn headless Neovim: {}", e))?;
if let Some(id) = child.id() {
HEADLESS_PID.store(id, std::sync::atomic::Ordering::SeqCst);
}
if let Some(stdout) = child.stdout.take() {
let mut reader = tokio::io::BufReader::new(stdout);
let mut ready_line = String::new();
match tokio::time::timeout(
tokio::time::Duration::from_millis(2000),
reader.read_line(&mut ready_line),
)
.await
{
Ok(Ok(_)) => {
if ready_line.trim() != "READY" {
HEADLESS_PID.store(0, std::sync::atomic::Ordering::SeqCst);
let _ = child.kill().await;
return Err(format!(
"Headless Neovim child output unexpected banner: {:?}",
ready_line
));
}
}
Ok(Err(e)) => {
HEADLESS_PID.store(0, std::sync::atomic::Ordering::SeqCst);
let _ = child.kill().await;
return Err(format!(
"Failed to read readiness from headless Neovim: {}",
e
));
}
Err(_) => {
HEADLESS_PID.store(0, std::sync::atomic::Ordering::SeqCst);
let _ = child.kill().await;
return Err("Timeout waiting for headless Neovim to become ready".to_string());
}
}
}
let old_child = {
let mut lock = HEADLESS_PROC.lock().await;
let old = lock.take();
*lock = Some(child);
old
};
if let Some(mut old) = old_child {
let _ = old.kill().await;
}
Ok(socket_name)
}
pub async fn kill_headless_nvim() {
HEADLESS_PID.store(0, std::sync::atomic::Ordering::SeqCst);
let child_to_kill = {
let mut proc_lock = HEADLESS_PROC.lock().await;
proc_lock.take()
};
if let Some(mut child) = child_to_kill {
let _ = child.kill().await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_headless_nvim_lifecycle() {
let res = spawn_headless_nvim().await;
if let Ok(socket_name) = res {
assert!(socket_name.contains("agy-headless-nvim"));
kill_headless_nvim().await;
}
}
}
+33
View File
@@ -0,0 +1,33 @@
# Antigravity Neovim MCP Instructions
When connected to this Neovim MCP server (`win-nvim`), you have powerful tools to interact directly with the active Neovim editor.
## The Consolidated Tool Arsenal (v3)
The server consolidates granular Neovim operations into 5 smart mega-tools:
- **`nvim_buffer`**: Buffer and file management. Actions: `read`, `replace`, `save`, `undo`, `redo`, `create_scratch`.
- **`nvim_workspace`**: Window split and focus management. Actions: `list_buffers`, `list_windows`, `focus`, `split`, `cwd`.
- **`nvim_intelligence`**: Code intelligence and LSP. Actions: `hover`, `definition`, `references`, `outline`, `query`, `diagnostics`, `rename`, `code_action`.
- **`nvim_ui`**: Visual highlighting, diff previews, and ghost text. Actions: `highlight`, `ghost_text`, `clear`.
- **`nvim_exec`**: Escape hatch for raw evaluation. Actions: `lua`, `vimscript`, `terminal`.
## 1. Using Consolidated Domain Tools First
Always prefer the specific consolidated tools (like `nvim_buffer`, `nvim_workspace`, `nvim_ui`, etc.) over writing raw Lua scripts. These tools are strongly typed, tested, and safe.
## 2. Lua God Mode (`nvim_exec` with action `lua`)
If you need to access *any* Neovim API that does not have a dedicated tool (e.g., changing options, setting autocmds), you MUST use `nvim_exec` with action `lua` as your escape hatch.
**CRITICAL**: `nvim_exec` is restricted to **READ-ONLY** queries. Do NOT use it to mutate editor state.
### CRITICAL RULES for `nvim_exec` (`lua`):
1. **Never Block:** Never use interactive prompt functions or interactive confirmation flags in substitutions (e.g., `%s/old/new/gc`). This will cause the headless MCP bridge to deadlock forever.
2. **Visual Feedback:** Always trigger a notification using `require("notify")("Antigravity: [Action]", "info", { title = "Antigravity" })`.
3. **Auto-Save:** If you modify a file buffer, always save it using `vim.cmd('write')` within the same Lua script so external tools can see the changes, unless you explicitly want to pause for manual human review.
4. **Buffer Focus & Centering:** When making changes to a buffer, switch the active window to that buffer, jump to the edited line/column (`vim.api.nvim_win_set_cursor(0, {line, col})`), and center the viewport horizontally and vertically (`vim.cmd('normal! zz'); vim.cmd('normal! ' .. col .. '|zs')`) so the user immediately sees the change in full context.
## 3. The "Unix is NOT King" Rule
You should **ALWAYS prioritize Neovim tools over basic unix terminal utilities** (like `cat`, `grep`, `sed`, `awk`, or PowerShell equivalents) for file read/writes and search/replace. If an interactive Neovim session is not currently open, the server will automatically connect or spawn a persistent headless Neovim daemon in the background to execute your commands.
**CRITICAL PAIR-PROGRAMMING EXCEPTION:**
While the headless background instance is great for autonomous, routine tasks, if you are performing collaborative "pair programming" activities, complex refactors that require visual engagement, or step-by-step human review, **DO NOT** execute them blindly in the background. Instead, explicitly ask the user to open a Neovim UI first so they can visually follow along. Use Neovim as your primary AST-aware interface to the codebase at all times.
## 4. Tool Schema Discovery
Do **NOT** grep or search the Rust source code to find tool schemas or arguments. All lazy-loaded MCP tool schemas are automatically cached as JSON files on your disk. To understand a tool's arguments, directly read `~/.gemini/antigravity-cli/mcp/win-nvim/<tool_name>.json` (or linux-nvim).
+2127 -623
View File
File diff suppressed because it is too large. Load diff
-168
View File
@@ -1,168 +0,0 @@
import re
import sys
with open('C:\\Users\\reazul.ashraf\\workspace\\rust\\mcp-memory\\nvim-core\\src\\lib.rs', 'r', encoding='utf-8') as f:
content = f.read()
start_pattern = r'#\[cfg\(windows\)\]\nasync fn call_nvim'
end_pattern = r'async fn send_nvim_command'
start_idx = re.search(start_pattern, content).start()
end_idx = re.search(end_pattern, content).start()
new_code = """use std::sync::LazyLock;
use std::sync::Arc;
use tokio::sync::{Mutex, mpsc, oneshot};
use std::collections::HashMap;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
pub struct NvimRequest {
pub msgid_str: String,
pub req: rmpv::Value,
pub reply: oneshot::Sender<Result<rmpv::Value, String>>,
}
static NVIM_CONN: LazyLock<Arc<Mutex<Option<mpsc::Sender<NvimRequest>>>>> = LazyLock::new(|| Arc::new(Mutex::new(None)));
async fn get_nvim_connection() -> Result<mpsc::Sender<NvimRequest>, String> {
let mut conn_lock = NVIM_CONN.lock().await;
if let Some(sender) = conn_lock.as_ref() {
if !sender.is_closed() {
return Ok(sender.clone());
}
}
tracing::info!("Establishing new persistent connection to Neovim");
let socket_path = get_socket_path().await?;
#[cfg(windows)]
let stream = {
use tokio::net::windows::named_pipe::ClientOptions;
ClientOptions::new().open(&socket_path).map_err(|e| e.to_string())?
};
#[cfg(unix)]
let stream = {
use tokio::net::UnixStream;
UnixStream::connect(socket_path).await.map_err(|e| e.to_string())?
};
let (mut read_half, mut write_half) = tokio::io::split(stream);
let (tx, mut rx) = mpsc::channel::<NvimRequest>(32);
let pending_requests: Arc<Mutex<HashMap<String, oneshot::Sender<Result<rmpv::Value, String>>>>> = Arc::new(Mutex::new(HashMap::new()));
// Write task
let pending_clone = Arc::clone(&pending_requests);
tokio::spawn(async move {
while let Some(req) = rx.recv().await {
let mut buf = Vec::new();
if let Err(e) = rmpv::encode::write_value(&mut buf, &req.req) {
let _ = req.reply.send(Err(e.to_string()));
continue;
}
pending_clone.lock().await.insert(req.msgid_str.clone(), req.reply);
if write_half.write_all(&buf).await.is_err() {
tracing::error!("Failed to write to Neovim socket");
break;
}
}
});
// Read task
let pending_clone2 = Arc::clone(&pending_requests);
tokio::spawn(async move {
let mut resp_buf = Vec::new();
let mut chunk = vec![0u8; 8192];
let mut offset = 0;
loop {
let mut cursor = std::io::Cursor::new(&resp_buf[offset..]);
match rmpv::decode::read_value(&mut cursor) {
Ok(val) => {
offset += cursor.position() as usize;
if let rmpv::Value::Array(ref arr) = val {
if arr.len() >= 4 && arr[0] == rmpv::Value::Integer(1.into()) {
let msgid = &arr[1];
let msgid_str = format!("{:?}", msgid);
if let Some(reply_sender) = pending_clone2.lock().await.remove(&msgid_str) {
let _ = reply_sender.send(Ok(val));
}
}
}
// Trim buffer if it gets too large
if offset > 1024 * 1024 {
resp_buf.drain(..offset);
offset = 0;
}
continue;
}
Err(_) => {
if offset > 0 {
resp_buf.drain(..offset);
offset = 0;
}
let read_future = read_half.read(&mut chunk);
match tokio::time::timeout(tokio::time::Duration::from_secs(60), read_future).await {
Ok(Ok(n)) if n > 0 => {
resp_buf.extend_from_slice(&chunk[..n]);
}
_ => {
tracing::error!("Neovim socket read loop closed or timeout");
break;
}
}
}
}
}
// Cleanup pending requests on disconnect
let mut pending = pending_clone2.lock().await;
for (_, sender) in pending.drain() {
let _ = sender.send(Err("Connection closed".to_string()));
}
});
*conn_lock = Some(tx.clone());
Ok(tx)
}
async fn call_nvim(req: rmpv::Value) -> Result<rmpv::Value, String> {
let msgid = if let rmpv::Value::Array(ref arr) = req {
if arr.len() > 1 {
arr[1].clone()
} else {
rmpv::Value::Nil
}
} else {
rmpv::Value::Nil
};
let msgid_str = format!("{:?}", msgid);
let tx = get_nvim_connection().await?;
let (reply_tx, reply_rx) = oneshot::channel();
tx.send(NvimRequest {
msgid_str,
req,
reply: reply_tx,
}).await.map_err(|_| "Failed to send request to Neovim connection manager")?;
match tokio::time::timeout(tokio::time::Duration::from_secs(10), reply_rx).await {
Ok(Ok(res)) => res,
Ok(Err(_)) => Err("Response channel dropped".to_string()),
Err(_) => Err("Timeout waiting for Neovim response".to_string()),
}
}
"""
new_content = content[:start_idx] + new_code + content[end_idx:]
with open('C:\\Users\\reazul.ashraf\\workspace\\rust\\mcp-memory\\nvim-core\\src\\lib.rs', 'w', encoding='utf-8') as f:
f.write(new_content)
print("Patched!")
-40
View File
@@ -1,40 +0,0 @@
import re
import sys
with open('C:\\Users\\reazul.ashraf\\workspace\\rust\\mcp-memory\\nvim-core\\src\\lib.rs', 'r', encoding='utf-8') as f:
content = f.read()
macro_def = """
macro_rules! send_text_result {
($id:expr, $text:expr) => {
send_response(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id: $id,
result: Some(json!({
"content": [{"type": "text", "text": $text}]
})),
error: None,
}).await;
};
}
"""
if "macro_rules! send_text_result" not in content:
idx = content.find("pub async fn run_mcp_loop")
content = content[:idx] + macro_def + "\n" + content[idx:]
# Find tools/call block
tools_call_idx = content.find('"tools/call" => {')
if tools_call_idx != -1:
block = content[tools_call_idx:]
# Replace the verbose blocks
pattern = r'send_response\(JsonRpcResponse \{\s*jsonrpc: "2\.0"\.to_string\(\),\s*id,\s*result: Some\(json!\(\{\s*"content": \[\{"type": "text", "text": (.*?)\}\]\s*\}\)\),\s*error: None,\s*\}\)\s*\.await;'
new_block = re.sub(pattern, r'send_text_result!(id.clone(), \1);', block)
content = content[:tools_call_idx] + new_block
with open('C:\\Users\\reazul.ashraf\\workspace\\rust\\mcp-memory\\nvim-core\\src\\lib.rs', 'w', encoding='utf-8') as f:
f.write(content)
print("Patched nvim macro")
-15
View File
@@ -1,15 +0,0 @@
import re
import sys
with open('C:\\Users\\reazul.ashraf\\workspace\\rust\\mcp-memory\\stub\\src\\main.rs', 'r', encoding='utf-8') as f:
content = f.read()
start1 = r'let mut log_prefix = String::from\("Message"\);\n\s*if let Ok\(v\) = serde_json::from_str::<serde_json::Value>\(&msg\) \{[\s\S]*?log_prefix = format!\("Response id=\{\}", res_id\);\n\s*\}\n\s*\}'
content = re.sub(start1, r'let log_prefix = logger::extract_log_prefix(&msg, false);', content)
start2 = r'let mut log_prefix = String::from\("Message"\);\n\s*if let Ok\(v\) = serde_json::from_str::<serde_json::Value>\(&text\) \{[\s\S]*?log_prefix = format!\("Response id=\{\} \[Error: \{\}\]", id, is_error\);\n\s*\}'
content = re.sub(start2, r'let log_prefix = logger::extract_log_prefix(&text, true);', content)
with open('C:\\Users\\reazul.ashraf\\workspace\\rust\\mcp-memory\\stub\\src\\main.rs', 'w', encoding='utf-8') as f:
f.write(content)
print("Patched stub")
-12
View File
@@ -1,12 +0,0 @@
import re
with open('C:\\Users\\reazul.ashraf\\workspace\\rust\\mcp-memory\\nvim-core\\src\\lib.rs', 'r', encoding='utf-8') as f:
content = f.read()
content = content.replace('read_message(&mut reader)', 'mcp_stdio::read_mcp_message(&mut reader)')
content = content.replace('let req = mcp_stdio::read_mcp_message(&mut reader).await.unwrap();', 'let raw = mcp_stdio::read_mcp_message(&mut reader).await.unwrap();\n let req: JsonRpcRequest = serde_json::from_str(&raw).unwrap();')
content = content.replace('let req = mcp_stdio::read_mcp_message(&mut reader).await;', 'let req = mcp_stdio::read_mcp_message(&mut reader).await;')
with open('C:\\Users\\reazul.ashraf\\workspace\\rust\\mcp-memory\\nvim-core\\src\\lib.rs', 'w', encoding='utf-8') as f:
f.write(content)
print("Patched tests")
-1
View File
@@ -1 +0,0 @@
{"code": "local buf = vim.fn.bufnr('server/src/search.rs')\nif buf == -1 then\n vim.cmd('e server/src/search.rs')\n buf = vim.api.nvim_get_current_buf()\nend\n\nvim.api.nvim_buf_set_lines(buf, 216, 222, false, {\n ' &self,',\n ' entities: &[Entity],',\n ' tasks: &[Task],',\n ' snippets: &[Snippet],',\n ' adrs: &[Adr],'\n})\n\n-- We need to change the loop variables inside the task from values to clones if they are passed as slices\n-- Actually we can just clone the slice data before moving it into spawn_blocking\nvim.api.nvim_buf_set_lines(buf, 222, 223, false, {\n ' ) -> tokio::task::JoinHandle<tantivy::Result<()>> {',\n ' let entities = entities.to_vec();',\n ' let tasks = tasks.to_vec();',\n ' let snippets = snippets.to_vec();',\n ' let adrs = adrs.to_vec();'\n})\n\nvim.cmd('write')\nrequire('notify')('Updated search index_batch signature', 'info', { title = 'Antigravity' })\nreturn 'ok'\n"}
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""
Pre-Push Gatekeeper for Git and MCP Memory.
Enforces workspace quality gates:
1. Allows tag pushes and branch deletions without verification.
2. Bypasses tests for docs/config-only modifications (.md, .txt, .png, justfile, etc.).
3. Queries mcp-memory gate API (/gate/verify?action=push&target=<branch>).
4. If no pre-authorized gate is found, runs project tests locally to verify 100% pass before allowing push.
"""
import os
import sys
import json
import subprocess
import urllib.request
import urllib.error
DOC_EXTENSIONS = {
".md", ".txt", ".png", ".jpg", ".jpeg", ".pdf", ".svg",
".drawio", ".gif", ".ico", ".csv"
}
DOC_FILENAMES = {
"justfile", "jenkinsfile", "dockerfile", "license",
"changelog", ".gitignore", ".gitattributes"
}
def is_doc_or_config(filepath: str) -> bool:
base = os.path.basename(filepath).lower()
if base in DOC_FILENAMES:
return True
ext = os.path.splitext(base)[1].lower()
return ext in DOC_EXTENSIONS
def run_git(cmd):
try:
res = subprocess.run(["git"] + cmd, capture_output=True, text=True, check=True)
return res.stdout.strip()
except Exception:
return ""
def check_mcp_gate(port: str, branch: str):
"""
Returns (status_code, reason)
status_code: 200 (Authorized), 403 (Blocked), 404 (Not found), 0 (Server unreachable)
"""
url = f"http://127.0.0.1:{port}/gate/verify?action=push&target={branch}"
try:
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=1.5) as resp:
return resp.status, "Authorized"
except urllib.error.HTTPError as e:
body = ""
try:
body = e.read().decode("utf-8")
except Exception:
pass
return e.code, body
except Exception:
return 0, "Server unreachable"
def set_mcp_gate(port: str, branch: str, authorize: bool, reason: str):
url = f"http://127.0.0.1:{port}/gate/set"
payload = {
"action": "push",
"target": branch,
"authorize": authorize,
"reason": reason
}
try:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(req, timeout=1.5):
pass
except Exception:
pass
def post_mcp_event(port: str, topic: str, payload: dict):
url = f"http://127.0.0.1:{port}/api/events/post"
try:
data = json.dumps({"topic": topic, "payload": payload}).encode("utf-8")
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(req, timeout=1.0):
pass
except Exception:
pass
def wait_for_mcp_decision(port: str, topic: str, timeout_secs: int = 2):
"""Waits for an operator or auditor decision on the event bus."""
url = f"http://127.0.0.1:{port}/events/wait?topic={topic}&timeout={timeout_secs}"
try:
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=timeout_secs + 0.5) as resp:
data = json.loads(resp.read().decode("utf-8"))
payload = data.get("payload", {})
status = str(payload.get("status", "")).lower()
reason = payload.get("reason", "Decision received via event bus")
if status in ("authorized", "approved", "ok", "true"):
return True, reason
elif status in ("blocked", "denied", "rejected", "false"):
return False, reason
except Exception:
pass
return None, ""
def run_local_tests() -> bool:
# 1. Rust workspace
if os.path.exists("Cargo.toml"):
print("\033[36m[Pre-Push Gatekeeper]\033[0m Running cargo test...")
cmd = ["cargo", "test", "--workspace"]
res = subprocess.run(cmd)
return res.returncode == 0
# 2. Python workspace (uv / pytest)
elif os.path.exists("pyproject.toml") or os.path.exists("setup.py"):
print("\033[36m[Pre-Push Gatekeeper]\033[0m Running python tests...")
cmd = ["uv", "run", "pytest"] if os.system("uv --version >nul 2>&1") == 0 else ["pytest"]
res = subprocess.run(cmd)
return res.returncode == 0
return True
def main():
# Read stdin passed by git pre-push: <local ref> <local sha> <remote ref> <remote sha>
lines = sys.stdin.read().splitlines()
if not lines:
sys.exit(0)
port = os.environ.get("MCP_PORT", "3000")
for line in lines:
parts = line.strip().split()
if len(parts) < 4:
continue
local_ref, local_sha, remote_ref, remote_sha = parts[:4]
# 1. Skip deletions
if local_sha == "0000000000000000000000000000000000000000" or local_ref == "(delete)":
continue
# 2. Skip tags
if local_ref.startswith("refs/tags/"):
print(f"\033[33m[Pre-Push Gatekeeper]\033[0m Tag push detected ({local_ref}). Quality gate bypassed.")
continue
# Extract branch name
branch = local_ref.replace("refs/heads/", "")
# 3. Check for documentation / config-only changes
zero_sha = "0000000000000000000000000000000000000000"
diff_range = local_sha if remote_sha == zero_sha else f"{remote_sha}..{local_sha}"
changed_files_raw = run_git(["diff", "--name-only", diff_range])
changed_files = [f.strip() for f in changed_files_raw.splitlines() if f.strip()]
if changed_files and all(is_doc_or_config(f) for f in changed_files):
print(f"\033[33m[Pre-Push Gatekeeper]\033[0m All modified files are docs/config-only. Quality gate bypassed.")
continue
# 4. Query MCP Memory gate status
status, reason = check_mcp_gate(port, branch)
if status == 200:
print(f"\033[32m[Pre-Push Gatekeeper]\033[0m \u2714 Push AUTHORIZED by MCP Memory gate for branch '{branch}'.")
continue
elif status == 403:
print(f"\033[31m[Pre-Push Gatekeeper]\033[0m \u274c Push BLOCKED by MCP Memory gate for branch '{branch}'!")
if reason:
print(f" Reason: {reason}")
sys.exit(1)
# 5. No gate found (404 or server offline) -> Request interactive approval or verify unit tests
if status != 0:
post_mcp_event(port, "gate:approval_requested", {
"branch": branch,
"local_sha": local_sha,
"remote_sha": remote_sha,
"changed_files_count": len(changed_files)
})
# Fast check: Did dashboard / operator / auditor approve or reject via event bus?
decision, dec_reason = wait_for_mcp_decision(port, f"gate:decision:{branch}", timeout_secs=1)
if decision is True:
print(f"\033[32m[Pre-Push Gatekeeper]\033[0m \u2714 Push INTERACTIVELY AUTHORIZED via event bus: {dec_reason}")
set_mcp_gate(port, branch, True, dec_reason)
post_mcp_event(port, "gate:event", {"status": "authorized", "active_gate": "authorized", "branch": branch})
continue
elif decision is False:
print(f"\033[31m[Pre-Push Gatekeeper]\033[0m \u274c Push BLOCKED by operator: {dec_reason}")
set_mcp_gate(port, branch, False, dec_reason)
post_mcp_event(port, "gate:event", {"status": "blocked", "active_gate": "blocked", "branch": branch})
sys.exit(1)
print(f"\033[33m[Pre-Push Gatekeeper]\033[0m No pre-authorized gate record for '{branch}'. Verifying unit tests...")
if run_local_tests():
print(f"\033[32m[Pre-Push Gatekeeper]\033[0m \u2714 Unit tests passed 100%. Push authorized.")
if status != 0:
set_mcp_gate(port, branch, True, "Unit tests passed locally via pre-push gatekeeper")
post_mcp_event(port, "gate:event", {"status": "authorized", "active_gate": "authorized", "branch": branch})
else:
print(f"\033[31m[Pre-Push Gatekeeper]\033[0m \u274c Push BLOCKED! Unit tests failed locally. Fix failing tests before pushing.")
if status != 0:
set_mcp_gate(port, branch, False, "Unit tests failed during pre-push gatekeeper check")
post_mcp_event(port, "gate:event", {"status": "blocked", "active_gate": "blocked", "branch": branch})
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
# Native shell pre-push hook using UDP Rendezvous
PORT=${MCP_UDP_PORT1:-3001}
while read local_ref local_sha remote_ref remote_sha; do
if [ "$local_sha" = "0000000000000000000000000000000000000000" ] || [ "$local_ref" = "(delete)" ]; then
continue
fi
if [[ "$local_ref" == refs/tags/* ]]; then
continue
fi
BRANCH=${local_ref#refs/heads/}
echo -e "\033[36m[Pre-Push Gatekeeper]\033[0m Checking gate for branch '$BRANCH'..."
# Check if gate is already authorized or blocked
if mcp-memory-server gate verify --action push --target "$BRANCH" --consume 2>/dev/null; then
echo -e "\033[32m[Pre-Push Gatekeeper]\033[0m Push AUTHORIZED by MCP Memory gate for branch '$BRANCH'."
continue
else
EXIT_CODE=$?
if [ $EXIT_CODE -eq 1 ]; then
echo -e "\033[31m[Pre-Push Gatekeeper]\033[0m Push BLOCKED by MCP Memory gate for branch '$BRANCH'."
exit 1
fi
# If exit code is 2 (Not Found), we wait via UDP Rendezvous
echo -e "\033[33m[Pre-Push Gatekeeper]\033[0m No pre-authorized gate for '$BRANCH'. Waiting for approval via UDP rendezvous..."
# Payload for UDP
PAYLOAD="{\"type\":\"gate_wait\",\"action\":\"push\",\"target\":\"$BRANCH\"}"
if udp-send --wait "$PORT" "$PAYLOAD"; then
echo -e "\033[32m[Pre-Push Gatekeeper]\033[0m Push AUTHORIZED via UDP rendezvous for branch '$BRANCH'."
continue
else
echo -e "\033[31m[Pre-Push Gatekeeper]\033[0m Push DENIED or timed out via UDP rendezvous for branch '$BRANCH'."
exit 1
fi
fi
done
exit 0
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""
Deterministic Git Reconciliation Hook for MCP Memory
Automatically syncs git commit events to mcp-memory to transition ADRs, resolve tech debts,
complete tasks, cascade-unblock dependent tasks, and recalculate milestone statuses.
"""
import os
import sys
import json
import subprocess
import urllib.request
import urllib.error
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 main():
port = os.environ.get("MCP_PORT", "3000")
# 1. Quick check if server is running
ping_url = f"http://127.0.0.1:{port}/ping"
try:
with urllib.request.urlopen(ping_url, timeout=1.0) as resp:
if resp.status != 200:
sys.exit(0)
except Exception:
# Server not running, silently exit
sys.exit(0)
# 2. Extract git metadata
commit = run_git(["rev-parse", "HEAD"])
branch = run_git(["branch", "--show-current"])
author = run_git(["log", "-1", "--pretty=format:%an"])
message = run_git(["log", "-1", "--pretty=format:%B"])
# Get changed files in this commit
files_raw = run_git(["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"])
files = [f.strip() for f in files_raw.splitlines() if f.strip()]
if not commit or not message:
sys.exit(0)
payload = {
"commit": commit,
"branch": branch,
"author": author,
"message": message,
"files": files,
}
# 3. Post to reconciliation endpoint
reconcile_url = f"http://127.0.0.1:{port}/api/git/commit"
req_data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
reconcile_url,
data=req_data,
headers={"Content-Type": "application/json"},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=3.0) as resp:
if resp.status == 200:
body = json.loads(resp.read().decode("utf-8"))
res = body.get("reconciliation", {})
adrs = res.get("implemented_adrs", [])
debts = res.get("resolved_tech_debts", [])
tasks = res.get("completed_tasks", [])
unblocked = res.get("unblocked_tasks", [])
milestones = res.get("updated_milestones", [])
if any([adrs, debts, tasks, unblocked, milestones]):
print("\033[36m[MCP Memory Auto-Reconciled]\033[0m")
if adrs:
print(f" \033[32m✔ Implemented ADRs:\033[0m {', '.join(adrs)}")
if debts:
print(f" \033[32m✔ Resolved Tech Debts:\033[0m {', '.join(debts)}")
if tasks:
print(f" \033[32m✔ Completed Tasks:\033[0m {', '.join(tasks)}")
if unblocked:
print(f" \033[33m⚡ Unblocked Dependent Tasks:\033[0m {', '.join(unblocked)}")
if milestones:
print(f" \033[35m🎯 Updated Milestones:\033[0m {', '.join(milestones)}")
except Exception:
# Never break commit flow on failure
pass
if __name__ == "__main__":
main()
+53 -16
View File
@@ -1,33 +1,57 @@
[package]
name = "mcp-memory-server"
version = "0.1.0"
version = "1.0.0"
edition = "2024"
[lib]
name = "mcp_memory_server"
path = "src/lib.rs"
[dependencies]
async-trait = "0.1.92"
axum = { version = "0.8", features = ["ws"] }
bincode = "1.3.3"
clap = { version = "4.6.6", features = ["derive"] }
dashmap = "6.2.1"
dirs = "6.0.0"
dirs = "7.0.0"
futures-util = "0.3.34"
glob = "0.3.4"
git2 = { version = "0.19.0", default-features = false }
notify = "6.1.1"
redb = "4.2.0"
reqwest = { version = "0.12", default-features = false, features = ["stream", "rustls-tls"] }
redb = "4.3.0"
reqwest = { version = "0.13", default-features = false, features = ["stream", "json"] }
schemars = "1.2.2"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
tantivy = "0.26.1"
tantivy = "0.26.2"
tokio = { version = "1.53.1", features = ["full"] }
tokio-stream = "0.1.19"
tokio-util = { version = "0.7.19", features = ["io"] }
tracing = "0.1.44"
tracing-subscriber = "0.3.23"
uuid = { version = "1.26.0", features = ["v4"] }
uuid = { version = "1.26.0", features = ["v4", "v5"] }
tracing-appender = "0.2.5"
rmcp = { version = "3.4.0", features = ["server"] }
thiserror = "2.0.20"
tower = { version = "0.5.3", features = ["util"] }
candle-core = "0.11.0"
candle-nn = "0.11.0"
candle-transformers = "0.11.0"
tokenizers = { version = "0.23.1", default-features = false, features = ["onig"] }
hf-hub = { version = "1.0.0", features = ["blocking"] }
arboard = "3.6.1"
image = "0.25.10"
base64 = "0.23.1"
git2 = "0.19.0"
tree-sitter = "0.23.2"
tree-sitter-rust = "0.23.3"
tree-sitter-typescript = "0.23.2"
tree-sitter-python = "0.23.6"
tree-sitter-java = "0.23.5"
tree-sitter-c = "0.24.2"
tree-sitter-cpp = "0.23.4"
tree-sitter-go = "0.25.0"
ignore = "0.4.33"
notify = "7"
blake3 = "1.8"
tokio-stream = { version = "0.1", features = ["sync"] }
chrono = { version = "0.4.45", features = ["serde"] }
ocrs = "0.13.1"
rten = "0.26.0"
serde_yaml = "0.9.34"
[build-dependencies]
chrono = "0.4.45"
@@ -36,6 +60,19 @@ winres = "0.1.12"
[dev-dependencies]
tempfile = "3.27.0"
[[bin]]
name = "test_rmcp"
path = "src/bin_test.rs"
[target.'cfg(windows)'.dependencies]
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",
] }
+18 -4
View File
@@ -12,16 +12,30 @@ fn main() {
let is_dirty = Command::new("git")
.args(["status", "--porcelain"])
.output().is_ok_and(|out| !out.stdout.is_empty());
.output()
.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();
-24
View File
@@ -1,24 +0,0 @@
import re
with open(r'C:\Users\reazul.ashraf\workspace\rust\mcp-memory\server\src\handlers.rs', 'r', encoding='utf-8') as f:
content = f.read()
content = content.replace(
'crate::mcp::tool_def::<GenerateStandupReportTool>(\n "generate_standup_report",\n "",\n )',
'crate::mcp::tool_def::<GenerateStandupReportTool>(\n "generate_standup_report",\n "Generate a standup report summarizing recent work, blockers, and next steps.",\n )'
)
content = content.replace(
'crate::mcp::tool_def::<GetEnvironmentDetailsTool>(\n "get_environment_details",\n "",\n )',
'crate::mcp::tool_def::<GetEnvironmentDetailsTool>(\n "get_environment_details",\n "Get detailed information about a specific deployment environment.",\n )'
)
content = content.replace(
'crate::mcp::tool_def::<ListContextWorkspacesTool>(\n "list_context_workspaces",\n "",\n )',
'crate::mcp::tool_def::<ListContextWorkspacesTool>(\n "list_context_workspaces",\n "List all saved context workspaces.",\n )'
)
with open(r'C:\Users\reazul.ashraf\workspace\rust\mcp-memory\server\src\handlers.rs', 'w', encoding='utf-8') as f:
f.write(content)
print("Descriptions fixed.")
+193
View File
@@ -0,0 +1,193 @@
use crate::AppState;
use crate::state::GenericEvent;
use axum::extract::{Query, State};
use axum::response::IntoResponse;
use axum::response::sse::{Event, KeepAlive, Sse};
use std::collections::HashMap;
use std::sync::Arc;
use tokio_stream::StreamExt;
use tokio_stream::wrappers::BroadcastStream;
pub async fn wait_for_event_handler(
State(state): State<Arc<AppState>>,
Query(params): Query<HashMap<String, String>>,
) -> 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) => {
let topic_matches = topic.as_ref().is_none_or(|t| t == &event.topic);
let session_matches = session_id
.as_ref()
.is_none_or(|s| Some(s) == event.session_id.as_ref());
if topic_matches && session_matches {
return Ok(event);
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
tracing::warn!(
"Event bus receiver lagged by {} messages; continuing wait.",
skipped
);
continue;
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
return Err("Event bus closed");
}
}
}
};
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": 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(),
},
}
}
pub async fn post_event_handler(
State(state): State<Arc<AppState>>,
axum::Json(event): axum::Json<GenericEvent>,
) -> impl IntoResponse {
let _ = state.handler.state.event_bus_tx.send(event);
axum::Json(serde_json::json!({"status": "ok"}))
}
/// Real-time Server-Sent Events (SSE) stream for agent execution events and observability (ADR-0110)
pub async fn sse_events_handler(
State(state): State<Arc<AppState>>,
Query(params): Query<HashMap<String, String>>,
) -> impl IntoResponse {
let topic_filter = params.get("topic").cloned();
let session_filter = params.get("session_id").cloned();
let rx = state.handler.state.event_bus_tx.subscribe();
let stream = BroadcastStream::new(rx).filter_map(move |msg| match msg {
Ok(event) => {
let topic_matches = topic_filter.as_ref().is_none_or(|t| t == &event.topic);
let session_matches = session_filter
.as_ref()
.is_none_or(|s| Some(s) == event.session_id.as_ref());
if topic_matches && session_matches {
let json_data = serde_json::to_string(&event).unwrap_or_default();
Some(Ok::<_, std::convert::Infallible>(
Event::default().event(event.topic).data(json_data),
))
} else {
None
}
}
Err(_) => None,
});
Sse::new(stream).keep_alive(KeepAlive::default())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::router::MemoryHandler;
use crate::state::MemoryState;
use axum::extract::Query;
use axum::extract::State;
use std::collections::HashMap;
use std::sync::RwLock;
use std::sync::atomic::AtomicUsize;
use tempfile::tempdir;
#[tokio::test]
async fn test_events_wait_and_post() {
let dir = tempdir().unwrap();
let mem_state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let (shutdown_tx, _) = tokio::sync::oneshot::channel();
let app_state = Arc::new(AppState {
handler: Arc::new(MemoryHandler::new(mem_state.clone())),
clients: RwLock::new(HashMap::new()),
next_id: AtomicUsize::new(1),
shutdown_tx: std::sync::Mutex::new(Some(shutdown_tx)),
});
// Start wait_for_event in a background task
let app_state_clone = app_state.clone();
let mut params = HashMap::new();
params.insert("topic".to_string(), "test_topic".to_string());
params.insert("session_id".to_string(), "123".to_string());
let wait_task = tokio::spawn(async move {
let res = wait_for_event_handler(State(app_state_clone), Query(params)).await;
// axum::Json<GenericEvent> is returned, we need to extract it somehow, but just returning is enough for testing
res
});
// Yield slightly to ensure the wait task has subscribed
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
// Post an event that shouldn't match
let unmatched_event = GenericEvent {
topic: "wrong_topic".to_string(),
session_id: Some("123".to_string()),
payload: serde_json::json!({}),
};
post_event_handler(State(app_state.clone()), axum::Json(unmatched_event)).await;
// Post the matching event
let matched_event = GenericEvent {
topic: "test_topic".to_string(),
session_id: Some("123".to_string()),
payload: serde_json::json!({"foo": "bar"}),
};
post_event_handler(State(app_state.clone()), axum::Json(matched_event)).await;
// Wait for the wait task to complete
let _ = wait_task.await.unwrap();
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod events;
pub mod rest;
pub mod setup;
pub mod telemetry;
pub mod ws;
+209
View File
@@ -0,0 +1,209 @@
use crate::AppState;
use crate::error::AppError;
use crate::models::GateRecord;
use axum::{
Json,
extract::{Query, State},
response::IntoResponse,
};
use std::collections::HashMap;
use std::sync::Arc;
#[derive(serde::Deserialize, serde::Serialize)]
pub struct GateVerifyReq {
pub action: String,
pub target: String,
pub namespace: Option<String>,
#[serde(default)]
pub params: HashMap<String, String>,
#[serde(default)]
pub consume: bool,
}
#[derive(serde::Deserialize, serde::Serialize)]
pub struct GateSetReq {
pub action: String,
pub target: String,
pub namespace: Option<String>,
#[serde(default)]
pub params: HashMap<String, String>,
pub authorize: Option<bool>,
pub block: Option<bool>,
pub reason: Option<String>,
}
pub async fn gate_verify_handler(
State(app_state): State<Arc<AppState>>,
Query(q): Query<GateVerifyReq>,
) -> Result<impl IntoResponse, AppError> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut found = None;
let mut to_remove = None;
app_state.handler.state.env.gates.modify(|gates| {
if let Some(idx) = gates.iter().position(|g| {
g.action == q.action
&& g.target == q.target
&& g.namespace == q.namespace
&& g.params == q.params
&& g.expires_at.is_none_or(|exp| exp > now)
}) {
found = Some(gates[idx].clone());
if q.consume {
to_remove = Some(idx);
}
}
if let Some(idx) = to_remove {
gates.remove(idx);
}
});
match found {
Some(record) => {
if record.status == "authorized" {
Ok((axum::http::StatusCode::OK, "Authorized"))
} else {
let msg = if let Some(r) = record.reason {
format!("Action blocked. Reason: {}", r)
} else {
"Action blocked.".to_string()
};
Err(AppError::Forbidden(msg))
}
}
None => Err(AppError::NotFound(
"Action not yet authorized (no gate record found).".to_string(),
)),
}
}
pub async fn gate_set_handler(
State(app_state): State<Arc<AppState>>,
Json(body): Json<GateSetReq>,
) -> Result<impl IntoResponse, AppError> {
let status = if body.block.unwrap_or(false) {
"blocked".to_string()
} else if body.authorize.unwrap_or(false) {
"authorized".to_string()
} else {
"pending".to_string()
};
let record = GateRecord {
id: uuid::Uuid::new_v4().to_string(),
action: body.action.clone(),
target: body.target.clone(),
namespace: body.namespace.clone(),
params: body.params.clone(),
status,
reason: body.reason.clone(),
timestamp: crate::handlers::utils::now_secs(),
..Default::default()
};
app_state.handler.state.env.gates.modify(|gates| {
gates.retain(|g| !(g.action == record.action && g.target == record.target));
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."))
}
pub async fn health_handler() -> impl IntoResponse {
axum::Json(serde_json::json!({
"status": "ok",
"version": {
"app_version": env!("APP_VERSION"),
"cargo_pkg_version": env!("CARGO_PKG_VERSION"),
"target_os": std::env::consts::OS,
"target_arch": std::env::consts::ARCH,
}
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::router::MemoryHandler;
use crate::state::MemoryState;
use std::sync::RwLock;
use std::sync::atomic::AtomicUsize;
use tempfile::tempdir;
#[tokio::test]
async fn test_gate_handlers() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let (shutdown_tx, _) = tokio::sync::oneshot::channel();
let app_state = Arc::new(AppState {
handler: Arc::new(MemoryHandler::new(state.clone())),
clients: RwLock::new(HashMap::new()),
next_id: AtomicUsize::new(1),
shutdown_tx: std::sync::Mutex::new(Some(shutdown_tx)),
});
// Set a gate to authorized
let set_req = GateSetReq {
action: "push".to_string(),
target: "main".to_string(),
namespace: Some("global".to_string()),
params: HashMap::new(),
authorize: Some(true),
block: None,
reason: None,
};
let res_set = gate_set_handler(State(app_state.clone()), Json(set_req))
.await
.unwrap();
assert_eq!(res_set.into_response().status(), axum::http::StatusCode::OK);
// Verify the gate (and consume it)
let verify_req = GateVerifyReq {
action: "push".to_string(),
target: "main".to_string(),
namespace: Some("global".to_string()),
params: HashMap::new(),
consume: true,
};
let res_verify = gate_verify_handler(State(app_state.clone()), Query(verify_req))
.await
.unwrap();
assert_eq!(
res_verify.into_response().status(),
axum::http::StatusCode::OK
);
// Verify again should fail since it was consumed
let verify_req2 = GateVerifyReq {
action: "push".to_string(),
target: "main".to_string(),
namespace: Some("global".to_string()),
params: HashMap::new(),
consume: false,
};
let res_verify2 = gate_verify_handler(State(app_state.clone()), Query(verify_req2)).await;
assert!(res_verify2.is_err());
}
}
File diff suppressed because it is too large. Load diff
+601
View File
@@ -0,0 +1,601 @@
use crate::AppState;
use axum::extract::{Query, State};
use std::collections::HashMap;
use std::sync::Arc;
#[derive(serde::Deserialize, serde::Serialize, Debug, Clone, PartialEq)]
pub struct ProjectedTechDebt {
pub id: String,
pub description: String,
pub severity: Option<String>,
pub line_range: Option<String>,
pub file_path: Option<String>,
pub ideal_solution: String,
}
#[derive(serde::Deserialize, serde::Serialize, Debug, Clone, PartialEq)]
pub struct ProjectedAdr {
pub id: String,
pub title: String,
pub decision: String,
pub status: String,
pub affected_components: Vec<String>,
}
pub fn find_projected_knowledge(
state: &crate::state::MemoryState,
file_path: &str,
) -> (Vec<ProjectedTechDebt>, Vec<ProjectedAdr>) {
let normalized_file = file_path.replace('\\', "/").to_lowercase();
let file_name = std::path::Path::new(&normalized_file)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let debts = state.code.tech_debts.read_with(|all_debts| {
all_debts
.iter()
.filter(|d| !d.is_resolved)
.filter(|d| {
if let Some(ref dfp) = d.file_path {
let ndfp = dfp.replace('\\', "/").to_lowercase();
if normalized_file == ndfp
|| normalized_file.ends_with(&format!("/{}", ndfp.trim_start_matches('/')))
|| ndfp.ends_with(&format!("/{}", normalized_file.trim_start_matches('/')))
|| (!file_name.is_empty()
&& (ndfp == file_name || ndfp.ends_with(&format!("/{}", file_name))))
{
return true;
}
}
if !file_name.is_empty()
&& file_name.len() > 3
&& d.description.to_lowercase().contains(&file_name)
{
return true;
}
false
})
.map(|d| ProjectedTechDebt {
id: d.id.clone(),
description: d.description.clone(),
severity: d.severity.clone(),
line_range: d.line_range.clone(),
file_path: d.file_path.clone(),
ideal_solution: d.ideal_solution.clone(),
})
.collect()
});
let adrs = state.code.adrs.read_with(|all_adrs| {
all_adrs
.iter()
.filter(|a| {
a.status.eq_ignore_ascii_case("accepted") || a.status.eq_ignore_ascii_case("active")
})
.filter(|a| {
for comp in &a.affected_components {
let ncomp = comp.replace('\\', "/").to_lowercase();
if !ncomp.is_empty()
&& (normalized_file.ends_with(&ncomp)
|| ncomp.ends_with(&normalized_file)
|| (!file_name.is_empty() && ncomp.contains(&file_name)))
{
return true;
}
}
if !file_name.is_empty()
&& file_name.len() > 3
&& (a.title.to_lowercase().contains(&file_name)
|| a.decision.to_lowercase().contains(&file_name))
{
return true;
}
false
})
.map(|a| ProjectedAdr {
id: a.id.clone(),
title: a.title.clone(),
decision: a.decision.clone(),
status: a.status.clone(),
affected_components: a.affected_components.clone(),
})
.collect()
});
(debts, adrs)
}
#[derive(serde::Deserialize, serde::Serialize, Debug)]
pub struct NvimTelemetry {
pub session_id: String,
pub event: String,
pub file: Option<String>,
pub line: Option<i64>,
pub col: Option<i64>,
// New interactive fields
pub prompt: Option<String>,
pub response: Option<String>,
pub index: Option<i64>,
pub request_id: Option<String>,
pub diff_id: Option<String>,
pub start_row: Option<i64>,
pub end_row: Option<i64>,
}
pub async fn nvim_telemetry_handler(
State(state): State<Arc<AppState>>,
axum::Json(payload): axum::Json<NvimTelemetry>,
) -> impl axum::response::IntoResponse {
// 1. Update active_nvim.txt if FocusGained, BufEnter, or VimEnter
if payload.event == "FocusGained" || payload.event == "BufEnter" || payload.event == "VimEnter"
{
let session = &payload.session_id;
let is_unix_socket = session.starts_with('/') || session.starts_with('~');
if is_unix_socket {
let wsl_path = "\\\\wsl.localhost\\Ubuntu\\home\\riz\\.gemini\\active_nvim.txt";
let _ = tokio::fs::write(wsl_path, session).await;
} else {
let profile =
std::env::var("USERPROFILE").unwrap_or_else(|_| "C:\\Users\\reazul.ashraf".into());
let win_path = format!("{}\\.gemini\\active_nvim.txt", profile);
let _ = tokio::fs::write(&win_path, session).await;
}
}
// 2. Query Knowledge Graph for matching Tech Debts and ADRs (ADR-0103)
let (tech_debts, adrs) = if let Some(ref f) = payload.file {
find_projected_knowledge(&state.handler.state, f)
} else {
(Vec::new(), Vec::new())
};
// 3. Broadcast to UI WebSockets
let ws_msg = serde_json::json!({
"type": "nvim_telemetry",
"data": payload,
"tech_debts": tech_debts,
"adrs": adrs
});
let msg_str = ws_msg.to_string();
let senders: Vec<_> = state
.clients
.read()
.unwrap_or_else(|e| e.into_inner())
.values()
.cloned()
.collect();
for tx in senders {
let _ = tx.try_send(msg_str.clone());
}
if payload.event == "BufWritePost"
&& let Some(ref file_path) = payload.file
{
let normalized_file = file_path.replace("\\", "/");
let topic = format!("nvim:save:{}", normalized_file);
let event = crate::state::GenericEvent {
topic,
session_id: Some(payload.session_id.clone()),
payload: serde_json::json!(&payload),
};
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",
&format!("Neovim {}: {}", payload.event, f),
None,
);
}
// 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,
"active_task": active_task,
"active_gate": active_gate
}))
}
pub async fn nvim_knowledge_projection_handler(
State(state): State<Arc<AppState>>,
Query(params): Query<HashMap<String, String>>,
) -> impl axum::response::IntoResponse {
let (tech_debts, adrs) = if let Some(file_path) = params.get("file") {
find_projected_knowledge(&state.handler.state, file_path)
} else {
(Vec::new(), Vec::new())
};
axum::Json(serde_json::json!({
"status": "ok",
"tech_debts": tech_debts,
"adrs": adrs
}))
}
use crate::models::TerminalHistory;
pub async fn get_terminal_history_handler(
State(state): State<Arc<AppState>>,
) -> impl axum::response::IntoResponse {
let history_json = state
.handler
.state
.telemetry
.terminal_history
.read_with(|h| serde_json::to_string(h).unwrap_or_else(|_| "[]".to_string()));
(
[(axum::http::header::CONTENT_TYPE, "application/json")],
history_json,
)
}
pub async fn terminal_telemetry_handler(
State(state): State<Arc<AppState>>,
axum::Json(payload): axum::Json<TerminalHistory>,
) -> impl axum::response::IntoResponse {
state.handler.state.record_activity(
"terminal",
&format!("Ran command: {}", payload.command),
Some(&format!(
"exit_code: {}, cwd: {}",
payload.exit_code, payload.cwd
)),
);
state.handler.state.record_terminal_history(payload.clone());
let ws_msg = serde_json::json!({
"type": "terminal_telemetry",
"data": payload
});
let msg_str = ws_msg.to_string();
let senders: Vec<_> = state
.clients
.read()
.unwrap_or_else(|e| e.into_inner())
.values()
.cloned()
.collect();
for tx in senders {
let _ = tx.try_send(msg_str.clone());
}
axum::Json(serde_json::json!({"status": "ok"}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::router::MemoryHandler;
use crate::state::MemoryState;
use axum::http::Request;
use axum::routing::get;
use axum::routing::post;
use std::collections::HashMap;
use std::sync::atomic::AtomicUsize;
use tempfile::tempdir;
use tower::ServiceExt;
#[tokio::test]
async fn test_terminal_history() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let (shutdown_tx, _) = tokio::sync::oneshot::channel();
let app_state = Arc::new(AppState {
handler: Arc::new(MemoryHandler::new(state.clone())),
clients: std::sync::RwLock::new(HashMap::new()),
next_id: AtomicUsize::new(1),
shutdown_tx: std::sync::Mutex::new(Some(shutdown_tx)),
});
let app = axum::Router::new()
.route("/api/terminal/history", get(get_terminal_history_handler))
.route("/terminal/telemetry", post(terminal_telemetry_handler))
.with_state(app_state.clone());
// Create a fake terminal telemetry payload
let payload = TerminalHistory {
command: "echo test".to_string(),
exit_code: 0,
cwd: "C:/Users/reazul.ashraf/workspace".to_string(),
os: "windows".to_string(),
timestamp: 12345,
error_output: None,
..Default::default()
};
// Post the telemetry
let req = Request::builder()
.method("POST")
.uri("/terminal/telemetry")
.header("content-type", "application/json")
.body(axum::body::Body::from(
serde_json::to_string(&payload).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), 200);
// Get the telemetry
let req2 = Request::builder()
.method("GET")
.uri("/api/terminal/history")
.body(axum::body::Body::empty())
.unwrap();
let response2 = app.oneshot(req2).await.unwrap();
assert_eq!(response2.status(), 200);
let body_bytes = axum::body::to_bytes(response2.into_body(), usize::MAX)
.await
.unwrap();
let history: Vec<TerminalHistory> = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].command, "echo test");
}
#[tokio::test]
async fn test_find_projected_knowledge_matching() {
let dir = tempdir().unwrap();
let state = MemoryState::new(dir.path().to_str().unwrap());
// Add 1 unresolved Tech Debt for server/src/lib.rs
state.code.tech_debts.modify(|debts| {
debts.push(crate::models::TechDebt {
id: "td-001".to_string(),
namespace: "default".to_string(),
description: "Refactor router dispatch in lib.rs".to_string(),
ideal_solution: "Use modular sub-routers".to_string(),
is_resolved: false,
created_at: 1000,
file_path: Some("server/src/lib.rs".to_string()),
line_range: Some("100-150".to_string()),
severity: Some("high".to_string()),
..Default::default()
});
// Add 1 resolved Tech Debt which should NOT be projected
debts.push(crate::models::TechDebt {
id: "td-002".to_string(),
namespace: "default".to_string(),
description: "Old resolved debt".to_string(),
ideal_solution: "Done".to_string(),
is_resolved: true,
created_at: 500,
file_path: Some("server/src/lib.rs".to_string()),
..Default::default()
});
});
// Add 1 accepted ADR for server/src/lib.rs
state.code.adrs.modify(|adrs| {
adrs.push(crate::models::Adr {
id: "ADR-0103".to_string(),
title: "Virtual Text Knowledge Projection".to_string(),
context: "Context".to_string(),
decision: "Project tech debt and ADRs into active buffer".to_string(),
consequence: "High developer awareness".to_string(),
status: "accepted".to_string(),
timestamp: 2000,
affected_components: vec![
"server/src/lib.rs".to_string(),
"gemini-ui.lua".to_string(),
],
..Default::default()
});
// Add 1 superseded ADR which should NOT be projected
adrs.push(crate::models::Adr {
id: "ADR-0099".to_string(),
title: "Old decision".to_string(),
context: "Context".to_string(),
decision: "Old".to_string(),
consequence: "Old".to_string(),
status: "superseded".to_string(),
timestamp: 1000,
affected_components: vec!["server/src/lib.rs".to_string()],
..Default::default()
});
});
// Test with Windows path format
let (debts, adrs) = find_projected_knowledge(
&state,
"C:\\Users\\reazul.ashraf\\workspace\\rust\\mcp-memory\\server\\src\\lib.rs",
);
assert_eq!(debts.len(), 1);
assert_eq!(debts[0].id, "td-001");
assert_eq!(debts[0].severity.as_deref(), Some("high"));
assert_eq!(adrs.len(), 1);
assert_eq!(adrs[0].id, "ADR-0103");
// Test with unrelated file
let (debts_unrelated, adrs_unrelated) =
find_projected_knowledge(&state, "crates/unrelated/src/foo.rs");
assert!(debts_unrelated.is_empty());
assert!(adrs_unrelated.is_empty());
}
#[tokio::test]
async fn test_nvim_telemetry_and_projection_endpoints() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
state.code.tech_debts.modify(|debts| {
debts.push(crate::models::TechDebt {
id: "td-active".to_string(),
namespace: "default".to_string(),
description: "Fix concurrency lock".to_string(),
ideal_solution: "Use tokio rwlock".to_string(),
is_resolved: false,
created_at: 1000,
file_path: Some("server/src/api/telemetry.rs".to_string()),
line_range: Some("25".to_string()),
severity: Some("medium".to_string()),
..Default::default()
});
});
state.code.adrs.modify(|adrs| {
adrs.push(crate::models::Adr {
id: "ADR-0102".to_string(),
title: "Neovim RPC Circuit Breaker".to_string(),
context: "Context".to_string(),
decision: "Fail fast".to_string(),
consequence: "No hangs".to_string(),
status: "accepted".to_string(),
timestamp: 1000,
affected_components: vec!["telemetry.rs".to_string()],
..Default::default()
});
});
let (shutdown_tx, _) = tokio::sync::oneshot::channel();
let app_state = Arc::new(AppState {
handler: Arc::new(MemoryHandler::new(state.clone())),
clients: std::sync::RwLock::new(HashMap::new()),
next_id: AtomicUsize::new(1),
shutdown_tx: std::sync::Mutex::new(Some(shutdown_tx)),
});
let app = axum::Router::new()
.route("/nvim/telemetry", post(nvim_telemetry_handler))
.route(
"/api/nvim/projection",
get(nvim_knowledge_projection_handler),
)
.with_state(app_state.clone());
// 1. Post BufEnter telemetry
let payload = NvimTelemetry {
session_id: "test-session".to_string(),
event: "BufEnter".to_string(),
file: Some(
"C:/Users/reazul.ashraf/workspace/rust/mcp-memory/server/src/api/telemetry.rs"
.to_string(),
),
line: Some(25),
col: Some(1),
prompt: None,
response: None,
index: None,
request_id: None,
diff_id: None,
start_row: None,
end_row: None,
};
let req = Request::builder()
.method("POST")
.uri("/nvim/telemetry")
.header("content-type", "application/json")
.body(axum::body::Body::from(
serde_json::to_string(&payload).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), 200);
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(body["status"], "ok");
assert_eq!(body["tech_debts"].as_array().unwrap().len(), 1);
assert_eq!(body["tech_debts"][0]["id"], "td-active");
assert_eq!(body["adrs"].as_array().unwrap().len(), 1);
assert_eq!(body["adrs"][0]["id"], "ADR-0102");
// 2. Query GET /api/nvim/projection
let req2 = Request::builder()
.method("GET")
.uri("/api/nvim/projection?file=server/src/api/telemetry.rs")
.body(axum::body::Body::empty())
.unwrap();
let response2 = app.oneshot(req2).await.unwrap();
assert_eq!(response2.status(), 200);
let body_bytes2 = axum::body::to_bytes(response2.into_body(), usize::MAX)
.await
.unwrap();
let body2: serde_json::Value = serde_json::from_slice(&body_bytes2).unwrap();
assert_eq!(body2["status"], "ok");
assert_eq!(body2["tech_debts"].as_array().unwrap().len(), 1);
assert_eq!(body2["adrs"].as_array().unwrap().len(), 1);
}
}
+329
View File
@@ -0,0 +1,329 @@
use crate::AppState;
use axum::extract::{
Query, State,
ws::{Message, WebSocket},
};
use axum::response::IntoResponse;
use futures_util::{SinkExt, StreamExt};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use tokio::sync::mpsc;
pub async fn ws_handler(
ws: axum::extract::ws::WebSocketUpgrade,
_headers: axum::http::HeaderMap,
State(state): State<Arc<AppState>>,
Query(query): Query<HashMap<String, String>>,
) -> axum::response::Response {
let client_type = query
.get("client")
.cloned()
.unwrap_or_else(|| "unknown".to_string());
ws.on_upgrade(move |socket| handle_socket(socket, state, client_type))
.into_response()
}
pub struct SessionCleanup {
pub session_id: String,
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 {
fn drop(&mut self) {
tracing::info!("Dropping session {}", self.session_id);
self.state
.clients
.write()
.unwrap_or_else(|e| e.into_inner())
.remove(&self.session_id);
self.send_task.abort();
self.recv_task.abort();
self.event_task.abort();
}
}
pub async fn handle_socket(socket: WebSocket, state: Arc<AppState>, _client_type: String) {
let session_id = format!("{}", state.next_id.fetch_add(1, Ordering::SeqCst));
let (tx, mut rx) = mpsc::channel::<String>(100);
state
.clients
.write()
.unwrap_or_else(|e| e.into_inner())
.insert(session_id.clone(), tx.clone());
// Reconnection Catch-Up: Replay recent TASK_EVENT notifications so client receives missed Futures
let recent_task_notifications: Vec<String> = state
.handler
.state
.telemetry
.recent_activities
.read_with(|activities| {
activities
.iter()
.filter_map(|act_val| {
if act_val["category"] == "TASK_EVENT"
&& let Some(details_str) = act_val["details"].as_str()
&& let Ok(event_val) =
serde_json::from_str::<serde_json::Value>(details_str)
{
return Some(
serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/task/completed",
"params": event_val
})
.to_string(),
);
}
None
})
.take(5)
.collect()
});
for notif in recent_task_notifications.into_iter().rev() {
let _ = tx.try_send(notif);
}
let (mut sender, mut receiver) = socket.split();
let send_task = tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
tracing::trace!(
"Sending message to websocket (length: {}): {}",
msg.len(),
msg
);
if sender.send(Message::Text(msg.into())).await.is_err() {
tracing::error!("Failed to send message to websocket");
break;
}
}
});
let handler = Arc::clone(&state.handler);
let session_id_clone = session_id.clone();
let response_tx = tx.clone();
let recv_task = tokio::spawn(async move {
while let Some(msg_result) = receiver.next().await {
match msg_result {
Ok(Message::Text(text)) => {
tracing::info!(
"Received text message from websocket (length: {})",
text.len()
);
tracing::trace!("Message content: {}", text);
if let Ok(payload) = serde_json::from_str::<serde_json::Value>(&text) {
// Process MCP request
if let Some(response) = handler.handle_request(payload).await {
let res_str = serde_json::to_string(&response).unwrap_or_else(|e| format!(r#"{{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{{\"code\":-32603,\"message\":\"{}\"}}}}"#, e));
if let Err(e) = response_tx.send(res_str).await {
tracing::error!(
"Failed to send response to client channel for session {}: {}",
session_id_clone,
e
);
}
}
} else {
tracing::warn!(
"Failed to parse payload as JSON from websocket message: {}",
text
);
}
}
Ok(other) => {
tracing::info!("Received non-text message from websocket: {:?}", other);
}
Err(e) => {
tracing::warn!("Websocket receive error: {}", e);
break;
}
}
}
});
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! {
_ = &mut cleanup.send_task => {
tracing::info!("Websocket send task finished for session {}", session_id);
},
_ = &mut cleanup.recv_task => {
tracing::info!("Websocket recv task finished for session {}", session_id);
},
};
}
#[cfg(test)]
mod tests {
use super::*;
use crate::router::MemoryHandler;
use crate::state::MemoryState;
use std::sync::RwLock;
use std::sync::atomic::AtomicUsize;
use tempfile::tempdir;
#[tokio::test]
async fn test_session_cleanup_drop() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let (shutdown_tx, _) = tokio::sync::oneshot::channel();
let app_state = Arc::new(AppState {
handler: Arc::new(MemoryHandler::new(state)),
clients: RwLock::new(HashMap::new()),
next_id: AtomicUsize::new(1),
shutdown_tx: std::sync::Mutex::new(Some(shutdown_tx)),
});
// Insert a dummy client
app_state
.clients
.write()
.unwrap()
.insert("test-session".to_string(), tokio::sync::mpsc::channel(1).0);
let send_task = tokio::spawn(async {});
let recv_task = tokio::spawn(async {});
let event_task = tokio::spawn(async {});
{
let _cleanup = SessionCleanup {
session_id: "test-session".to_string(),
state: app_state.clone(),
send_task,
recv_task,
event_task,
};
} // Drop happens here
assert!(app_state.clients.read().unwrap().is_empty());
}
#[tokio::test]
async fn test_task_event_broadcast_and_reconnection_replay() {
let dir = tempdir().unwrap();
let mem_state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let task_event = crate::models::TaskEvent {
task_id: "task-999".to_string(),
status: "completed".to_string(),
action: Some("update".to_string()),
result: Some(serde_json::json!({"status": "completed"})),
error: None,
timestamp: 1728129000,
session_id: None,
..Default::default()
};
// Broadcast task event
mem_state.broadcast_task_event(task_event.clone());
// Verify recent activities recorded the event
let recorded = mem_state
.telemetry
.recent_activities
.read_with(|act| act.clone());
assert!(!recorded.is_empty());
assert_eq!(recorded[0]["category"], "TASK_EVENT");
// Verify reconnection catch-up replay fetches the notification
let (shutdown_tx, _) = tokio::sync::oneshot::channel();
let app_state = Arc::new(AppState {
handler: Arc::new(MemoryHandler::new(mem_state)),
clients: RwLock::new(HashMap::new()),
next_id: AtomicUsize::new(1),
shutdown_tx: std::sync::Mutex::new(Some(shutdown_tx)),
});
let (tx, mut rx) = mpsc::channel::<String>(10);
app_state
.clients
.write()
.unwrap()
.insert("session-1".to_string(), tx.clone());
let recent_notifications: Vec<String> = app_state
.handler
.state
.telemetry
.recent_activities
.read_with(|activities| {
activities
.iter()
.filter_map(|act_val| {
if act_val["category"] == "TASK_EVENT" {
let details_str = act_val["details"].as_str()?;
let event_val =
serde_json::from_str::<serde_json::Value>(details_str).ok()?;
return Some(
serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/task/completed",
"params": event_val
})
.to_string(),
);
}
None
})
.take(5)
.collect()
});
for notif in recent_notifications {
let _ = tx.try_send(notif);
}
let replayed_msg = rx
.recv()
.await
.expect("Expected replayed task event notification");
let parsed: serde_json::Value = serde_json::from_str(&replayed_msg).unwrap();
assert_eq!(parsed["method"], "notifications/task/completed");
assert_eq!(parsed["params"]["task_id"], "task-999");
assert_eq!(parsed["params"]["status"], "completed");
}
}
-62
View File
@@ -1,62 +0,0 @@
import os
import re
def bake_nvim_safeguards():
filepath = 'nvim-core/src/lib.rs'
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
# 1. We're going to inject an AST-level safeguard inside "nvim_execute_lua"
# Find the execute_lua block
target = """ "nvim_execute_lua" => {
if let Some(code) = args.get("code").and_then(|v| v.as_str()) {
match execute_nvim_lua(code).await {"""
safeguarded = """ "nvim_execute_lua" => {
if let Some(code) = args.get("code").and_then(|v| v.as_str()) {
// BAKE IN: Block interactive prompts that cause server deadlocks
let lower_code = code.to_lowercase();
if lower_code.contains("vim.fn.input") || lower_code.contains("vim.ui.select") || lower_code.contains("vim.fn.confirm") || lower_code.contains("vim.ui.input") {
send_error(id, -32600, "CRITICAL ERROR: Interactive Lua functions (input, select, confirm) are strictly forbidden via MCP as they will hang the headless server.").await;
return;
}
// Block the 'c' confirm flag in vim.cmd substitutions
if (code.contains("vim.cmd") || code.contains("vim.api.nvim_command")) && code.contains("%s") && code.contains("c") {
// A rough heuristic to block `%s/foo/bar/gc`
if regex::Regex::new(r"%s.*/.*c").unwrap().is_match(code) {
send_error(id, -32600, "CRITICAL ERROR: The 'c' (confirm) flag in Neovim substitutions is strictly forbidden via MCP as it triggers an interactive prompt that hangs the headless server. Use '/ge' instead.").await;
return;
}
}
match execute_nvim_lua(code).await {"""
if target in content:
# Also need to add regex as a dependency or just use basic string matching.
# Let's just use basic string matching for the %s block to avoid adding the regex crate to nvim-core if not present
safeguarded_no_regex = """ "nvim_execute_lua" => {
if let Some(code) = args.get("code").and_then(|v| v.as_str()) {
// BAKE IN: Block interactive prompts that cause server deadlocks
let lower_code = code.to_lowercase();
if lower_code.contains("vim.fn.input") || lower_code.contains("vim.ui.select") || lower_code.contains("vim.fn.confirm") || lower_code.contains("vim.ui.input") {
send_error(id, -32600, "CRITICAL ERROR: Interactive Lua functions (input, select, confirm) are strictly forbidden via MCP as they will hang the headless server.").await;
return;
}
// Block the 'c' confirm flag in vim.cmd substitutions
if (code.contains("vim.cmd") || code.contains("vim.api.nvim_command")) && code.contains("%s") && (code.contains("gc'") || code.contains('gc"') || code.contains("gc\\n") || code.contains("c'") || code.contains('c"')) {
send_error(id, -32600, "CRITICAL ERROR: The 'c' (confirm) flag in Neovim substitutions is strictly forbidden via MCP as it triggers an interactive prompt that hangs the headless server. Use '/g' or '/ge' instead.").await;
return;
}
match execute_nvim_lua(code).await {"""
content = content.replace(target, safeguarded_no_regex)
print("Baked safeguards into nvim_execute_lua")
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
else:
print("Could not find nvim_execute_lua block")
bake_nvim_safeguards()
+86
View File
@@ -0,0 +1,86 @@
use mcp_memory_server::router::MemoryHandler;
use mcp_memory_server::state::MemoryState;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = std::env::temp_dir().join(format!("mcp_schemas_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir)?;
let state = Arc::new(MemoryState::new(temp_dir.to_str().unwrap()));
let handler = MemoryHandler::new(state);
let win_dir = PathBuf::from(r"C:\Users\reazul.ashraf\.gemini\antigravity-cli\mcp\mcp-memory");
let wsl_dir =
PathBuf::from(r"\\wsl.localhost\Ubuntu\home\riz\.gemini\antigravity-cli\mcp\mcp-memory");
fs::create_dir_all(&win_dir)?;
if !wsl_dir.exists() {
let _ = fs::create_dir_all(&wsl_dir);
}
let active_tools: std::collections::HashSet<String> = handler.tools.keys().cloned().collect();
println!("Exporting {} active tools schemas...", active_tools.len());
for (name, tool) in &handler.tools {
let schema = tool.schema();
let json_str = serde_json::to_string_pretty(&schema)?;
let win_path = win_dir.join(format!("{}.json", name));
fs::write(&win_path, &json_str)?;
let wsl_path = wsl_dir.join(format!("{}.json", name));
let _ = fs::write(&wsl_path, &json_str);
println!(" [OK] Exported {}.json", name);
}
// Clean up stale JSON files in Windows directory
if let Ok(entries) = fs::read_dir(&win_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "json")
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
&& !active_tools.contains(stem)
{
println!(" [STALE REMOVED Win] {}", path.display());
let _ = fs::remove_file(&path);
}
}
}
// Clean up stale JSON files in WSL directory
if let Ok(entries) = fs::read_dir(&wsl_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "json")
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
&& !active_tools.contains(stem)
{
println!(" [STALE REMOVED WSL] {}", path.display());
let _ = fs::remove_file(&path);
}
}
}
// Sync instructions.md
let instructions_src =
PathBuf::from(r"C:\Users\reazul.ashraf\workspace\rust\mcp-memory\instructions.md");
if instructions_src.exists() {
let _ = fs::copy(&instructions_src, win_dir.join("instructions.md"));
let _ = fs::copy(&instructions_src, wsl_dir.join("instructions.md"));
let server_instructions = PathBuf::from(
r"C:\Users\reazul.ashraf\workspace\rust\mcp-memory\server\src\instructions.md",
);
let _ = fs::copy(&instructions_src, server_instructions);
println!(" [OK] Synchronized instructions.md to Win, WSL, and server/src/instructions.md");
}
let _ = fs::remove_dir_all(&temp_dir);
println!("Schema export complete!");
Ok(())
}
+5
View File
@@ -0,0 +1,5 @@
fn main() {
let mut img = image::DynamicImage::new_rgb8(10, 10);
img.invert();
let _img2 = img.adjust_contrast(20.0);
}
-150
View File
@@ -1,150 +0,0 @@
import os
def rewrite_graph():
with open('server/src/handlers_v2/graph.rs', 'r', encoding='utf-8') as f:
content = f.read()
old_block = """ let mut included: HashSet<&str> = HashSet::new();
let mut to_draw = Vec::new();
state.read_graph(|full| {
for (name, e) in &full.entities {
if let Some(ns) = &req.namespace
&& e.namespace != *ns
{
continue;
}
if query.is_empty()
|| contains_ignore_ascii_case(name, &query)
|| contains_ignore_ascii_case(&e.entity_type, &query)
{
included.insert(name.as_str());
}
}
for r in &full.relations {
if let Some(ns) = &req.namespace
&& r.namespace != *ns
{
continue;
}
if query.is_empty() || included.contains(r.from.as_str()) || included.contains(r.to.as_str()) {
included.insert(r.from.as_str());
included.insert(r.to.as_str());
to_draw.push(r.clone());
}
}
});
use std::fmt::Write;
let mut output = String::with_capacity(included.len() * 40 + to_draw.len() * 60);
output.push_str("graph TD;\\n");
let sanitize = |s: &str, id_mode: bool| -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if c != '"' && c != '(' && c != ')' {
if id_mode && (c == ' ' || c == '-' || c == '.') {
out.push('_');
} else {
out.push(c);
}
}
}
out
};
for name in &included {
let _ = writeln!(
output,
" id_{}[\\"{}\\"];",
sanitize(name, true),
sanitize(name, false)
);
}
for r in to_draw {
let _ = writeln!(
output,
" id_{}-->|\\"{}\\"|id_{};",
sanitize(&r.from, true),
r.relation_type.replace("\\"", ""),
sanitize(&r.to, true)
);
}"""
new_block = """ use std::fmt::Write;
let mut output = state.read_graph(|full| {
let mut included: HashSet<&str> = HashSet::new();
let mut to_draw = Vec::new();
for (name, e) in &full.entities {
if let Some(ns) = &req.namespace
&& e.namespace != *ns
{
continue;
}
if query.is_empty()
|| contains_ignore_ascii_case(name, &query)
|| contains_ignore_ascii_case(&e.entity_type, &query)
{
included.insert(name.as_str());
}
}
for r in &full.relations {
if let Some(ns) = &req.namespace
&& r.namespace != *ns
{
continue;
}
if query.is_empty() || included.contains(r.from.as_str()) || included.contains(r.to.as_str()) {
included.insert(r.from.as_str());
included.insert(r.to.as_str());
to_draw.push(r.clone());
}
}
let mut out = String::with_capacity(included.len() * 40 + to_draw.len() * 60);
out.push_str("graph TD;\\n");
let sanitize = |s: &str, id_mode: bool| -> String {
let mut o = String::with_capacity(s.len());
for c in s.chars() {
if c != '"' && c != '(' && c != ')' {
if id_mode && (c == ' ' || c == '-' || c == '.') {
o.push('_');
} else {
o.push(c);
}
}
}
o
};
for name in &included {
let _ = writeln!(
out,
" id_{}[\\"{}\\"];",
sanitize(name, true),
sanitize(name, false)
);
}
for r in to_draw {
let _ = writeln!(
out,
" id_{}-->|\\"{}\\"|id_{};",
sanitize(&r.from, true),
r.relation_type.replace("\\"", ""),
sanitize(&r.to, true)
);
}
out
});"""
if old_block in content:
with open('server/src/handlers_v2/graph.rs', 'w', encoding='utf-8') as f:
f.write(content.replace(old_block, new_block))
print("Replaced visualize_graph")
else:
print("Could not find old block")
rewrite_graph()
-10
View File
@@ -1,10 +0,0 @@
use rmcp::model::{InitializeResult, ServerCapabilities};
fn main() {
let init = InitializeResult::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(rmcp::model::Implementation::new(
"gemini-mcp-memory",
"3.0.0",
));
println!("{}", serde_json::to_string_pretty(&init).unwrap());
}
+43
View File
@@ -0,0 +1,43 @@
use serde_json::Value;
use std::env;
use std::fs;
use std::path::PathBuf;
use tracing::info;
pub fn load_mcp_config_env() {
let config_path = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gemini/config/mcp_config.json");
if !config_path.exists() {
return;
}
let config_str = match fs::read_to_string(&config_path) {
Ok(s) => s,
Err(_) => return,
};
let parsed: Value = match serde_json::from_str(&config_str) {
Ok(v) => v,
Err(_) => return,
};
if let Some(env_obj) = parsed
.get("mcpServers")
.and_then(|v| v.get("mcp-memory"))
.and_then(|v| v.get("env"))
.and_then(|v| v.as_object())
{
for (key, val) in env_obj {
if let Some(val_str) = val.as_str()
&& env::var(key).is_err()
{
unsafe {
env::set_var(key, val_str);
}
info!("Loaded env var {} from mcp_config.json", key);
}
}
}
}
+441 -477
View File
@@ -21,9 +21,13 @@
--btn-border: #dcdde1;
--btn-hover: #f8f9fa;
--search-hover: #f0f2f5;
--input-bg: white;
--input-bg: #ffffff;
--input-text: #2c3e50;
--inspector-bg: rgba(255,255,255,0.98);
--success-color: #27ae60;
--warning-color: #f39c12;
--error-color: #e74c3c;
--open-color: #e67e22;
}
[data-theme="dark"] {
@@ -42,9 +46,13 @@
--btn-border: #3d4a57;
--btn-hover: #3d4a57;
--search-hover: #2c3643;
--input-bg: #171b21;
--input-bg: #1e242b;
--input-text: #d1d8e0;
--inspector-bg: rgba(30, 36, 43, 0.98);
--success-color: #2ecc71;
--warning-color: #f1c40f;
--error-color: #e74c3c;
--open-color: #e67e22;
}
body {
@@ -458,6 +466,317 @@
color: #7f8c8d;
margin-right: 8px;
}
/* --- Enterprise UX/HCI Styles --- */
.enterprise-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
background: var(--panel-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 10px 14px;
margin-bottom: 12px;
}
.enterprise-search-wrap {
position: relative;
flex: 1;
min-width: 240px;
display: flex;
align-items: center;
}
.enterprise-search-input {
width: 100%;
padding: 8px 30px 8px 32px !important;
border-radius: 6px !important;
border: 1px solid var(--border-color) !important;
background: var(--input-bg) !important;
color: var(--input-text) !important;
font-size: 0.88em !important;
margin-bottom: 0 !important;
box-sizing: border-box !important;
transition: border-color 0.2s, box-shadow 0.2s;
outline: none;
}
.enterprise-search-input:focus {
border-color: #3498db !important;
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2) !important;
}
.enterprise-search-icon {
position: absolute;
left: 10px;
color: var(--text-secondary);
pointer-events: none;
font-size: 0.85em;
}
.enterprise-clear-btn {
position: absolute;
right: 8px;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 1.1em;
line-height: 1;
padding: 2px 4px;
border-radius: 3px;
display: none;
}
.enterprise-clear-btn:hover {
color: var(--error-color);
}
.enterprise-filters-group {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.enterprise-select {
padding: 7px 10px;
border-radius: 6px;
border: 1px solid var(--border-color);
background: var(--input-bg);
color: var(--input-text);
font-size: 0.85em;
cursor: pointer;
outline: none;
transition: border-color 0.15s;
}
.enterprise-select:focus {
border-color: #3498db;
}
.enterprise-btn {
padding: 7px 12px;
border-radius: 6px;
border: 1px solid var(--border-color);
background: var(--btn-bg);
color: var(--text-primary);
font-size: 0.85em;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: all 0.15s ease;
font-weight: 500;
}
.enterprise-btn:hover {
background: var(--btn-hover);
border-color: #3498db;
}
.enterprise-btn.active {
background: #3498db;
color: #fff;
border-color: #2980b9;
}
.enterprise-meta-bar {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.82em;
color: var(--text-secondary);
margin-bottom: 10px;
padding: 0 4px;
}
.enterprise-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid var(--border-color);
}
.enterprise-pagination button {
min-width: 32px;
height: 32px;
padding: 0 8px;
border: 1px solid var(--border-color);
background: var(--btn-bg);
color: var(--text-primary);
border-radius: 6px;
cursor: pointer;
font-size: 0.85em;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
}
.enterprise-pagination button:hover:not(:disabled) {
background: var(--btn-hover);
border-color: #3498db;
}
.enterprise-pagination button.active {
background: #3498db;
color: #fff;
border-color: #2980b9;
font-weight: bold;
}
.enterprise-pagination button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.enterprise-empty-state {
text-align: center;
padding: 36px 20px;
color: var(--text-secondary);
font-size: 0.95em;
border: 1px dashed var(--border-color);
border-radius: 8px;
background: var(--card-bg);
margin: 10px 0;
}
.enterprise-kbd {
font-size: 0.72em;
padding: 2px 5px;
border-radius: 4px;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-secondary);
font-family: monospace;
vertical-align: middle;
}
.enterprise-cards-list {
display: flex;
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>
@@ -467,21 +786,63 @@
<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 &nbsp;•&nbsp; <span>↵</span> to jump &nbsp;•&nbsp; <span>ESC</span> to close</div>
<div id="palette-count" style="font-family: monospace;">0 items</div>
</div>
</div>
</div>
<!-- Clipboard Modal -->
<div id="clipboard-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.8); z-index:9999; justify-content:center; align-items:center; flex-direction:column;">
<div style="background:var(--card-bg); padding:20px; border-radius:8px; max-width:80%; max-height:80%; display:flex; flex-direction:column; gap:15px; border:1px solid var(--border-color); box-shadow: 0 10px 30px rgba(0,0,0,0.5);">
<div style="display:flex; justify-content:space-between; align-items:center;">
<h3 style="margin:0;">Clipboard Vision Extraction</h3>
<button onclick="document.getElementById('clipboard-modal').style.display='none'" style="background:none; border:none; color:var(--text-primary); font-size:20px; cursor:pointer;">&times;</button>
</div>
<div id="clipboard-result" style="overflow:auto; text-align:center; display:flex; justify-content:center;">
<p id="clipboard-loading">Processing clipboard...</p>
</div>
</div>
</div>
<div class="main-container">
<div class="tabs">
<button class="tab-button active" onclick="switchTab('graph-tab', this)">Graph Explorer</button>
<button class="tab-button active" onclick="switchTab('activity-tab', this)">Live Activity</button>
<button class="tab-button" onclick="switchTab('ledger-tab', this)">Code Ledger</button>
<button class="tab-button" onclick="switchTab('graph-tab', this)">Graph Explorer</button>
<button class="tab-button" onclick="switchTab('search-tab', this)">Omni-Search</button>
<button class="tab-button" onclick="switchTab('task-tab', this)">Task Board</button>
<button class="tab-button" onclick="switchTab('sticky-tab', this)">Sticky Notes</button>
<button class="tab-button" onclick="switchTab('activity-tab', this)">Live Activity</button>
<button class="tab-button" onclick="switchTab('techdebt-tab', this)">Tech Debt</button>
<button class="tab-button" onclick="switchTab('adrs-tab', this)">Decisions (ADR)</button>
<button class="tab-button" onclick="switchTab('memos-tab', this)">Handoffs</button>
<button class="tab-button" onclick="switchTab('snippets-tab', this)">Snippets</button>
<button class="tab-button" onclick="switchTab('terminal-tab', this)">Terminal</button>
<button class="tab-button" onclick="switchTab('env-tab', this)">Environment</button>
</div>
<div id="graph-tab" class="tab-content active">
<div id="graph-tab" class="tab-content">
<div class="panel graph-panel" style="flex:1;">
<div class="graph-filters" id="graph-filters">
<!-- Filters injected dynamically -->
@@ -517,489 +878,92 @@
<div id="task-tab" class="tab-content">
<div class="panel kanban-panel" style="flex:1; display:flex; flex-direction:column;">
<h3 style="margin-top:0;">Task Network (HTN)</h3>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; flex-wrap:wrap; gap:10px;">
<h3 style="margin:0;">Task Network (HTN)</h3>
<div style="display:flex; gap:8px; align-items:center;">
<input type="text" id="task-filter-input" placeholder="Filter tasks..." oninput="handleTaskFilter(this.value)" style="padding:6px 10px; font-size:0.85em; border-radius:6px; border:1px solid var(--border-color); background:var(--input-bg); color:var(--input-text); width:200px; margin-bottom:0;">
<select id="task-status-filter" onchange="handleTaskStatusFilter(this.value)" class="enterprise-select">
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
<option value="blocked">Blocked</option>
</select>
</div>
</div>
<div class="kanban-items" id="task-tree-container" style="flex:1; border: 1px solid var(--border-color); padding:15px; border-radius:6px; background:var(--canvas-bg);"></div>
</div>
</div>
<div id="sticky-tab" class="tab-content">
<div class="panel sticky-panel" style="flex:1; display:flex; flex-direction:column;">
<h2>Sticky Notes</h2>
<div id="sticky-notes-container" style="flex:1; overflow-y:auto; display:flex; flex-wrap:wrap; gap:15px; align-items:flex-start; align-content:flex-start;"></div>
<div id="techdebt-tab" class="tab-content">
<div class="panel" style="flex:1; display:flex; flex-direction:column; overflow-y:auto;">
<h2>Tech Debt</h2>
<div id="techdebt-container" style="display:flex; flex-direction:column; gap:15px; margin-bottom: 20px;"></div>
<h2 style="border-top: 1px solid var(--border-color); padding-top: 15px;">Resolved Error Patterns</h2>
<div id="errorfixes-container" style="display:flex; flex-direction:column; gap:15px;"></div>
</div>
</div>
<div id="activity-tab" class="tab-content">
<div id="adrs-tab" class="tab-content">
<div class="panel" style="flex:1; display:flex; flex-direction:column; overflow-y:auto;">
<h2>Architecture Decision Records</h2>
<div id="adrs-container" style="display:flex; flex-direction:column; gap:15px;"></div>
</div>
</div>
<div id="memos-tab" class="tab-content">
<div class="panel" style="flex:1; display:flex; flex-direction:column; overflow-y:auto;">
<h2>Handoff Memos</h2>
<div id="memos-container" style="display:flex; flex-direction:column; gap:15px; margin-bottom: 30px;"></div>
<h2>Session Summaries</h2>
<div id="summaries-container" style="display:flex; flex-direction:column; gap:15px; margin-bottom: 30px;"></div>
<h2>Project Milestones</h2>
<div id="milestones-container" style="display:flex; flex-direction:column; gap:15px;"></div>
</div>
</div>
<div id="snippets-tab" class="tab-content">
<div class="panel" style="flex:1; display:flex; flex-direction:column; overflow-y:auto;">
<h2>Snippets Library</h2>
<div id="snippets-container" style="display:flex; flex-direction:column; gap:15px;"></div>
</div>
</div>
<div id="terminal-tab" class="tab-content">
<div class="panel" style="flex:1; display:flex; flex-direction:column; overflow-y:auto;">
<h2>Terminal History</h2>
<div id="terminal-container" style="display:flex; flex-direction:column; gap:15px;"></div>
</div>
</div>
<div id="ledger-tab" class="tab-content">
<div class="panel" style="flex:1; display:flex; flex-direction:column; overflow-y:auto;">
<h2>Code Change Ledger & Recent Deltas</h2>
<div id="ledger-container" style="display:flex; flex-direction:column; gap:15px;"></div>
</div>
</div>
<div id="env-tab" class="tab-content">
<div class="panel" style="flex:1; display:flex; flex-direction:column; overflow-y:auto;">
<h2>Target Environments & Infrastructure</h2>
<div id="environments-container" style="display:flex; flex-direction:column; gap:15px; margin-bottom: 30px;"></div>
<h2>Tool & System Fingerprints</h2>
<div id="fingerprints-container" style="display:flex; flex-direction:column; gap:15px; margin-bottom: 30px;"></div>
<h2>Environment Requirements & Secrets</h2>
<div id="requirements-container" style="display:flex; flex-direction:column; gap:15px; margin-bottom: 30px;"></div>
<h2>Quality & Authorization Gates</h2>
<div id="gates-container" style="display:flex; flex-direction:column; gap:15px;"></div>
</div>
</div>
<div id="activity-tab" class="tab-content active">
<div class="panel activity-panel" style="flex:1; display:flex; flex-direction:column;">
<div id="activity-feed" style="flex:1;"></div>
</div>
</div>
</div>
<script>
// --- Tabs ---
function switchTab(tabId, btn) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.tab-button').forEach(el => el.classList.remove('active'));
document.getElementById(tabId).classList.add('active');
btn.classList.add('active');
if (tabId === 'graph-tab' && network) {
// Resize graph when becoming visible again
network.redraw();
network.fit();
}
}
// --- Theme Toggle ---
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
}
if (localStorage.getItem('theme') === 'dark' || (!localStorage.getItem('theme') && window.matchMatchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.setAttribute('data-theme', 'dark');
}
// --- Global Graph Data ---
let network = null;
let nodesData = new vis.DataSet();
let edgesData = new vis.DataSet();
let rawEntities = {};
let rawRelations = [];
let activeFilters = new Set();
let allTypes = new Set();
const typeColors = {
'Concept': { bg: '#9b59b6', border: '#8e44ad' },
'Technology': { bg: '#3498db', border: '#2980b9' },
'Infrastructure': { bg: '#e67e22', border: '#d35400' },
'Database_Table': { bg: '#f1c40f', border: '#f39c12' },
'Service': { bg: '#1abc9c', border: '#f39c12' },
'Tool': { bg: '#e74c3c', border: '#c0392b' },
'Integration': { bg: '#2ecc71', border: '#27ae60' },
'System': { bg: '#34495e', border: '#2c3e50' },
'Rule': { bg: '#fd79a8', border: '#e84393' }
};
function getColorForType(type) {
if (typeColors[type]) return typeColors[type];
// Hash string to color
let hash = 0;
for (let i = 0; i < type.length; i++) hash = type.charCodeAt(i) + ((hash << 5) - hash);
const hue = hash % 360;
return { background: `hsl(${hue}, 70%, 60%)`, border: `hsl(${hue}, 70%, 40%)` };
}
// --- Graph Controls ---
function zoomGraph(step) {
if (!network) return;
const currentScale = network.getScale();
network.moveTo({ scale: currentScale * (1 + step) });
}
function resetGraph() {
if (!network) return;
network.fit({ animation: { duration: 500, easingFunction: 'easeInOutQuad' } });
}
// --- Graph Inspector ---
function closeInspector() {
document.getElementById('inspector-panel').classList.remove('open');
if (network) network.unselectAll();
}
function showInspector(nodeId) {
const entity = rawEntities[nodeId];
if (!entity) return;
document.getElementById('inspector-title').innerText = entity.name;
document.getElementById('inspector-type').innerText = entity.entityType;
document.getElementById('inspector-namespace').innerText = entity.namespace || 'global';
const obsHtml = (entity.observations || []).map(o => `<li>${o}</li>`).join('');
document.getElementById('inspector-observations').innerHTML = obsHtml || '<li><em>No observations recorded.</em></li>';
const rels = rawRelations.filter(r => r.from === nodeId || r.to === nodeId);
const relHtml = rels.map(r => {
if (r.from === nodeId) return `<li>→ <em>${r.relationType}</em> → <strong>${r.to}</strong></li>`;
return `<li>← <em>${r.relationType}</em> ← <strong>${r.from}</strong></li>`;
}).join('');
document.getElementById('inspector-relations').innerHTML = relHtml || '<li><em>No direct relations.</em></li>';
document.getElementById('inspector-panel').classList.add('open');
}
function toggleFilter(type) {
if (activeFilters.has(type)) activeFilters.delete(type);
else activeFilters.add(type);
renderFilters();
updateGraphData();
}
function renderFilters() {
const container = document.getElementById('graph-filters');
container.innerHTML = Array.from(allTypes).sort().map(type => {
const isActive = activeFilters.has(type);
const color = getColorForType(type).background;
return `<button class="filter-chip ${isActive ? 'active' : ''}" onclick="toggleFilter('${type}')">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${color};margin-right:4px;"></span>
${type}
</button>`;
}).join('');
}
function updateGraphData() {
const newNodes = [];
const newEdges = [];
const nodeIds = new Set();
for (const [name, entity] of Object.entries(rawEntities)) {
allTypes.add(entity.entityType);
// If filters are active, only show matching types
if (activeFilters.size > 0 && !activeFilters.has(entity.entityType)) continue;
const color = getColorForType(entity.entityType);
newNodes.push({
id: name,
label: name,
title: `${name}\nType: ${entity.entityType}`,
color: { background: color.bg || color.background, border: color.border },
font: { color: document.documentElement.getAttribute('data-theme') === 'dark' ? '#eee' : '#333' }
});
nodeIds.add(name);
}
rawRelations.forEach(r => {
if (nodeIds.has(r.from) && nodeIds.has(r.to)) {
newEdges.push({
id: r.from + '-' + r.to + '-' + r.relationType,
from: r.from,
to: r.to,
label: r.relationType,
arrows: 'to',
font: { color: document.documentElement.getAttribute('data-theme') === 'dark' ? '#aaa' : '#666', strokeWidth: 0 }
});
}
});
const currentNodes = nodesData.getIds();
nodesData.remove(currentNodes.filter(id => !nodeIds.has(id)));
nodesData.update(newNodes);
const currentEdges = edgesData.getIds();
const newEdgeIds = new Set(newEdges.map(e => e.id));
edgesData.remove(currentEdges.filter(id => !newEdgeIds.has(id)));
edgesData.update(newEdges);
renderFilters();
}
async function loadGraph() {
try {
const res = await fetch('/api/graph');
const data = await res.json();
rawEntities = data.entities || {};
rawRelations = data.relations || [];
updateGraphData();
if (!network) {
const container = document.getElementById('network-container');
const options = {
nodes: { shape: 'dot', size: 16, font: { size: 12 } },
edges: { color: { inherit: 'from', opacity: 0.6 }, font: { size: 10, align: 'middle' }, smooth: { type: 'continuous' } },
physics: { barnesHut: { gravitationalConstant: -2000, centralGravity: 0.3, springLength: 95 } },
interaction: { hover: true, tooltipDelay: 100 }
};
network = new vis.Network(container, { nodes: nodesData, edges: edgesData }, options);
network.on("selectNode", function(params) {
if (params.nodes.length > 0) showInspector(params.nodes[0]);
});
network.on("deselectNode", function(params) {
if (params.nodes.length === 0) closeInspector();
});
}
} catch (err) {
console.error("Failed to load graph", err);
document.getElementById("network-container").innerHTML = '<div style="color:#e74c3c;padding:20px;font-weight:bold;">Error loading graph: ' + err.message + '</div>';
}
}
// --- Task Tree (HTN/DAG) ---
async function completeTask(id) {
try {
await fetch(`/api/tasks/${id}/complete`, { method: 'POST' });
loadTasks(); // Refresh UI instantly
} catch(e) { console.error("Failed to complete task", e); }
}
function buildTaskTreeHTML(tasks, parentId, depth = 0) {
let html = '';
const children = tasks.filter(t => {
const pid = t.parentId || t.parent_id;
if (!parentId) return !pid; // If looking for root, return tasks with no parent
return pid === parentId;
});
if (children.length === 0) return html;
children.forEach(t => {
const isCompleted = t.status === 'completed' || t.status === 'done';
const isCancelled = t.status === 'cancelled' || t.status === 'abandoned';
let cardClass = 'task-card';
if (isCompleted) cardClass += ' completed';
if (isCancelled) cardClass += ' cancelled';
// Find blockers
let isBlocked = false;
let blockers = [];
const deps = t.dependencies || [];
deps.forEach(depId => {
const depTask = tasks.find(dt => dt.id === depId);
if (depTask && depTask.status !== 'completed' && depTask.status !== 'done') {
isBlocked = true;
blockers.push(depTask.title);
}
});
// Child progress
const allChildren = tasks.filter(ct => (ct.parentId || ct.parent_id) === t.id);
const completedChildren = allChildren.filter(ct => ct.status === 'completed' || ct.status === 'done');
let progressHtml = '';
if (allChildren.length > 0) {
const pct = Math.round((completedChildren.length / allChildren.length) * 100);
progressHtml = `
<div style="margin-top:10px; background:#e1e8ed; border-radius:4px; height:8px; overflow:hidden;">
<div style="background:#3498db; width:${pct}%; height:100%; transition:width 0.3s;"></div>
</div>
<div style="font-size:0.8em; color:var(--text-secondary); text-align:right; margin-top:2px;">${pct}% (${completedChildren.length}/${allChildren.length} child tasks)</div>
`;
if (completedChildren.length < allChildren.length) {
isBlocked = true; // Implicitly blocked by children
}
}
html += `<div class="${cardClass}" style="margin-left: ${depth * 20}px; margin-bottom: 10px;">`;
if (isBlocked && !isCompleted && !isCancelled) {
html += `<div style="background:#e74c3c; color:white; font-size:0.75em; padding:2px 6px; border-radius:3px; display:inline-block; margin-bottom:6px; font-weight:bold;">[BLOCKED]</div>`;
if (blockers.length > 0) {
html += `<div style="font-size:0.8em; color:#e74c3c; margin-bottom:6px;">Waiting on: ${blockers.join(', ')}</div>`;
}
}
if (isCancelled) {
html += `<div style="background:#95a5a6; color:white; font-size:0.75em; padding:2px 6px; border-radius:3px; display:inline-block; margin-bottom:6px; font-weight:bold;">[CANCELLED]</div>`;
}
html += `<strong>${t.title}</strong>${t.description}`;
const criteria = t.acceptanceCriteria || t.acceptance_criteria || [];
if (criteria.length > 0) {
html += `<ul style="margin:8px 0 0 0; padding-left:20px; font-size: 0.9em; color: var(--text-secondary);">`;
let unmetCriteria = false;
criteria.forEach(c => {
const isMet = c.isMet || c.is_met;
if (!isMet) unmetCriteria = true;
const check = isMet ? '☑' : '☐';
const strike = isMet ? 'text-decoration: line-through;' : '';
html += `<li style="${strike}">${check} ${c.description}</li>`;
});
html += `</ul>`;
if (unmetCriteria && !isCompleted && !isCancelled) isBlocked = true;
}
html += progressHtml;
if (!isCompleted && !isCancelled && !isBlocked) {
html += `<button class="complete-btn" onclick="completeTask('${t.id}')" title="Mark Completed">✓</button>`;
}
// Recursively render children
if (allChildren.length > 0) {
html += `<div style="margin-top: 15px; border-left: 2px solid var(--border-color); padding-left: 10px;">`;
html += buildTaskTreeHTML(tasks, t.id, 0); // Reset depth since we use margin-left on wrapper
html += `</div>`;
}
html += `</div>`;
});
return html;
}
async function loadTasks() {
try {
const res = await fetch('/api/tasks');
const tasks = await res.json();
const taskContainer = document.getElementById('task-tree-container');
if (!taskContainer) return;
// Find root tasks (no parent)
const rootHtml = buildTaskTreeHTML(tasks, null, 0);
if (!rootHtml) {
taskContainer.innerHTML = '<div style="color:var(--text-secondary); padding:20px; text-align:center;">No active tasks.</div>';
} else {
taskContainer.innerHTML = rootHtml;
}
} catch (err) {
console.error("Failed to load tasks", err);
}
}
// --- Fuzzy-ish Search ---
function highlightText(text, query) {
if (!query) return text;
const regex = new RegExp(`(${query})`, 'gi');
return text.replace(regex, '<span class="search-highlight">$1</span>');
}
async function handleSearch(e) {
const q = e.target.value;
// Provide live feedback if they type at least 3 characters, or hit Enter
if (e.key === 'Enter' || q.length >= 3 || q.length === 0) {
if (!q) {
document.getElementById('search-results').innerHTML = '';
return;
}
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
const data = await res.json();
const container = document.getElementById('search-results');
if (data.results.length === 0) {
container.innerHTML = '<div style="padding:15px;color:var(--text-secondary);">No results found.</div>';
return;
}
container.innerHTML = data.results.map(r => `
<div class="search-result-item" onclick="if(rawEntities['${r.id}']){ showInspector('${r.id}'); network.selectNodes(['${r.id}']); network.focus('${r.id}', {scale:1.2, animation:true}); }" style="cursor:pointer;">
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
<div>
<strong>${highlightText(r.title, q)}</strong>
<span style="font-size: 0.7em; color: var(--text-secondary); background: var(--canvas-bg); padding: 2px 6px; border-radius: 4px; margin-left: 8px; text-transform: uppercase; border: 1px solid var(--border-color);">${r.type_name}</span>
</div>
<div style="font-size:0.75em; color:var(--text-secondary);">${r.score.toFixed(2)}</div>
</div>
<div style="margin-top:6px; font-size:0.9em; line-height:1.4;">${highlightText(r.content.substring(0, 150), q)}${r.content.length > 150 ? '...' : ''}</div>
<div style="font-size:0.75em; color:var(--text-secondary); margin-top:8px; font-family:monospace; background: var(--canvas-bg); padding: 2px 4px; border-radius: 3px; display: inline-block;">ID: ${r.id}</div>
</div>
`).join('');
}
}
// --- WebSocket Activity Feed ---
const MAX_ACTIVITY_HISTORY = 100;
async function loadActivityHistory() {
try {
const response = await fetch('/api/activity');
const history = await response.json();
const feed = document.getElementById('activity-feed');
feed.innerHTML = '';
history.forEach(item => {
const div = document.createElement('div');
div.className = 'feed-entry';
const timeStr = new Date(item.time).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', second:'2-digit'});
div.innerHTML = `<span class="time">[${timeStr}]</span> ${item.message || item.data}`;
feed.appendChild(div);
});
if (history.length > 0) {
feed.scrollTop = feed.scrollHeight;
}
} catch(e) {}
}
function setupWS() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${location.host}/ws?client=ui`);
const feed = document.getElementById('activity-feed');
ws.onmessage = function(event) {
try {
const data = JSON.parse(event.data);
if (data.type === 'activity') {
const div = document.createElement('div');
div.className = 'feed-entry';
const timeStr = new Date(data.data.time).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', second:'2-digit'});
div.innerHTML = `<span class="time">[${timeStr}]</span> ${data.data.message || data.data.data || data.data}`;
feed.appendChild(div);
// Auto-scroll logic
const isScrolledToBottom = feed.scrollHeight - feed.clientHeight <= feed.scrollTop + 20;
if (isScrolledToBottom) {
feed.scrollTop = feed.scrollHeight;
}
}
} catch (e) {
// Ignore non-JSON or other messages for now
}
};
ws.onclose = function() {
console.log("WebSocket closed, attempting to reconnect in 3s...");
setTimeout(setupWS, 3000);
};
}
async function loadStickyNotes() {
try {
const res = await fetch('/api/sticky');
const sticky = await res.json();
const container = document.getElementById('sticky-notes-container');
container.innerHTML = '';
sticky.forEach(note => {
const card = document.createElement('div');
card.className = 'sticky-note';
const date = new Date(note.timestamp * 1000).toLocaleString();
card.innerHTML = `<div style="font-size:0.75em; color:#7f8c8d; margin-bottom:8px;">${date}</div>
<div>${note.content}</div>`;
container.appendChild(card);
});
} catch (err) {
console.error("Failed to load sticky notes", err);
}
}
async function loadVersion() {
try {
const res = await fetch('/api/version');
const data = await res.json();
const verEl = document.getElementById('app-version');
if (verEl) {
verEl.textContent = `v${data.version}`;
}
} catch (err) {
console.error("Failed to load version", err);
}
}
// --- Start ---
loadVersion();
loadGraph();
loadTasks();
loadStickyNotes();
loadActivityHistory();
setupWS();
// Listen to theme toggle changes to update graph font colors
const observer = new MutationObserver(() => updateGraphData());
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
setInterval(() => {
loadGraph();
loadTasks();
loadStickyNotes();
}, 5000);
</script>
<script src="/dashboard.js"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+142
View File
@@ -0,0 +1,142 @@
use crate::store::STORE_TABLE;
use redb::{Database, ReadableTable};
use std::path::Path;
use std::sync::Arc;
pub fn init_redb(base: &Path) -> Arc<Database> {
let is_in_memory = base == Path::new(":memory:") || base.to_str() == Some(":memory:");
let db = if is_in_memory {
Arc::new(
redb::Database::builder()
.create_with_backend(redb::backends::InMemoryBackend::new())
.expect("Failed to create in-memory redb database"),
)
} else {
let redb_path = base.join("mcp_store.redb");
if redb_path.exists() {
let mut db_opt = None;
let mut last_open_err = String::new();
for attempt in 1..=3 {
match redb::Database::open(&redb_path) {
Ok(db) => {
db_opt = Some(Arc::new(db));
break;
}
Err(open_err) => {
last_open_err = open_err.to_string();
if attempt < 3 {
tracing::warn!(
"Transient lock contention opening redb at {:?} (attempt {}/3: {}). Retrying...",
redb_path,
attempt,
open_err
);
std::thread::sleep(std::time::Duration::from_millis(150));
}
}
}
}
if let Some(db) = db_opt {
db
} else {
let err_msg = format!(
"Failed to open existing redb database at {:?}: {}. Attempting to recreate database.",
redb_path, last_open_err
);
tracing::warn!("{}", err_msg);
match redb::Database::create(&redb_path) {
Ok(db) => Arc::new(db),
Err(create_err) => {
if std::env::var("MCP_ALLOW_TMP_FALLBACK").unwrap_or_default() == "1" {
let temp_path = std::env::temp_dir()
.join(format!("mcp_store_fallback_{}.redb", uuid::Uuid::new_v4()));
tracing::warn!(
"CRITICAL PERSISTENCE ALERT: MCP_ALLOW_TMP_FALLBACK=1 set. Using temporary redb database {:?}. Changes will be discarded upon application exit.",
temp_path
);
Arc::new(
redb::Database::create(&temp_path)
.expect("Failed to create fallback redb database"),
)
} else {
panic!(
"CRITICAL PERSISTENCE FAILURE: Unable to open or create primary database at {:?}: (open: {}, create: {}). To prevent silent data loss on restart, the application cannot start without accessible persistence.",
redb_path, last_open_err, create_err
);
}
}
}
}
} else {
match redb::Database::create(&redb_path) {
Ok(db) => Arc::new(db),
Err(create_err) => {
let mut db_opt = None;
let mut last_open_err = String::new();
for attempt in 1..=3 {
match redb::Database::open(&redb_path) {
Ok(db) => {
db_opt = Some(Arc::new(db));
break;
}
Err(open_err) => {
last_open_err = open_err.to_string();
if attempt < 3 {
tracing::warn!(
"Transient lock contention opening redb at {:?} (attempt {}/3: {}). Retrying...",
redb_path,
attempt,
open_err
);
std::thread::sleep(std::time::Duration::from_millis(150));
}
}
}
}
if let Some(db) = db_opt {
db
} else if std::env::var("MCP_ALLOW_TMP_FALLBACK").unwrap_or_default() == "1" {
let temp_path = std::env::temp_dir()
.join(format!("mcp_store_fallback_{}.redb", uuid::Uuid::new_v4()));
tracing::warn!(
"CRITICAL PERSISTENCE ALERT: MCP_ALLOW_TMP_FALLBACK=1 set. Using temporary redb database {:?}. Changes will be discarded upon application exit.",
temp_path
);
Arc::new(
redb::Database::create(&temp_path)
.expect("Failed to create fallback redb database"),
)
} else {
panic!(
"CRITICAL PERSISTENCE FAILURE: Unable to open or create primary database at {:?}: (create: {}, open: {}). To prevent silent data loss on restart, the application cannot start without accessible persistence.",
redb_path, create_err, last_open_err
);
}
}
}
}
};
// Ensure the table exists
match db.begin_write() {
Ok(write_txn) => {
let mut opened_ok = false;
if let Ok(_) = write_txn.open_table(STORE_TABLE) {
opened_ok = true;
}
if opened_ok {
if let Err(e) = write_txn.commit() {
tracing::error!("Failed to commit database initialization transaction: {}", e);
}
}
}
Err(e) => {
tracing::error!(
"Failed to begin write transaction for redb initialization: {}",
e
);
}
}
db
}
+386
View File
@@ -0,0 +1,386 @@
use std::sync::Mutex;
use std::sync::OnceLock;
use candle_core::{DType, Device, Tensor};
use candle_nn::VarBuilder;
use candle_transformers::models::bert::{BertModel, Config};
use tokenizers::Tokenizer;
struct CandleEmbeddingModel {
model: BertModel,
tokenizer: Tokenizer,
device: Device,
}
impl CandleEmbeddingModel {
fn new() -> Result<Self, String> {
if cfg!(test) || std::env::var("MCP_OFFLINE_EMBEDDINGS").is_ok() {
return Err("Offline embedding mode active (tests/offline flag)".to_string());
}
let client = hf_hub::HFClientSync::new().map_err(|e| e.to_string())?;
let repo = client.model("sentence-transformers", "all-MiniLM-L6-v2");
let config_file = repo
.download_file()
.filename("config.json")
.send()
.map_err(|e| format!("Failed to download config.json: {}", e))?;
let tokenizer_file = repo
.download_file()
.filename("tokenizer.json")
.send()
.map_err(|e| format!("Failed to download tokenizer.json: {}", e))?;
let weights_file = repo
.download_file()
.filename("model.safetensors")
.send()
.map_err(|e| format!("Failed to download model.safetensors: {}", e))?;
let config_str = std::fs::read_to_string(&config_file)
.map_err(|e| format!("Failed to read config.json: {}", e))?;
let config: Config = serde_json::from_str(&config_str)
.map_err(|e| format!("Failed to parse config.json: {}", e))?;
let mut tokenizer = Tokenizer::from_file(&tokenizer_file)
.map_err(|e| format!("Failed to load tokenizer: {}", e))?;
tokenizer.with_padding(Some(tokenizers::PaddingParams::default()));
let device = Device::Cpu;
let vb = unsafe {
VarBuilder::from_mmaped_safetensors(&[weights_file], DType::F32, &device)
.map_err(|e| format!("Failed to load safetensors: {}", e))?
};
let model =
BertModel::load(vb, &config).map_err(|e| format!("Failed to load BertModel: {}", e))?;
Ok(Self {
model,
tokenizer,
device,
})
}
fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, String> {
if texts.is_empty() {
return Ok(Vec::new());
}
let encodings = self
.tokenizer
.encode_batch(texts.to_vec(), true)
.map_err(|e| format!("Failed to encode texts: {}", e))?;
let batch_size = encodings.len();
if batch_size == 0 {
return Ok(Vec::new());
}
let seq_len = encodings[0].get_ids().len();
if seq_len == 0 {
return Ok(vec![vec![0.0; 384]; batch_size]);
}
let mut all_ids: Vec<u32> = Vec::with_capacity(batch_size * seq_len);
let mut all_type_ids: Vec<u32> = Vec::with_capacity(batch_size * seq_len);
let mut all_attention_mask: Vec<u32> = Vec::with_capacity(batch_size * seq_len);
for enc in &encodings {
all_ids.extend(enc.get_ids());
all_type_ids.extend(enc.get_type_ids());
all_attention_mask.extend(enc.get_attention_mask());
}
let input_ids = Tensor::from_vec(all_ids, (batch_size, seq_len), &self.device)
.map_err(|e| format!("Failed to build input_ids tensor: {}", e))?;
let token_type_ids = Tensor::from_vec(all_type_ids, (batch_size, seq_len), &self.device)
.map_err(|e| format!("Failed to build token_type_ids tensor: {}", e))?;
let attention_mask =
Tensor::from_vec(all_attention_mask, (batch_size, seq_len), &self.device)
.map_err(|e| format!("Failed to build attention_mask tensor: {}", e))?;
let sequence_output = self
.model
.forward(&input_ids, &token_type_ids, Some(&attention_mask))
.map_err(|e| format!("Bert forward failed: {}", e))?;
// Mean pooling: sum(sequence_output * mask) / clamp(sum(mask), min=1e-9)
let mask_f32 = attention_mask
.to_dtype(DType::F32)
.map_err(|e| e.to_string())?
.unsqueeze(2)
.map_err(|e| e.to_string())?;
let sum_embeddings = sequence_output
.broadcast_mul(&mask_f32)
.map_err(|e| e.to_string())?
.sum(1)
.map_err(|e| e.to_string())?;
let sum_mask = mask_f32
.sum(1)
.map_err(|e| e.to_string())?
.clamp(1e-9, f32::MAX)
.map_err(|e| e.to_string())?;
let mean_pooled = sum_embeddings
.broadcast_div(&sum_mask)
.map_err(|e| e.to_string())?;
// L2 Normalization
let norm = mean_pooled
.sqr()
.map_err(|e| e.to_string())?
.sum_keepdim(1)
.map_err(|e| e.to_string())?
.sqrt()
.map_err(|e| e.to_string())?;
let normalized = mean_pooled
.broadcast_div(&norm)
.map_err(|e| e.to_string())?;
normalized.to_vec2::<f32>().map_err(|e| e.to_string())
}
}
fn fallback_embed(text: &str) -> Vec<f32> {
const DIM: usize = 384;
let mut vec = vec![0.0f32; DIM];
let words: Vec<&str> = text.split_whitespace().collect();
if words.is_empty() {
vec[0] = 1.0;
return vec;
}
for word in words {
let clean: String = word
.chars()
.filter(|c| c.is_alphanumeric())
.flat_map(|c| c.to_lowercase())
.collect();
if clean.is_empty() {
continue;
}
let hash_bytes = blake3::hash(clean.as_bytes());
let h = u64::from_le_bytes(hash_bytes.as_bytes()[0..8].try_into().unwrap());
let idx = (h as usize) % DIM;
let sign = if (h >> 32) & 1 == 0 { 1.0f32 } else { -1.0f32 };
vec[idx] += sign;
let chars: Vec<char> = clean.chars().collect();
for window in chars.windows(3) {
let window_str: String = window.iter().collect();
let h2_bytes = blake3::hash(window_str.as_bytes());
let hv = u64::from_le_bytes(h2_bytes.as_bytes()[0..8].try_into().unwrap());
let idx2 = (hv as usize) % DIM;
let s2 = if (hv >> 32) & 1 == 0 { 0.5f32 } else { -0.5f32 };
vec[idx2] += s2;
}
}
let norm_sq: f32 = vec.iter().map(|x| x * x).sum();
if norm_sq > 0.0 {
let norm = norm_sq.sqrt();
for x in vec.iter_mut() {
*x /= norm;
}
} else {
vec[0] = 1.0;
}
vec
}
enum EmbeddingModel {
Candle(Box<CandleEmbeddingModel>),
Fallback,
}
impl EmbeddingModel {
fn new() -> Self {
match CandleEmbeddingModel::new() {
Ok(model) => EmbeddingModel::Candle(Box::new(model)),
Err(e) => {
tracing::warn!(
"Failed to initialize Candle BERT model ({e}); falling back to deterministic offline embeddings."
);
EmbeddingModel::Fallback
}
}
}
fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, String> {
match self {
EmbeddingModel::Candle(model) => model.embed(texts),
EmbeddingModel::Fallback => Ok(texts.iter().map(|t| fallback_embed(t)).collect()),
}
}
}
static EMBEDDING_MODEL: OnceLock<Mutex<EmbeddingModel>> = OnceLock::new();
static INIT_MUTEX: Mutex<()> = Mutex::new(());
fn get_embedding_model() -> Result<&'static Mutex<EmbeddingModel>, String> {
if let Some(model) = EMBEDDING_MODEL.get() {
return Ok(model);
}
let _guard = INIT_MUTEX.lock().map_err(|e| e.to_string())?;
if let Some(model) = EMBEDDING_MODEL.get() {
return Ok(model);
}
let model = EmbeddingModel::new();
let _ = EMBEDDING_MODEL.set(Mutex::new(model));
Ok(EMBEDDING_MODEL.get().unwrap())
}
pub async fn generate_embedding_async(text: String) -> Result<Vec<f32>, String> {
tokio::task::spawn_blocking(move || {
let model_mutex = get_embedding_model()?;
let model = model_mutex.lock().map_err(|e| e.to_string())?;
let embeddings = model.embed(&[text])?;
let emb = embeddings
.into_iter()
.next()
.ok_or_else(|| "Embedding model returned no embeddings".to_string())?;
if emb.is_empty() {
return Err("Embedding model generated a 0-length vector".to_string());
}
Ok(emb)
})
.await
.map_err(|e| e.to_string())?
}
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
if a.is_empty() || b.is_empty() || a.len() != b.len() {
return 0.0;
}
let mut dot_product = 0.0f32;
let mut norm_a_sq = 0.0f32;
let mut norm_b_sq = 0.0f32;
let (chunks_a, remainder_a) = a.as_chunks::<8>();
let (chunks_b, remainder_b) = b.as_chunks::<8>();
for (ca, cb) in chunks_a.iter().zip(chunks_b.iter()) {
for i in 0..8 {
let x = ca[i];
let y = cb[i];
dot_product += x * y;
norm_a_sq += x * x;
norm_b_sq += y * y;
}
}
for (&x, &y) in remainder_a.iter().zip(remainder_b.iter()) {
dot_product += x * y;
norm_a_sq += x * x;
norm_b_sq += y * y;
}
// Fast path: If vectors are already normalized (Candle & fallback embeddings), skip square roots
if (norm_a_sq - 1.0).abs() < 1e-4 && (norm_b_sq - 1.0).abs() < 1e-4 {
return dot_product.clamp(-1.0, 1.0);
}
let norm_product = norm_a_sq * norm_b_sq;
if norm_product <= 0.0 {
0.0
} else {
(dot_product / norm_product.sqrt()).clamp(-1.0, 1.0)
}
}
pub async fn generate_embeddings_async(texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> {
if texts.is_empty() {
return Ok(Vec::new());
}
tokio::task::spawn_blocking(move || {
let model_mutex = get_embedding_model()?;
let model = model_mutex.lock().map_err(|e| e.to_string())?;
let mut all_embeddings = Vec::with_capacity(texts.len());
let mut current_chunk = Vec::new();
let mut current_chars = 0;
const MAX_CHARS_PER_BATCH: usize = 16384;
for text in texts {
let text_len = text.len();
if !current_chunk.is_empty()
&& (current_chunk.len() >= 64 || current_chars + text_len > MAX_CHARS_PER_BATCH)
{
let chunk_vec = std::mem::take(&mut current_chunk);
let chunk_embeddings = model.embed(&chunk_vec)?;
all_embeddings.extend(chunk_embeddings);
current_chars = 0;
}
current_chars += text_len;
current_chunk.push(text);
}
if !current_chunk.is_empty() {
let chunk_embeddings = model.embed(&current_chunk)?;
all_embeddings.extend(chunk_embeddings);
}
Ok(all_embeddings)
})
.await
.map_err(|e| e.to_string())?
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cosine_similarity_edge_cases() {
assert_eq!(cosine_similarity(&[], &[]), 0.0);
assert_eq!(cosine_similarity(&[1.0, 2.0], &[1.0]), 0.0);
assert_eq!(cosine_similarity(&[0.0, 0.0], &[0.0, 0.0]), 0.0);
let v1 = vec![1.0, 0.0, 0.0];
let v2 = vec![1.0, 0.0, 0.0];
assert!((cosine_similarity(&v1, &v2) - 1.0).abs() < 1e-5);
let v3 = vec![0.0, 1.0, 0.0];
assert!((cosine_similarity(&v1, &v3) - 0.0).abs() < 1e-5);
}
#[tokio::test]
async fn test_generate_embeddings_async_empty() {
let res = generate_embeddings_async(vec![]).await.unwrap();
assert!(res.is_empty());
}
#[tokio::test]
async fn test_generate_embeddings_async_single_text() {
let text = "test text".to_string();
let res = generate_embeddings_async(vec![text.clone()]).await.unwrap();
assert_eq!(res.len(), 1);
assert_eq!(res[0].len(), 384);
}
#[tokio::test]
async fn test_generate_embeddings_async_multiple_texts() {
let texts = vec![
"test text 1".to_string(),
"test text 2".to_string(),
"test text 3".to_string(),
];
let res = generate_embeddings_async(texts.clone()).await.unwrap();
assert_eq!(res.len(), 3);
for embedding in &res {
assert_eq!(embedding.len(), 384);
}
}
#[test]
fn test_fallback_embed_deterministic_stability() {
let text = "The quick brown fox jumps over the lazy dog";
let emb1 = fallback_embed(text);
let emb2 = fallback_embed(text);
assert_eq!(emb1.len(), 384);
assert_eq!(emb1, emb2);
assert!((cosine_similarity(&emb1, &emb2) - 1.0).abs() < 1e-5);
}
}
+169
View File
@@ -0,0 +1,169 @@
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde_json::json;
use thiserror::Error;
pub type Result<T> = std::result::Result<T, AppError>;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Not Found: {0}")]
NotFound(String),
#[error("Entity '{name}' was not found in namespace '{namespace}'")]
EntityNotFound { name: String, namespace: String },
#[error("Invalid relation between '{from}' and '{to}': {reason}")]
InvalidRelation {
from: String,
to: String,
reason: String,
},
#[error("Database error for key '{key}': {message}")]
DatabaseError { key: String, message: String },
#[error("Forbidden: {0}")]
Forbidden(String),
#[error("Internal Server Error: {0}")]
Internal(String),
#[error("Bad Request: {0}")]
BadRequest(String),
#[error("Serialization Error: {0}")]
JsonError(#[from] serde_json::Error),
#[error("I/O Error: {0}")]
IoError(#[from] std::io::Error),
#[error("YAML Serialization Error: {0}")]
YamlError(#[from] serde_yaml::Error),
}
impl From<AppError> for String {
fn from(err: AppError) -> String {
err.to_string()
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, error_message) = match &self {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
AppError::EntityNotFound { name, namespace } => (
StatusCode::NOT_FOUND,
format!(
"Entity '{}' was not found in namespace '{}'",
name, namespace
),
),
AppError::InvalidRelation { from, to, reason } => (
StatusCode::BAD_REQUEST,
format!(
"Invalid relation between '{}' and '{}': {}",
from, to, reason
),
),
AppError::DatabaseError { key, message } => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error for key '{}': {}", key, message),
),
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()),
AppError::JsonError(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("JSON Error: {}", e),
),
AppError::IoError(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("I/O Error: {}", e),
),
AppError::YamlError(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("YAML Serialization Error: {}", e),
),
};
let body = Json(json!({
"error": error_message,
}));
(status, body).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::StatusCode;
use axum::response::IntoResponse;
#[test]
fn test_app_error_not_found() {
let err = AppError::NotFound("item not found".into());
let res = err.into_response();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}
#[test]
fn test_app_error_forbidden() {
let err = AppError::Forbidden("access denied".into());
let res = err.into_response();
assert_eq!(res.status(), StatusCode::FORBIDDEN);
}
#[test]
fn test_app_error_internal() {
let err = AppError::Internal("db error".into());
let res = err.into_response();
assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn test_app_error_bad_request() {
let err = AppError::BadRequest("invalid json".into());
let res = err.into_response();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn test_app_error_display() {
let err = AppError::NotFound("test".into());
assert_eq!(err.to_string(), "Not Found: test");
let err = AppError::Forbidden("test".into());
assert_eq!(err.to_string(), "Forbidden: test");
let err = AppError::Internal("test".into());
assert_eq!(err.to_string(), "Internal Server Error: test");
let err = AppError::BadRequest("test".into());
assert_eq!(err.to_string(), "Bad Request: test");
}
}
impl From<String> for AppError {
fn from(msg: String) -> Self {
AppError::Internal(msg)
}
}
impl From<&str> for AppError {
fn from(msg: &str) -> Self {
AppError::Internal(msg.to_string())
}
}
// Generated by improve-test (Attempt 1)
#[test]
fn test_app_error_unknown_error() {
let err = AppError::Internal("unknown error".into());
let res = err.into_response();
assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
-61
View File
@@ -1,61 +0,0 @@
import os
def fix_env_and_workspaces():
filepath = 'server/src/handlers_v2/env.rs'
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
old_env = """ let fps = state.env_fingerprints.read();
if let Some(fp) = fps.get(&req.namespace) {"""
new_env = """ let data = state.env_fingerprints.read_with(|fps| {
fps.get(&req.namespace).cloned()
});
if let Some(fp) = data {"""
if old_env in content:
content = content.replace(old_env, new_env)
print('Replaced env read')
else:
print('Could not find old_env')
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
filepath = 'server/src/handlers_v2/workspaces.rs'
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
old_ws = """ let query = req.query.to_lowercase();
let snippets = state.snippets.read();
let mut results = Vec::new();
for s in snippets {
if contains_ignore_ascii_case(&s.name, &query)
|| contains_ignore_ascii_case(&s.description, &query)
|| contains_ignore_ascii_case(&s.language, &query)
{
results.push(s);
}
}
let data = serde_json::to_string(&results).unwrap_or_default();"""
new_ws = """ let query = req.query.to_lowercase();
let data = state.snippets.read_with(|snippets| {
let results: Vec<_> = snippets.iter().filter(|s| {
contains_ignore_ascii_case(&s.name, &query)
|| contains_ignore_ascii_case(&s.description, &query)
|| contains_ignore_ascii_case(&s.language, &query)
}).cloned().collect();
serde_json::to_string(&results).unwrap_or_default()
});"""
if old_ws in content:
content = content.replace(old_ws, new_ws)
print('Replaced workspaces read')
else:
print('Could not find old_ws')
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
fix_env_and_workspaces()
-60
View File
@@ -1,60 +0,0 @@
import os
def fix_main_stats():
filepath = 'server/src/main.rs'
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
old_stats = """ let (entities, relations) = {
let graph = state_clone.get_full_graph();
(graph.entities.len(), graph.relations.len())
};
let tasks = state_clone.tasks.read().len();
let snippets = state_clone.snippets.read().len();
let tech_debts = state_clone.tech_debts.read().len();
let adrs = state_clone.adrs.read().len();
let ledger = state_clone.ledger.read().len();
let sticky = state_clone.sticky.read().len();
let error_fixes = state_clone.error_fixes.read().len();
let pinned_files = state_clone.pinned_files.read().len();
let session_summaries = state_clone.session_summaries.read().len();
let handoff_memos = state_clone.handoff_memos.read().len();
let env_fingerprints = state_clone.env_fingerprints.read().len();
let env_requirements = state_clone.env_requirements.read().len();
let milestones = state_clone.milestones.read().len();
let environments = state_clone.environments.read().len();
let pr_checklists = state_clone.pr_checklists.read().len();
let gates = state_clone.gates.read().len();
let context_workspaces = state_clone.context_workspaces.read().len();"""
new_stats = """ let (entities, relations) = state_clone.read_graph(|g| (g.entities.len(), g.relations.len()));
let tasks = state_clone.tasks.read_with(|items| items.len());
let snippets = state_clone.snippets.read_with(|items| items.len());
let tech_debts = state_clone.tech_debts.read_with(|items| items.len());
let adrs = state_clone.adrs.read_with(|items| items.len());
let ledger = state_clone.ledger.read_with(|items| items.len());
let sticky = state_clone.sticky.read_with(|items| items.len());
let error_fixes = state_clone.error_fixes.read_with(|items| items.len());
let pinned_files = state_clone.pinned_files.read_with(|items| items.len());
let session_summaries = state_clone.session_summaries.read_with(|items| items.len());
let handoff_memos = state_clone.handoff_memos.read_with(|items| items.len());
let env_fingerprints = state_clone.env_fingerprints.read_with(|items| items.len());
let env_requirements = state_clone.env_requirements.read_with(|items| items.len());
let milestones = state_clone.milestones.read_with(|items| items.len());
let environments = state_clone.environments.read_with(|items| items.len());
let pr_checklists = state_clone.pr_checklists.read_with(|items| items.len());
let gates = state_clone.gates.read_with(|items| items.len());
let context_workspaces = state_clone.context_workspaces.read_with(|items| items.len());"""
if old_stats in content:
content = content.replace(old_stats, new_stats)
print('Replaced stats reads')
else:
print('Could not find old_stats')
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
fix_main_stats()
-111
View File
@@ -1,111 +0,0 @@
import os
def fix_meta():
filepath = 'server/src/handlers_v2/meta.rs'
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
old_omni = """ let q = req.query.to_lowercase();
let tech_debts: Vec<_> = state
.tech_debts
.read()
.into_iter()
.filter(|d| {
req.namespace.as_ref().is_none_or(|ns| d.namespace == *ns)
&& (contains_ignore_ascii_case(&d.description, &q)
|| contains_ignore_ascii_case(&d.ideal_solution, &q))
})
.collect();
let memos: Vec<_> = state
.handoff_memos
.read()
.into_iter()
.filter(|m| {
req.namespace.as_ref().is_none_or(|ns| m.namespace == *ns)
&& contains_ignore_ascii_case(&m.content, &q)
})
.collect();
let error_fixes: Vec<_> = state
.error_fixes
.read()
.into_iter()
.filter(|f| {
contains_ignore_ascii_case(&f.signature, &q)
|| contains_ignore_ascii_case(&f.solution, &q)
})
.collect();"""
new_omni = """ let q = req.query.to_lowercase();
let tech_debts = state.tech_debts.read_with(|debts| {
debts.iter().filter(|d| {
req.namespace.as_ref().is_none_or(|ns| d.namespace == *ns)
&& (contains_ignore_ascii_case(&d.description, &q)
|| contains_ignore_ascii_case(&d.ideal_solution, &q))
}).cloned().collect::<Vec<_>>()
});
let memos = state.handoff_memos.read_with(|memos| {
memos.iter().filter(|m| {
req.namespace.as_ref().is_none_or(|ns| m.namespace == *ns)
&& contains_ignore_ascii_case(&m.content, &q)
}).cloned().collect::<Vec<_>>()
});
let error_fixes = state.error_fixes.read_with(|fixes| {
fixes.iter().filter(|f| {
contains_ignore_ascii_case(&f.signature, &q)
|| contains_ignore_ascii_case(&f.solution, &q)
}).cloned().collect::<Vec<_>>()
});"""
if old_omni in content:
content = content.replace(old_omni, new_omni)
print('Replaced omni_search vecs')
else:
print('Could not find old_omni')
old_health = """ let active_tasks = state
.tasks
.read()
.into_iter()
.filter(|t| t.status != "done")
.count();
let unresolved_debt = state
.tech_debts
.read()
.into_iter()
.filter(|d| d.namespace == req.namespace && !d.is_resolved)
.count();
let unread_memos = state
.handoff_memos
.read()
.into_iter()
.filter(|m| m.namespace == req.namespace)
.count();
let active_milestones = state
.milestones
.read()
.into_iter()
.filter(|m| m.namespace == req.namespace && m.status != "done")
.count();
let remaining_checklists = state
.pr_checklists
.read()
.into_iter()
.filter(|c| c.namespace == req.namespace)
.count();"""
new_health = """ let active_tasks = state.tasks.read_with(|tasks| tasks.iter().filter(|t| t.status != "done").count());
let unresolved_debt = state.tech_debts.read_with(|debts| debts.iter().filter(|d| d.namespace == req.namespace && !d.is_resolved).count());
let unread_memos = state.handoff_memos.read_with(|memos| memos.iter().filter(|m| m.namespace == req.namespace).count());
let active_milestones = state.milestones.read_with(|milestones| milestones.iter().filter(|m| m.namespace == req.namespace && m.status != "done").count());
let remaining_checklists = state.pr_checklists.read_with(|checklists| checklists.iter().filter(|c| c.namespace == req.namespace).count());"""
if old_health in content:
content = content.replace(old_health, new_health)
print('Replaced health counters')
else:
print('Could not find old_health')
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
fix_meta()
-44
View File
@@ -1,44 +0,0 @@
import os
import re
def fix_nvim_msgid():
filepath = 'nvim-core/src/lib.rs'
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
# Add atomic import and static var if not exists
if 'static NEXT_MSGID' not in content:
atomic_def = "use std::sync::atomic::{AtomicU64, Ordering};\nstatic NEXT_MSGID: AtomicU64 = AtomicU64::new(1);\n"
# Find NVIM_CONN
conn_idx = content.find('static NVIM_CONN')
if conn_idx != -1:
content = content[:conn_idx] + atomic_def + content[conn_idx:]
# Replace all hardcoded msgid
# e.g., RmpValue::Integer(1.into()), // msgid
# with: let msgid = NEXT_MSGID.fetch_add(1, Ordering::SeqCst); ... RmpValue::Integer(msgid.into()),
# We need to insert `let msgid = NEXT_MSGID.fetch_add(1, Ordering::SeqCst);` before `let req = RmpValue::Array(vec![`
# We will use regex to find the blocks
funcs = [
('send_nvim_command', '1'),
('get_nvim_active_buffer', '2'),
('get_nvim_cursor', '3'),
('get_nvim_visual_selection', '4'),
('set_nvim_diagnostics', '5'),
('execute_nvim_lua', '6'),
]
for func, old_id in funcs:
pattern = rf"let req = RmpValue::Array\(vec!\[\s*RmpValue::Integer\(0\.into\(\)\),\s*RmpValue::Integer\({old_id}\.into\(\)\), // msgid"
replacement = f"let msgid = NEXT_MSGID.fetch_add(1, Ordering::SeqCst);\n let req = RmpValue::Array(vec![\n RmpValue::Integer(0.into()),\n RmpValue::Integer(msgid.into()), // msgid"
content = re.sub(pattern, replacement, content)
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
print('Fixed msgid allocations')
fix_nvim_msgid()
-40
View File
@@ -1,40 +0,0 @@
import os
def fix_notes():
filepath = 'server/src/handlers_v2/notes.rs'
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
old_standup = """ let tasks = state
.tasks
.read()
.into_iter()
.filter(|t| t.updated_at >= cutoff)
.collect::<Vec<_>>();
let changes = state
.ledger
.read()
.into_iter()
.filter(|c| c.timestamp >= cutoff)
.collect::<Vec<_>>();
let summaries = state
.session_summaries
.read()
.into_iter()
.filter(|s| s.namespace == req.namespace && s.timestamp >= cutoff)
.collect::<Vec<_>>();"""
new_standup = """ let tasks = state.tasks.read_with(|items| items.iter().filter(|t| t.updated_at >= cutoff).cloned().collect::<Vec<_>>());
let changes = state.ledger.read_with(|items| items.iter().filter(|c| c.timestamp >= cutoff).cloned().collect::<Vec<_>>());
let summaries = state.session_summaries.read_with(|items| items.iter().filter(|s| s.namespace == req.namespace && s.timestamp >= cutoff).cloned().collect::<Vec<_>>());"""
if old_standup in content:
content = content.replace(old_standup, new_standup)
print('Replaced standup in notes.rs')
else:
print('Could not find old_standup in notes.rs')
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
fix_notes()
-66
View File
@@ -1,66 +0,0 @@
import os
def fix_stub_leaks():
filepath = 'stub/src/main.rs'
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
# 1. Fix connect_async to handle shutdown and timeout
old_connect = """ match tokio_tungstenite::connect_async(request).await {"""
new_connect = """ let connect_result = tokio::select! {
_ = shutdown_rx.recv() => {
tracing::info!("Shutdown received during connect");
break;
}
res = tokio::time::timeout(
tokio::time::Duration::from_secs(5),
tokio_tungstenite::connect_async(request)
) => res,
};
match connect_result {
Ok(Ok((ws_stream, _))) => {"""
if old_connect in content:
content = content.replace(old_connect, new_connect)
print("Replaced connect_async")
# Fix Err block to match the new match structure
old_err = """ Err(e) => {
tracing::error!("Failed to connect via WSS: {}", e);
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}"""
new_err = """ Ok(Err(e)) => {
tracing::error!("Failed to connect via WSS: {}", e);
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}
Err(_) => {
tracing::error!("Connection attempt timed out");
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}"""
if old_err in content:
content = content.replace(old_err, new_err)
print("Replaced Err branch")
# 2. Fix the break in send_task that exits the stub instead of reconnecting
old_select_send = """ _ = &mut send_task => {
tracing::error!("Send task exited");
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
recv_task.abort();
break;
}"""
new_select_send = """ _ = &mut send_task => {
tracing::error!("Send task exited");
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
recv_task.abort();
}"""
if old_select_send in content:
content = content.replace(old_select_send, new_select_send)
print("Replaced select send_task")
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
fix_stub_leaks()
-197
View File
@@ -1,197 +0,0 @@
use crate::router::McpTool;
use crate::state::MemoryState;
use std::sync::Arc;
pub struct MemoryHandler {
pub state: Arc<MemoryState>,
pub tools: std::collections::HashMap<String, Box<dyn McpTool>>,
}
impl MemoryHandler {
pub fn new(state: Arc<MemoryState>) -> Self {
let mut tools: std::collections::HashMap<String, Box<dyn McpTool>> =
std::collections::HashMap::new();
macro_rules! register {
($module:ident::$handler:ident) => {
let h = crate::handlers_v2::$module::$handler;
tools.insert(h.name().to_string(), Box::new(h));
};
}
register!(graph::QueryGraphPathHandler);
register!(graph::CreateEntitiesHandler);
register!(graph::CreateRelationsHandler);
register!(graph::AddObservationsHandler);
register!(graph::DeleteEntitiesHandler);
register!(graph::DeleteObservationsHandler);
register!(graph::DeleteRelationsHandler);
register!(graph::ReadGraphHandler);
register!(graph::SearchNodesHandler);
register!(graph::OpenNodesHandler);
register!(graph::VisualizeGraphHandler);
register!(graph::CondenseEntityHandler);
register!(graph::MergeEntitiesHandler);
register!(graph::FindOrphansHandler);
register!(tasks::AddTaskHandler);
register!(tasks::DeleteTaskHandler);
register!(tasks::UpdateTaskStatusHandler);
register!(tasks::ListActiveTasksHandler);
register!(tasks::SetAcceptanceCriteriaHandler);
register!(tasks::VerifyAcceptanceCriteriaHandler);
register!(tasks::AddMilestoneHandler);
register!(tasks::UpdateMilestoneHandler);
register!(tasks::ListMilestonesHandler);
register!(notes::AddStickyNoteHandler);
register!(notes::ReadStickyNotesHandler);
register!(notes::DeleteStickyNoteHandler);
register!(notes::ClearStickyNotesHandler);
register!(notes::LeaveHandoffMemoHandler);
register!(notes::ReadHandoffMemosHandler);
register!(notes::ClearHandoffMemosHandler);
register!(notes::AddSessionSummaryHandler);
register!(notes::GenerateStandupReportHandler);
register!(meta::LogDecisionHandler);
register!(meta::QueryDecisionsHandler);
register!(meta::LogErrorFixHandler);
register!(meta::SearchErrorFixesHandler);
register!(meta::LogCodeChangeHandler);
register!(meta::QueryRecentChangesHandler);
register!(meta::LearnPreferenceHandler);
register!(meta::ReadPreferencesHandler);
register!(meta::LogTechDebtHandler);
register!(meta::ResolveTechDebtHandler);
register!(meta::ListTechDebtHandler);
register!(meta::OmniSearchHandler);
register!(meta::GetProjectHealthHandler);
register!(env::UpdateEnvFingerprintHandler);
register!(env::ReadEnvFingerprintHandler);
register!(env::LogEnvRequirementHandler);
register!(env::RegisterEnvironmentHandler);
register!(env::GetEnvironmentDetailsHandler);
register!(workspaces::PinFileHandler);
register!(workspaces::UnpinFileHandler);
register!(workspaces::ListPinnedFilesHandler);
register!(workspaces::StoreSnippetHandler);
register!(workspaces::SearchSnippetsHandler);
register!(workspaces::DeleteSnippetHandler);
register!(workspaces::SaveContextWorkspaceHandler);
register!(workspaces::LoadContextWorkspaceHandler);
register!(workspaces::ListContextWorkspacesHandler);
register!(workspaces::AddPrChecklistItemHandler);
register!(workspaces::GetPrChecklistHandler);
register!(workspaces::ClearPrChecklistHandler);
Self { state, tools }
}
pub async fn handle_request(&self, req: serde_json::Value) -> Option<serde_json::Value> {
let id = req.get("id").cloned().unwrap_or(serde_json::Value::Null);
let id_clone = id.clone();
let method = req.get("method").and_then(|m| m.as_str()).unwrap_or("");
match method {
"server/discover" => {
let payload = serde_json::json!({
"resultType": "complete",
"ttlMs": 0,
"cacheScope": "public",
"supportedVersions": ["2026-07-28", "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"],
"capabilities": {
"tools": serde_json::json!({})
},
"_meta": {
"io.modelcontextprotocol/serverInfo": {
"name": "gemini-mcp-memory",
"version": "3.0.0"
}
}
});
Some(crate::mcp::success(id, payload))
}
"initialize" => {
let init = rmcp::model::InitializeResult::new(
rmcp::model::ServerCapabilities::builder()
.enable_tools()
.build(),
)
.with_server_info(rmcp::model::Implementation::new(
"gemini-mcp-memory",
"3.0.0",
));
Some(crate::mcp::success(
id,
serde_json::to_value(&init).unwrap_or_default(),
))
}
"notifications/initialized" => None,
"tools/list" => {
let mut tools: Vec<serde_json::Value> =
self.tools.values().map(|t| t.schema()).collect();
tools.sort_by_key(|t| {
t.get("name")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string()
});
Some(crate::mcp::success(
id,
serde_json::json!({ "tools": tools }),
))
}
"tools/call" => {
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
let name = params.get("name").and_then(|n| n.as_str()).unwrap_or("");
let args = params
.get("arguments")
.cloned()
.unwrap_or(serde_json::Value::Object(Default::default()));
self.state
.broadcast_activity(&format!("Agent executed tool: {}", name));
let result: Result<String, String> = if let Some(tool) = self.tools.get(name) {
tool.execute(args, self.state.clone()).await
} else {
Err(format!("Unknown tool: {}", name))
};
match result {
Ok(text) => {
let payload = serde_json::json!({
"content": [{"type": "text", "text": text}],
"isError": false
});
Some(crate::mcp::success(id_clone, payload))
}
Err(e) => {
tracing::error!("Tool {} failed: {}", name, e);
let payload = serde_json::json!({
"content": [{"type": "text", "text": e}],
"isError": true
});
Some(crate::mcp::success(id_clone, payload))
}
}
}
m if m.starts_with("notifications/") => None,
"ping" => Some(crate::mcp::success(id, serde_json::json!({}))),
_ => {
if id.is_null() {
None
} else {
Some(crate::mcp::error(
id,
-32601,
&format!("Method {} not found", method),
))
}
}
}
}
}
+707
View File
@@ -0,0 +1,707 @@
use crate::router::McpTool;
use crate::state::MemoryState;
use crate::tools::ReadFileSkeletonTool;
use async_trait::async_trait;
use serde_json::Value;
use std::sync::Arc;
use tree_sitter::{Node, Parser};
fn validate_safe_path(path_str: &str) -> crate::error::Result<()> {
if path_str.trim().is_empty() || path_str.contains('\0') {
return Err(crate::error::AppError::Internal(
"Invalid file path: path is empty or contains null characters".to_string(),
));
}
let path = std::path::Path::new(path_str);
for component in path.components() {
if component == std::path::Component::ParentDir {
return Err(crate::error::AppError::Internal(format!(
"Path traversal forbidden: '{}' contains parent directory relative components",
path_str
)));
}
}
Ok(())
}
pub struct ReadFileSkeletonHandler;
#[async_trait]
impl McpTool for ReadFileSkeletonHandler {
fn name(&self) -> &'static str {
"read_file_skeleton"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ReadFileSkeletonTool>(
"read_file_skeleton",
"Read a source file and return only its AST structural skeleton, omitting implementation details to save tokens.",
)
}
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> {
let tool_args: ReadFileSkeletonTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let file_path = tool_args.file_path.clone();
validate_safe_path(&file_path)?;
let result = tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
let code = std::fs::read_to_string(&file_path).map_err(|e| {
crate::error::AppError::Internal(format!("Failed to read file: {}", e))
})?;
let ext = std::path::Path::new(&file_path)
.extension()
.and_then(|s| s.to_str())
.unwrap_or("");
let language = match ext {
"rs" => tree_sitter_rust::LANGUAGE,
"ts" | "tsx" | "js" | "jsx" => tree_sitter_typescript::LANGUAGE_TYPESCRIPT,
"py" => tree_sitter_python::LANGUAGE,
"java" => tree_sitter_java::LANGUAGE,
"c" | "h" => tree_sitter_c::LANGUAGE,
"cpp" | "cc" | "cxx" | "hpp" | "hxx" => tree_sitter_cpp::LANGUAGE,
"go" => tree_sitter_go::LANGUAGE,
_ => return Ok(code),
};
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&language.into())
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
let tree = parser.parse(&code, None).ok_or_else(|| {
crate::error::AppError::Internal("Failed to parse code".to_string())
})?;
let mut result_skeleton = String::with_capacity(code.len() / 2);
fn extract_skeleton(node: Node, code: &str, out: &mut String, depth: usize) {
if depth > 128 {
return;
}
let kind = node.kind();
let is_container = matches!(
kind,
"impl_item"
| "class_declaration"
| "class_definition"
| "trait_item"
| "interface_declaration"
);
let is_structural = is_container
|| matches!(
kind,
"use_declaration"
| "import_statement"
| "import_from_statement"
| "struct_item"
| "enum_item"
| "function_item"
| "function_declaration"
| "function_definition"
| "method_definition"
| "type_alias_declaration"
);
if is_container {
let indent = " ".repeat(depth);
let node_text = node.utf8_text(code.as_bytes()).unwrap_or("");
let mut header = String::new();
for line in node_text.lines() {
let trimmed = line.trim();
if trimmed.ends_with('{') || trimmed.ends_with(':') {
header.push_str(line);
break;
} else {
header.push_str(line);
header.push('\n');
}
}
if header.is_empty()
&& let Some(first_line) = node_text.lines().next()
{
header = first_line.to_string();
}
out.push_str(&indent);
out.push_str(header.trim());
out.push('\n');
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
extract_skeleton(child, code, out, depth + 1);
}
if header.trim().ends_with('{') {
out.push_str(&indent);
out.push_str("}\n");
}
} else if is_structural {
let indent = " ".repeat(depth);
let node_text = node.utf8_text(code.as_bytes()).unwrap_or("");
let mut signature = String::new();
for line in node_text.lines() {
let trimmed = line.trim();
if trimmed.ends_with('{') || trimmed.ends_with(':') {
signature.push_str(line);
signature.push_str(" ... }");
break;
} else {
signature.push_str(line);
signature.push('\n');
}
}
if signature.is_empty() {
signature = node_text.to_string();
}
out.push_str(&indent);
out.push_str(signature.trim());
out.push('\n');
} else if node.is_named() {
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
extract_skeleton(child, code, out, depth);
}
}
}
extract_skeleton(tree.root_node(), &code, &mut result_skeleton, 0);
if result_skeleton.is_empty() {
Ok(code)
} else {
Ok(result_skeleton)
}
})
.await
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??;
Ok(result)
}
}
use crate::tools::ReplaceAstNodeTool;
pub struct ReplaceAstNodeHandler;
#[async_trait]
impl McpTool for ReplaceAstNodeHandler {
fn name(&self) -> &'static str {
"replace_ast_node"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ReplaceAstNodeTool>(
"replace_ast_node",
"Replace a specific AST node (e.g., function, struct, enum, class, trait) entirely using tree-sitter for robust structural editing. Supported node_type values include: 'function_item' (or 'function'/'fn'/'method'), 'struct_item' (or 'struct'), 'class_declaration' (or 'class'), 'enum_item' (or 'enum'), 'trait_item' (or 'trait'/'interface'), 'type_alias_declaration' (or 'type').",
)
}
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> {
let tool_args: ReplaceAstNodeTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let file_path = tool_args.file_path.clone();
validate_safe_path(&file_path)?;
let result = tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
let code = std::fs::read_to_string(&file_path).map_err(|e| {
crate::error::AppError::Internal(format!("Failed to read file: {}", e))
})?;
let mut parser = Parser::new();
let ext = std::path::Path::new(&file_path)
.extension()
.and_then(|s| s.to_str())
.unwrap_or("");
let language = match ext {
"rs" => tree_sitter_rust::LANGUAGE,
"ts" | "tsx" | "js" | "jsx" => tree_sitter_typescript::LANGUAGE_TYPESCRIPT,
"py" => tree_sitter_python::LANGUAGE,
"java" => tree_sitter_java::LANGUAGE,
"c" | "h" => tree_sitter_c::LANGUAGE,
"cpp" | "cc" | "cxx" | "hpp" | "hxx" => tree_sitter_cpp::LANGUAGE,
"go" => tree_sitter_go::LANGUAGE,
_ => {
return Err(crate::error::AppError::Internal(format!(
"Unsupported language for AST replacement: {}",
ext
)));
}
};
parser
.set_language(&language.into())
.map_err(|e| e.to_string())?;
let tree = parser.parse(&code, None).ok_or_else(|| {
crate::error::AppError::Internal("Failed to parse code".to_string())
})?;
fn matches_node_type(actual_kind: &str, requested_type: &str) -> bool {
if actual_kind == requested_type {
return true;
}
match requested_type.to_lowercase().as_str() {
"function" | "func" | "fn" | "method" | "def" => matches!(
actual_kind,
"function_item"
| "function_declaration"
| "function_definition"
| "method_definition"
| "function"
),
"struct" => matches!(
actual_kind,
"struct_item" | "struct_declaration" | "struct_specifier"
),
"class" => matches!(
actual_kind,
"class_declaration" | "class_definition" | "class_item"
),
"enum" => matches!(actual_kind, "enum_item" | "enum_declaration"),
"trait" | "interface" => matches!(
actual_kind,
"trait_item" | "interface_declaration" | "interface_item"
),
"impl" | "impl_item" => actual_kind == "impl_item",
"type" | "type_alias" => matches!(
actual_kind,
"type_alias_declaration" | "type_item" | "type_definition"
),
_ => false,
}
}
// Search for the node
fn find_node<'a>(
node: Node<'a>,
code: &str,
target_type: &str,
target_name: &str,
) -> Option<Node<'a>> {
if matches_node_type(node.kind(), target_type) {
// Try to find the name/identifier
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
let kind = child.kind();
if kind == "identifier"
|| kind == "name"
|| kind == "type_identifier"
|| kind == "property_identifier"
|| kind == "field_identifier"
{
let name = child.utf8_text(code.as_bytes()).unwrap_or("");
if name == target_name {
return Some(node);
}
}
}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if let Some(found) = find_node(child, code, target_type, target_name) {
return Some(found);
}
}
None
}
let target_node = find_node(
tree.root_node(),
&code,
&tool_args.node_type,
&tool_args.node_name,
);
if let Some(node) = target_node {
let start_byte = node.start_byte();
let end_byte = node.end_byte();
if !code.is_char_boundary(start_byte) || !code.is_char_boundary(end_byte) {
return Err(crate::error::AppError::Internal(format!(
"Byte offsets {}..{} do not fall on UTF-8 character boundaries in {}",
start_byte, end_byte, file_path
)));
}
let mut new_file_content =
String::with_capacity(code.len() + tool_args.new_content.len());
new_file_content.push_str(&code[..start_byte]);
new_file_content.push_str(&tool_args.new_content);
new_file_content.push_str(&code[end_byte..]);
let target_path = std::path::PathBuf::from(&file_path);
let parent_dir = target_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."));
let temp_file_path =
parent_dir.join(format!(".tmp_ast_{}.tmp", uuid::Uuid::new_v4()));
std::fs::write(&temp_file_path, new_file_content).map_err(|e| e.to_string())?;
if let Err(e) = std::fs::rename(&temp_file_path, &target_path) {
// On Windows, std::fs::rename fails if the target file already exists.
// Fall back to copy-and-remove to ensure atomic-like overwrite behavior.
if let Err(copy_err) = std::fs::copy(&temp_file_path, &target_path) {
let _ = std::fs::remove_file(&temp_file_path);
return Err(crate::error::AppError::Internal(format!(
"Failed to atomically overwrite {}: rename failed ({}), copy failed ({})",
file_path, e, copy_err
)));
}
let _ = std::fs::remove_file(&temp_file_path);
}
Ok(format!(
"Successfully replaced node {} of type {} in {}",
tool_args.node_name, tool_args.node_type, file_path
))
} else {
Err(crate::error::AppError::Internal(format!(
"Could not find node {} of type {}",
tool_args.node_name, tool_args.node_type
)))
}
})
.await
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??;
Ok(result)
}
}
fn scan_workspace_for_symbol(
target_sym: &str,
limit: usize,
filter_fn_call: bool,
workspace_dir: Option<std::path::PathBuf>,
) -> Vec<serde_json::Value> {
let mut results = Vec::new();
let scan_dir = workspace_dir.unwrap_or_else(|| {
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
});
if !scan_dir.exists() {
return results;
}
let walker = ignore::WalkBuilder::new(&scan_dir)
.hidden(true)
.git_ignore(true)
.build();
let mut scanned_files = 0;
let call_pattern = format!("{}(", target_sym);
let await_pattern = format!("{}.await", target_sym);
for result in walker {
let entry = match result {
Ok(e) => e,
Err(_) => continue,
};
if entry.file_type().is_some_and(|ft| ft.is_file()) {
let path = entry.path();
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
if matches!(
ext,
"rs" | "ts"
| "tsx"
| "js"
| "jsx"
| "py"
| "go"
| "java"
| "c"
| "cpp"
| "h"
| "hpp"
) {
scanned_files += 1;
if scanned_files > 500 {
break;
}
if let Ok(meta) = std::fs::metadata(path)
&& meta.len() > 1024 * 1024
{
continue;
}
if let Ok(content) = std::fs::read_to_string(path) {
for (line_num, line) in content.lines().enumerate() {
let is_match = if filter_fn_call {
line.contains(&call_pattern) || line.contains(&await_pattern)
} else {
line.contains(target_sym)
};
if is_match {
results.push(serde_json::json!({
"file_path": path.to_string_lossy(),
"line": line_num + 1,
"content": line.trim(),
}));
if results.len() >= limit {
return results;
}
}
}
}
}
}
}
results
}
pub struct FindSymbolReferencesHandler;
#[async_trait]
impl McpTool for FindSymbolReferencesHandler {
fn name(&self) -> &'static str {
"find_symbol_references"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<crate::tools::FindSymbolReferencesTool>(
"find_symbol_references",
"Find all source locations and AST chunks where a specific symbol is referenced or called.",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: crate::tools::FindSymbolReferencesTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let limit = req.limit.unwrap_or(10);
let target_sym = req.symbol.clone();
let mut matches = state.code.snippets.read_with(|snippets| {
let mut refs = Vec::new();
for snippet in snippets {
if snippet.code.contains(&target_sym) || snippet.name.contains(&target_sym) {
refs.push(serde_json::json!({
"source": "snippet",
"name": snippet.name,
"code": snippet.code,
}));
if refs.len() >= limit {
break;
}
}
}
Ok::<Vec<serde_json::Value>, crate::error::AppError>(refs)
})?;
let custom_dir = req.workspace_dir.as_ref().map(std::path::PathBuf::from);
if matches.len() < limit {
let remaining = limit - matches.len();
let disk_matches = tokio::task::spawn_blocking(move || {
scan_workspace_for_symbol(&target_sym, remaining, false, custom_dir)
})
.await
.unwrap_or_default();
matches.extend(disk_matches);
}
Ok(serde_yaml::to_string(&matches)?)
}
}
pub struct GetCallersHandler;
#[async_trait]
impl McpTool for GetCallersHandler {
fn name(&self) -> &'static str {
"get_callers"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<crate::tools::GetCallersTool>(
"get_callers",
"Find all caller functions or methods that invoke a specified target function name.",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: crate::tools::GetCallersTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let limit = req.limit.unwrap_or(10);
let target_fn = req.function_name.clone();
let call_pattern = format!("{}(", target_fn);
let await_pattern = format!("{}.await", target_fn);
let mut callers = state.code.snippets.read_with(|snippets| {
let mut matching = Vec::new();
for snippet in snippets {
if snippet.code.contains(&call_pattern) || snippet.code.contains(&await_pattern) {
matching.push(serde_json::json!({
"source": "snippet",
"name": snippet.name,
"code": snippet.code,
}));
if matching.len() >= limit {
break;
}
}
}
Ok::<Vec<serde_json::Value>, crate::error::AppError>(matching)
})?;
let custom_dir = req.workspace_dir.as_ref().map(std::path::PathBuf::from);
if callers.len() < limit {
let remaining = limit - callers.len();
let disk_callers = tokio::task::spawn_blocking(move || {
scan_workspace_for_symbol(&target_fn, remaining, true, custom_dir)
})
.await
.unwrap_or_default();
callers.extend(disk_callers);
}
Ok(serde_yaml::to_string(&callers)?)
}
}
pub struct AnalyzeImpactHandler;
#[async_trait]
impl McpTool for AnalyzeImpactHandler {
fn name(&self) -> &'static str {
"analyze_impact"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<crate::tools::AnalyzeImpactTool>(
"analyze_impact",
"Analyze the potential downstream breaking impact of modifying a function, struct, or file.",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: crate::tools::AnalyzeImpactTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let sym = req.target_symbol.clone();
let mut callers = Vec::new();
state.code.snippets.read_with(|snippets| {
for snippet in snippets {
if snippet.code.contains(&sym) {
callers.push(snippet.name.clone());
}
}
});
let custom_dir = req
.file_path
.as_ref()
.and_then(|p| std::path::Path::new(p).parent().map(|p| p.to_path_buf()));
let sym_clone = sym.clone();
let disk_refs = tokio::task::spawn_blocking(move || {
scan_workspace_for_symbol(&sym_clone, 20, false, custom_dir)
})
.await
.unwrap_or_default();
for r in &disk_refs {
if let Some(path) = r.get("file_path").and_then(|p| p.as_str()) {
let line = r.get("line").and_then(|l| l.as_u64()).unwrap_or(0);
callers.push(format!("{}:{}", path, line));
}
}
let mut kg_connected = Vec::new();
state.read_graph(|g| {
for rel in &g.relations {
if rel.from == sym {
kg_connected.push(format!("Outgoing: {} -> {}", rel.relation_type, rel.to));
} else if rel.to == sym {
kg_connected.push(format!("Incoming: {} <- {}", rel.relation_type, rel.from));
}
}
});
let caller_count = callers.len();
let graph_count = kg_connected.len();
let risk_level = if caller_count > 10 || graph_count > 5 {
"CRITICAL"
} else if caller_count > 3 || graph_count > 2 {
"HIGH"
} else if caller_count > 0 || graph_count > 0 {
"MEDIUM"
} else {
"LOW"
};
let result = serde_json::json!({
"target_symbol": sym,
"risk_level": risk_level,
"ast_callers_count": caller_count,
"ast_callers_sample": callers.into_iter().take(10).collect::<Vec<_>>(),
"graph_relations_count": graph_count,
"graph_relations": kg_connected,
"recommendation": match risk_level {
"CRITICAL" | "HIGH" => "Requires comprehensive unit test verification and backwards compatibility checks before modifying.",
"MEDIUM" => "Verify direct call sites and run affected module tests.",
_ => "Safe to modify with standard unit test verification.",
}
});
Ok(serde_yaml::to_string(&result)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::tempdir;
#[tokio::test]
async fn test_read_file_skeleton() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let file_path = dir.path().join("test_skeleton.rs");
let code = "fn my_func() {\n let x = 1;\n}\n\nstruct MyStruct {\n val: i32\n}";
std::fs::write(&file_path, code).unwrap();
let handler = ReadFileSkeletonHandler;
let args = json!({
"file_path": file_path.to_str().unwrap()
});
let res = handler.execute(args, state.clone()).await.unwrap();
assert!(res.contains("fn my_func()"));
assert!(res.contains("struct MyStruct"));
}
#[tokio::test]
async fn test_replace_ast_node() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let file_path = dir.path().join("test_replace.rs");
let code = "fn my_func() {\n let x = 1;\n}\n\nstruct MyStruct {\n val: i32\n}";
std::fs::write(&file_path, code).unwrap();
let handler = ReplaceAstNodeHandler;
let args = json!({
"file_path": file_path.to_str().unwrap(),
"node_type": "function_item",
"node_name": "my_func",
"new_content": "fn my_func() {\n let x = 2;\n}"
});
let res = handler.execute(args, state.clone()).await.unwrap();
assert!(res.contains("Successfully replaced node"));
let new_code = std::fs::read_to_string(&file_path).unwrap();
assert!(new_code.contains("let x = 2;"));
assert!(!new_code.contains("let x = 1;"));
}
}
+264
View File
@@ -0,0 +1,264 @@
use crate::router::McpTool;
use crate::state::MemoryState;
use crate::tools::*;
use async_trait::async_trait;
use serde_json::Value;
use std::sync::Arc;
pub struct EnvironmentHandler;
#[async_trait]
impl McpTool for EnvironmentHandler {
fn name(&self) -> &'static str {
"environment"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<EnvironmentTool>(
"environment",
"Consolidated environment & infrastructure management",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: EnvironmentTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let ns = req
.namespace
.unwrap_or_else(crate::models::default_namespace);
match req.action {
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: detected_os,
shell: detected_shell,
tool_versions,
updated_at: crate::handlers::utils::now_secs(),
..Default::default()
},
);
});
Ok("Env fingerprint updated".to_string())
}
EnvAction::ReadFingerprint => {
let data = state
.env
.env_fingerprints
.read_with(|fps| fps.get(&ns).cloned());
if let Some(fp) = data {
let data = Ok::<String, crate::error::AppError>(serde_yaml::to_string(&fp)?)?;
Ok(data)
} else {
Ok("{}".to_string())
}
}
EnvAction::LogRequirement => {
let key = req.key.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'key' for action 'log_requirement'. Next step: Provide environment variable 'key' string in request and retry.".to_string())
})?;
let desc = req.description.unwrap_or_default();
let secret = req.is_secret.unwrap_or(false);
state.env.env_requirements.modify(|reqs| {
reqs.retain(|r| !(r.namespace == ns && r.key == key));
reqs.push(crate::models::EnvRequirement {
namespace: ns,
key,
description: desc,
is_secret: secret,
..Default::default()
});
});
Ok("Env requirement logged".to_string())
}
EnvAction::Register => {
let name = req.name.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'name' for action 'register'. Next step: Provide environment 'name' string in request and retry.".to_string())
})?;
let url = req.url.unwrap_or_default();
let desc = req.description.unwrap_or_default();
let vpn = req.requires_vpn.unwrap_or(false);
state.env.environments.modify(|envs| {
envs.retain(|e| !(e.namespace == ns && e.name == name));
envs.push(crate::models::EnvironmentDetail {
namespace: ns,
name,
url,
description: desc,
requires_vpn: vpn,
updated_at: crate::handlers::utils::now_secs(),
..Default::default()
});
});
Ok("Environment registered".to_string())
}
EnvAction::GetDetails => {
let data = state.env.environments.read_with(|envs| {
let filtered: Vec<_> = envs.iter().filter(|e| e.namespace == ns).collect();
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&filtered)?)
})?;
Ok(data)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::tempdir;
#[tokio::test]
async fn test_env_fingerprint() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let handler = EnvironmentHandler;
let args = json!({
"action": "update_fingerprint",
"namespace": "global",
"tool_versions": {
"rustc": "1.70.0"
}
});
let res = handler
.execute(args, state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert_eq!(res, "Env fingerprint updated");
let res2 = handler
.execute(
json!({"action": "read_fingerprint", "namespace": "global"}),
state.clone(),
)
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert!(res2.contains("rustc"));
assert!(res2.contains("1.70.0"));
}
#[tokio::test]
async fn test_env_details() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
// Ensure namespace is present in test setup
state.env.environments.modify(|e| {
e.push(crate::models::EnvironmentDetail {
namespace: "global".to_string(),
name: "test".to_string(),
url: "http://localhost".to_string(),
description: "Test env".to_string(),
requires_vpn: false,
updated_at: crate::handlers::utils::now_secs(),
..Default::default()
});
});
let handler = EnvironmentHandler;
let res = handler
.execute(
json!({"action": "get_details", "namespace": "global"}),
state.clone(),
)
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert!(res.contains("global"));
}
#[tokio::test]
async fn test_env_requirements_and_registration() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let handler = EnvironmentHandler;
let args_req = json!({
"action": "log_requirement",
"key": "vpn",
"description": "Requires VPN",
"is_secret": false,
"namespace": "global"
});
let res1 = handler
.execute(args_req, state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert_eq!(res1, "Env requirement logged");
let args_reg = json!({
"action": "register",
"name": "prod",
"url": "https://prod.local",
"description": "Production",
"requires_vpn": true,
"namespace": "global"
});
let res2 = handler
.execute(args_reg, state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert_eq!(res2, "Environment registered");
let res3 = handler
.execute(
json!({"action": "get_details", "namespace": "global"}),
state.clone(),
)
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert!(res3.contains("prod.local"));
assert!(!res3.is_empty());
}
#[tokio::test]
async fn test_empty_namespace_in_update_env_fingerprint() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let handler = EnvironmentHandler;
let args = serde_json::json!({
"action": "update_fingerprint",
"namespace": "",
"tool_versions": {
"rustc": "1.70.0"
}
});
let res = handler.execute(args, state.clone()).await;
assert!(res.is_ok());
}
}
+217
View File
@@ -0,0 +1,217 @@
use crate::router::McpTool;
use crate::state::MemoryState;
use crate::tools::GetActiveWorktreeContextTool;
use async_trait::async_trait;
use serde_json::{Value, json};
use std::env;
use std::sync::Arc;
pub struct GetActiveWorktreeContextHandler;
#[async_trait]
impl McpTool for GetActiveWorktreeContextHandler {
fn name(&self) -> &'static str {
"get_active_worktree_context"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<GetActiveWorktreeContextTool>(
"get_active_worktree_context",
"Get the active worktree context, including branch name, modified files, and a truncated git diff.",
)
}
async fn execute(
&self,
_args: Value,
_state: Arc<MemoryState>,
) -> crate::error::Result<String> {
let result =
tokio::task::spawn_blocking(move || -> crate::error::Result<serde_json::Value> {
let cwd = env::current_dir().map_err(|e| e.to_string())?;
let repo = git2::Repository::discover(&cwd).map_err(|e| {
crate::error::AppError::Internal(format!("Not in a git repository: {}", e))
})?;
let mut branch_name = String::new();
if let Ok(head) = repo.head()
&& let Some(name) = head.shorthand()
{
branch_name = name.to_string();
}
let mut opts = git2::DiffOptions::new();
let mut diff = None;
// Try to diff against HEAD
if let Ok(tree) = repo.head().and_then(|h| h.peel_to_tree()) {
diff = repo
.diff_tree_to_workdir_with_index(Some(&tree), Some(&mut opts))
.ok();
}
let mut files = Vec::new();
let mut diff_output = String::new();
if let Some(diff) = diff {
let _ = diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
match line.origin() {
'+' | '-' | ' ' => diff_output.push(line.origin()),
_ => {}
}
let content = std::str::from_utf8(line.content()).unwrap_or("");
diff_output.push_str(content);
true
});
for delta in diff.deltas() {
if let Some(path) = delta.new_file().path() {
files.push(path.to_string_lossy().into_owned());
}
}
}
// Truncate diff output if it's too large to save tokens
if diff_output.len() > 10000 {
let valid_boundary = diff_output.floor_char_boundary(10000);
diff_output.truncate(valid_boundary);
diff_output.push_str("\n... [Diff truncated due to size]");
}
Ok(json!({
"branch": branch_name,
"modified_files": files,
"diff": diff_output
}))
})
.await
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??;
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&result)?)
}
}
pub struct QueryGitDiffsHandler;
#[async_trait]
impl McpTool for QueryGitDiffsHandler {
fn name(&self) -> &'static str {
"query_git_diffs"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<crate::tools::QueryGitDiffsTool>(
"query_git_diffs",
"Query recent git commit history, diffs, and change ledger entries.",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: crate::tools::QueryGitDiffsTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let limit = req.limit.unwrap_or(5);
let q = req.query.to_lowercase();
let mut matches = Vec::new();
state.code.ledger.read_with(|ledger| {
for change in ledger {
if change.file_path.to_lowercase().contains(&q)
|| change.description.to_lowercase().contains(&q)
|| change.git_commit.as_ref().is_some_and(|c| c.contains(&q))
{
matches.push(json!({
"file_path": change.file_path,
"description": change.description,
"commit": change.git_commit,
"branch": change.git_branch,
"timestamp": change.timestamp,
}));
if matches.len() >= limit {
break;
}
}
}
});
if matches.len() < limit {
let remaining = limit - matches.len();
let git_matches = tokio::task::spawn_blocking(move || {
let mut results = Vec::new();
let cwd = env::current_dir().unwrap_or_default();
if let Ok(repo) = git2::Repository::discover(&cwd)
&& let Ok(mut revwalk) = repo.revwalk()
{
let _ = revwalk.push_head();
let mut count = 0;
for oid in revwalk.flatten() {
if count >= remaining {
break;
}
if let Ok(commit) = repo.find_commit(oid) {
let summary = commit.summary().unwrap_or("");
if summary.to_lowercase().contains(&q) {
count += 1;
results.push(json!({
"commit_id": oid.to_string(),
"author": commit.author().name().unwrap_or("unknown"),
"message": summary,
"timestamp": commit.time().seconds(),
}));
}
}
}
}
results
})
.await
.unwrap_or_default();
matches.extend(git_matches);
}
Ok(serde_yaml::to_string(&matches)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::sync::Arc;
use tempfile::tempdir;
#[tokio::test]
async fn test_get_active_worktree_context() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let handler = GetActiveWorktreeContextHandler;
let result = handler
.execute(json!({}), state)
.await
.map_err(|e| format!("Failed to get worktree context: {}", e))
.unwrap();
let parsed: serde_json::Value = serde_yaml::from_str(&result).unwrap();
assert!(parsed.get("branch").is_some());
assert!(parsed.get("modified_files").is_some());
assert!(parsed.get("diff").is_some());
}
#[tokio::test]
async fn test_get_active_worktree_context_empty_git_repo() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let handler = GetActiveWorktreeContextHandler;
let result = handler
.execute(serde_json::json!({}), state)
.await
.map_err(|e| format!("Failed to get worktree context: {}", e))
.unwrap();
let parsed: serde_json::Value = serde_yaml::from_str(&result).unwrap();
assert!(parsed.get("branch").is_some() || parsed.is_object());
}
}
File diff suppressed because it is too large. Load diff
+182
View File
@@ -0,0 +1,182 @@
use crate::router::McpTool;
use crate::state::MemoryState;
use crate::tools::{ProcessLogAction, ProcessLogsTool};
use async_trait::async_trait;
use serde_json::Value;
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::sync::Arc;
pub struct ProcessLogsHandler;
#[async_trait]
impl McpTool for ProcessLogsHandler {
fn name(&self) -> &'static str {
"process_logs"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ProcessLogsTool>(
"process_logs",
"Monitor, tail, and manage process logs: watch a log file, tail recent output, or clear log files.",
)
}
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> {
let tool_args: ProcessLogsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let safe_path = crate::handlers::utils::validate_safe_path(&tool_args.file_path)?;
match tool_args.action {
ProcessLogAction::Watch => {
if !safe_path.exists() {
return Err(crate::error::AppError::Internal(format!(
"File does not exist: {}",
tool_args.file_path
)));
}
Ok(format!("Started watching logs for {}", tool_args.file_path))
}
ProcessLogAction::Get => {
let max_lines = tool_args.max_lines.unwrap_or(100);
let result =
tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
let mut file = File::open(&safe_path).map_err(|e| {
crate::error::AppError::Internal(format!("Failed to open file: {}", e))
})?;
let len = file.metadata().map_err(|e| e.to_string())?.len();
let read_size = std::cmp::min(16 * 1024, len);
file.seek(SeekFrom::End(-(read_size as i64)))
.map_err(|e| e.to_string())?;
let mut vec_buf = Vec::new();
file.read_to_end(&mut vec_buf).map_err(|e| e.to_string())?;
let buffer = String::from_utf8_lossy(&vec_buf).to_string();
let lines: Vec<&str> = buffer.lines().collect();
let recent_lines = if lines.len() > max_lines {
lines[lines.len() - max_lines..].join("\n")
} else {
buffer
};
Ok(recent_lines)
})
.await
.map_err(|e| {
crate::error::AppError::Internal(format!("Task panic: {}", e))
})??;
Ok(result)
}
ProcessLogAction::Clear => {
if safe_path.exists() {
std::fs::write(&safe_path, "").map_err(|e| {
crate::error::AppError::Internal(format!("Failed to clear log file: {}", e))
})?;
}
Ok(format!("Cleared logs in {}", tool_args.file_path))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::sync::Arc;
use tempfile::tempdir;
#[tokio::test]
async fn test_process_logs_watch() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let handler = ProcessLogsHandler;
let log_file = dir.path().join("test.log");
std::fs::write(&log_file, "line1\nline2").unwrap();
let args = json!({
"action": "watch",
"file_path": log_file.to_str().unwrap()
});
let result = handler
.execute(args, state)
.await
.map_err(|e| format!("Failed to watch logs: {}", e))
.unwrap();
assert!(result.contains("Started watching logs"));
}
#[tokio::test]
async fn test_process_logs_get() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let handler = ProcessLogsHandler;
let log_file = dir.path().join("test_recent.log");
std::fs::write(&log_file, "line1\nline2\nline3").unwrap();
let args = json!({
"action": "get",
"file_path": log_file.to_str().unwrap()
});
let result = handler
.execute(args, state)
.await
.map_err(|e| format!("Failed to get recent logs: {}", e))
.unwrap();
assert!(result.contains("line1"));
assert!(result.contains("line3"));
}
#[tokio::test]
async fn test_process_logs_clear() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let handler = ProcessLogsHandler;
let log_file = dir.path().join("test_clear.log");
std::fs::write(&log_file, "line1\nline2\nline3").unwrap();
let args = json!({
"action": "clear",
"file_path": log_file.to_str().unwrap()
});
let result = handler.execute(args, state.clone()).await.unwrap();
assert!(result.contains("Cleared logs"));
let content = std::fs::read_to_string(&log_file).unwrap();
assert_eq!(content, "");
}
#[tokio::test]
async fn test_process_logs_get_with_large_file() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let handler = ProcessLogsHandler;
let log_file = dir.path().join("large_test.log");
let mut buffer = String::new();
for _ in 0..1000 {
buffer.push_str("line\n");
}
std::fs::write(&log_file, buffer).unwrap();
let args = serde_json::json!({
"action": "get",
"file_path": log_file.to_str().unwrap()
});
let result = handler
.execute(args, state)
.await
.map_err(|e| format!("Failed to get recent logs: {}", e))
.unwrap();
assert!(result.contains("line"));
}
}
File diff suppressed because it is too large. Load diff
@@ -1,7 +1,12 @@
pub mod ast;
pub mod env;
pub mod git;
pub mod graph;
pub mod logs;
pub mod meta;
pub mod notes;
pub mod reconciliation;
pub mod tasks;
pub mod utils;
pub mod vision;
pub mod workspaces;
+286
View File
@@ -0,0 +1,286 @@
use crate::models::*;
use crate::router::McpTool;
use crate::state::MemoryState;
use crate::tools::*;
use async_trait::async_trait;
use serde_json::Value;
use std::collections::HashSet;
use std::sync::Arc;
pub struct HandoffMemosHandler;
#[async_trait]
impl McpTool for HandoffMemosHandler {
fn name(&self) -> &'static str {
"handoff_memos"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<HandoffMemosTool>(
"handoff_memos",
"Handoff memos across sessions (leave, read, clear)",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: HandoffMemosTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
match req.action {
HandoffMemoAction::Leave => {
let content = req.content.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'content' for action 'leave'. Next step: Provide non-empty 'content' string in request and retry.".to_string())
})?;
let ns = req
.namespace
.unwrap_or_else(crate::models::default_namespace);
state.telemetry.handoff_memos.modify(|memos| {
memos.push(crate::models::HandoffMemo {
id: uuid::Uuid::new_v4().to_string(),
author: "agy".to_string(),
content,
namespace: ns,
timestamp: crate::handlers::utils::now_secs(),
expires_at: None,
..Default::default()
});
if memos.len() > 200 {
memos.remove(0);
}
});
Ok("Handoff memo left.".to_string())
}
HandoffMemoAction::Read => {
let data = state.telemetry.handoff_memos.read_with(|items| {
let filtered: Vec<_> = items
.iter()
.filter(|i| {
if let Some(ns) = &req.namespace {
&i.namespace == ns
} else {
true
}
})
.collect();
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&filtered)?)
})?;
Ok(data)
}
HandoffMemoAction::Clear => {
let ids: HashSet<_> = req.ids.unwrap_or_default().into_iter().collect();
state.telemetry.handoff_memos.modify(|memos| {
if ids.is_empty() {
memos.clear();
} else {
memos.retain(|m| !ids.contains(&m.id));
}
});
Ok("Handoff memos cleared.".to_string())
}
}
}
}
pub struct AddSessionSummaryHandler;
#[async_trait]
impl McpTool for AddSessionSummaryHandler {
fn name(&self) -> &'static str {
"add_session_summary"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<AddSessionSummaryTool>(
"add_session_summary",
"Execute add_session_summary",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: AddSessionSummaryTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
state.telemetry.session_summaries.modify(|summaries| {
summaries.push(crate::models::SessionSummary {
summary: req.summary,
namespace: req.namespace,
timestamp: crate::handlers::utils::now_secs(),
expires_at: None,
session_id: req.session_id,
repo_name: req.repo_name,
git_branch: req.git_branch,
git_commit: req.git_commit,
tasks_completed: req.tasks_completed.unwrap_or_default(),
next_steps: req.next_steps.unwrap_or_default(),
});
if summaries.len() > 200 {
let now = crate::handlers::utils::now_secs();
if let Some((idx_to_remove, _)) =
summaries.iter().enumerate().min_by_key(|(_, s)| {
let is_expired = s.expires_at.map(|exp| exp < now).unwrap_or(false);
let status_score = if is_expired { 0 } else { 1 };
(status_score, s.timestamp)
})
{
summaries.remove(idx_to_remove);
}
}
});
Ok("Session summary added".to_string())
}
}
pub struct GenerateStandupReportHandler;
#[async_trait]
impl McpTool for GenerateStandupReportHandler {
fn name(&self) -> &'static str {
"generate_standup_report"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<GenerateStandupReportTool>(
"generate_standup_report",
"Execute generate_standup_report",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: GenerateStandupReportTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let cutoff = crate::handlers::utils::now_secs().saturating_sub(req.hours_lookback * 3600);
let filtered_tasks = state.project.tasks.read_with(|items| {
items
.iter()
.filter(|t| t.updated_at >= cutoff)
.cloned()
.collect::<Vec<_>>()
});
let filtered_changes = state.code.ledger.read_with(|changes| {
changes
.iter()
.filter(|c| c.timestamp >= cutoff)
.cloned()
.collect::<Vec<_>>()
});
let filtered_summaries = state.telemetry.session_summaries.read_with(|summaries| {
summaries
.iter()
.filter(|s| s.namespace == req.namespace && s.timestamp >= cutoff)
.cloned()
.collect::<Vec<_>>()
});
let report = serde_json::json!({
"tasks_updated": filtered_tasks,
"code_changes": filtered_changes,
"session_summaries": filtered_summaries
});
let report_str = report.to_string();
Ok(report_str)
}
}
pub struct PromoteToEntityHandler;
#[async_trait]
impl McpTool for PromoteToEntityHandler {
fn name(&self) -> &'static str {
"promote_to_entity"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<PromoteToEntityTool>(
"promote_to_entity",
"Promote a task observation or finding into a permanent Knowledge Graph entity.",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: PromoteToEntityTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let entity_name = req.entity_name.clone();
let obs = req.content.clone();
let entity_type = req.entity_type.clone();
let namespace = req.namespace.clone();
state.modify_graph(|g| {
let entity = g
.entities
.entry(entity_name.clone())
.or_insert_with(|| Entity {
name: entity_name.clone(),
entity_type,
observations: Vec::new(),
namespace,
git_branch: None,
..Default::default()
});
if !entity.observations.contains(&obs) {
entity.observations.push(obs.clone());
}
});
Ok(format!(
"Promoted transient note into permanent entity '{}'.",
req.entity_name
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::tempdir;
#[tokio::test]
async fn test_handoff_and_summaries() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let handoff_handler = HandoffMemosHandler;
let args = json!({
"action": "leave",
"content": "Finished implementing graph tests",
"namespace": "global"
});
let res = handoff_handler
.execute(args, state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert_eq!(res, "Handoff memo left.");
let res2 = handoff_handler
.execute(
json!({"action": "read", "namespace": "global"}),
state.clone(),
)
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert!(res2.contains("Finished implementing graph tests"));
let summary_handler = AddSessionSummaryHandler;
let args_sum = json!({
"summary": "Completed a bunch of tests",
"namespace": "global"
});
let res3 = summary_handler
.execute(args_sum, state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert_eq!(res3, "Session summary added");
let standup_handler = GenerateStandupReportHandler;
let res4 = standup_handler
.execute(
json!({"namespace": "global", "hours_lookback": 24}),
state.clone(),
)
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert!(!res4.is_empty());
}
}
+554
View File
@@ -0,0 +1,554 @@
use crate::models::{Adr, TaskEvent};
use crate::state::MemoryState;
use std::sync::Arc;
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, Default)]
pub struct GitCommitPayload {
pub commit: String,
#[serde(default)]
pub branch: Option<String>,
pub message: String,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub files: Vec<String>,
#[serde(default)]
pub repo_name: Option<String>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct ReconciliationResult {
pub implemented_adrs: Vec<String>,
pub resolved_tech_debts: Vec<String>,
pub completed_tasks: Vec<String>,
pub unblocked_tasks: Vec<String>,
pub updated_milestones: Vec<String>,
}
/// Extracts ADR identifiers such as "ADR-0102", "ADR-102", "adr-005", "ADR: 102"
pub fn extract_adrs(text: &str) -> Vec<String> {
let mut adrs = Vec::new();
let lower = text.to_lowercase();
let chars: Vec<char> = text.chars().collect();
let lower_chars: Vec<char> = lower.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
// Look for "adr"
if i + 3 <= len && lower_chars[i..i + 3] == ['a', 'd', 'r'] {
let mut cursor = i + 3;
// Skip optional '-', ':', or whitespace
while cursor < len
&& (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ')
{
cursor += 1;
}
// Collect consecutive digits
let digit_start = cursor;
while cursor < len && chars[cursor].is_ascii_digit() {
cursor += 1;
}
if cursor > digit_start {
let num_str: String = chars[digit_start..cursor].iter().collect();
if let Ok(num) = num_str.parse::<u32>() {
let formatted = format!("ADR-{:04}", num);
if !adrs.contains(&formatted) {
adrs.push(formatted);
}
let raw_formatted = format!("ADR-{}", num_str);
if !adrs.contains(&raw_formatted) {
adrs.push(raw_formatted);
}
}
}
i = cursor;
} else {
i += 1;
}
}
adrs
}
/// Extracts TechDebt identifiers such as "TD-001", "td-auth-cleanup", "TD: 12"
pub fn extract_tech_debts(text: &str) -> Vec<String> {
let mut debts = Vec::new();
let lower = text.to_lowercase();
let chars: Vec<char> = text.chars().collect();
let lower_chars: Vec<char> = lower.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
if i + 2 <= len && lower_chars[i..i + 2] == ['t', 'd'] {
let mut cursor = i + 2;
while cursor < len
&& (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ')
{
cursor += 1;
}
let start = cursor;
while cursor < len
&& (chars[cursor].is_ascii_alphanumeric()
|| chars[cursor] == '_'
|| chars[cursor] == '-')
{
cursor += 1;
}
if cursor > start {
let id_part: String = chars[start..cursor].iter().collect();
let full_id = format!("TD-{}", id_part);
if !debts.contains(&full_id) {
debts.push(full_id);
}
if !debts.contains(&id_part) {
debts.push(id_part);
}
}
i = cursor;
} else {
i += 1;
}
}
debts
}
/// Extracts Task identifiers such as "TASK-123", "task-01", or UUID strings
pub fn extract_tasks(text: &str) -> Vec<String> {
let mut tasks = Vec::new();
let lower = text.to_lowercase();
let chars: Vec<char> = text.chars().collect();
let lower_chars: Vec<char> = lower.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
if i + 4 <= len && lower_chars[i..i + 4] == ['t', 'a', 's', 'k'] {
let mut cursor = i + 4;
while cursor < len
&& (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ')
{
cursor += 1;
}
let start = cursor;
while cursor < len
&& (chars[cursor].is_ascii_alphanumeric()
|| chars[cursor] == '_'
|| chars[cursor] == '-')
{
cursor += 1;
}
if cursor > start {
let id_part: String = chars[start..cursor].iter().collect();
let full_id = format!("TASK-{}", id_part);
if !tasks.contains(&full_id) {
tasks.push(full_id);
}
if !tasks.contains(&id_part) {
tasks.push(id_part);
}
}
i = cursor;
} else {
i += 1;
}
}
tasks
}
fn adr_numeric_id(id: &str) -> Option<u32> {
let digits: String = id.chars().filter(|c| c.is_ascii_digit()).collect();
digits.parse::<u32>().ok()
}
/// Automatically cascades unblocking to tasks whose dependencies have all completed.
pub fn cascade_unblock_tasks(state: &MemoryState) -> Vec<String> {
let mut unblocked = Vec::new();
let tasks_snapshot = state.project.tasks.read_with(|tasks| tasks.clone());
state.project.tasks.modify(|tasks| {
for t in tasks.iter_mut() {
if t.status.eq_ignore_ascii_case("blocked") {
if t.dependencies.is_empty() {
t.status = "active".to_string();
t.updated_at = crate::handlers::utils::now_secs();
unblocked.push(t.id.clone());
} else {
let all_deps_completed = t.dependencies.iter().all(|dep_id| {
tasks_snapshot.iter().any(|other| {
(other.id == *dep_id || other.title == *dep_id) && !other.is_active()
})
});
if all_deps_completed {
t.status = "active".to_string();
t.updated_at = crate::handlers::utils::now_secs();
unblocked.push(t.id.clone());
}
}
}
}
});
for tid in &unblocked {
state.record_activity(
"task_update",
&format!("Auto-unblocked Task {} (all dependencies met)", tid),
Some("active"),
);
state.broadcast_task_event(TaskEvent {
task_id: tid.clone(),
status: "active".to_string(),
action: Some("cascade_unblock".to_string()),
result: Some(serde_json::json!({ "status": "active" })),
error: None,
timestamp: crate::handlers::utils::now_secs(),
session_id: None,
..Default::default()
});
}
unblocked
}
/// Automatically recalculates milestone progress and status based on task completion.
pub fn recalculate_milestones(state: &MemoryState) -> Vec<String> {
let mut updated = Vec::new();
let tasks_snapshot = state.project.tasks.read_with(|tasks| tasks.clone());
state.project.milestones.modify(|milestones| {
for m in milestones.iter_mut() {
if m.task_ids.is_empty() {
continue;
}
let total = m.task_ids.len();
let completed = m
.task_ids
.iter()
.filter(|tid| {
tasks_snapshot
.iter()
.any(|t| (t.id == **tid || t.title == **tid) && !t.is_active())
})
.count();
if completed == total {
if !m.status.eq_ignore_ascii_case("completed") {
m.status = "completed".to_string();
if m.completed_at.is_none() {
m.completed_at = Some(crate::handlers::utils::now_secs());
}
updated.push(m.id.clone());
}
} else if (completed > 0
|| tasks_snapshot.iter().any(|t| {
m.task_ids.iter().any(|tid| tid == &t.id || tid == &t.title)
&& (t.status == "in_progress" || t.status == "active")
}))
&& !m.status.eq_ignore_ascii_case("in_progress")
&& !m.status.eq_ignore_ascii_case("completed")
{
m.status = "in_progress".to_string();
updated.push(m.id.clone());
}
}
});
for mid in &updated {
state.record_activity(
"milestone",
&format!("Milestone {} status updated via task progress", mid),
None,
);
}
updated
}
/// Reconciles state deterministically from git commit messages or MCP `log_code_change` calls.
pub async fn reconcile_commit_or_code_change(
state: &Arc<MemoryState>,
text: &str,
file_path: Option<&str>,
git_commit: Option<&str>,
git_branch: Option<&str>,
) -> ReconciliationResult {
let mut result = ReconciliationResult::default();
let now = crate::handlers::utils::now_secs();
let text_lower = text.to_lowercase();
// 1. Reconcile ADRs
let adr_candidates = extract_adrs(text);
if !adr_candidates.is_empty() {
let mut adrs_to_index: Vec<Adr> = Vec::new();
state.code.adrs.modify(|adrs| {
for adr in adrs.iter_mut() {
let matches = adr_candidates.iter().any(|cand| {
if adr.id.eq_ignore_ascii_case(cand) {
return true;
}
if let (Some(n1), Some(n2)) = (adr_numeric_id(&adr.id), adr_numeric_id(cand))
&& n1 == n2
{
return true;
}
false
});
if matches && !adr.status.eq_ignore_ascii_case("implemented") {
adr.status = "implemented".to_string();
adr.resolved_at = Some(now);
if let Some(rev) = git_commit {
adr.git_commit = Some(rev.to_string());
}
if let Some(br) = git_branch {
adr.git_branch = Some(br.to_string());
}
result.implemented_adrs.push(adr.id.clone());
adrs_to_index.push(adr.clone());
}
}
});
if !adrs_to_index.is_empty() {
let idx = state.get_search_index().await;
for a in &adrs_to_index {
drop(idx.index_adr(a));
state.record_activity(
"decision",
&format!("Auto-implemented ADR {}: {}", a.id, a.title),
Some("implemented"),
);
}
}
}
// 2. Reconcile Tech Debts
let td_candidates = extract_tech_debts(text);
let mentions_debt_resolution = text_lower.contains("fix")
|| text_lower.contains("resolve")
|| text_lower.contains("refactor")
|| text_lower.contains("clean")
|| text_lower.contains("address");
state.code.tech_debts.modify(|debts| {
for d in debts.iter_mut() {
if d.is_resolved {
continue;
}
let matches_id = td_candidates
.iter()
.any(|cand| d.id.eq_ignore_ascii_case(cand));
let matches_file = file_path
.map(|fp| d.file_path.as_deref() == Some(fp))
.unwrap_or(false)
&& mentions_debt_resolution
&& (text_lower.contains("debt") || text_lower.contains("td-"));
if matches_id || matches_file {
d.is_resolved = true;
if let Some(rev) = git_commit {
d.git_commit = Some(rev.to_string());
}
if let Some(br) = git_branch {
d.git_branch = Some(br.to_string());
}
result.resolved_tech_debts.push(d.id.clone());
}
}
});
for tdid in &result.resolved_tech_debts {
state.record_activity(
"tech_debt",
&format!("Auto-resolved TechDebt {} via commit", tdid),
Some("resolved"),
);
}
// 3. Reconcile Tasks
let task_candidates = extract_tasks(text);
let mentions_task_close = text_lower.contains("fix")
|| text_lower.contains("close")
|| text_lower.contains("complete")
|| text_lower.contains("resolve")
|| text_lower.contains("finish")
|| text_lower.contains("implement");
if mentions_task_close && !task_candidates.is_empty() {
state.project.tasks.modify(|tasks| {
for t in tasks.iter_mut() {
if !t.is_active() {
continue;
}
let matches = task_candidates.iter().any(|cand| {
t.id.eq_ignore_ascii_case(cand)
|| cand.trim_start_matches("TASK-").eq_ignore_ascii_case(&t.id)
});
if matches {
t.status = "completed".to_string();
t.updated_at = now;
result.completed_tasks.push(t.id.clone());
}
}
});
for tid in &result.completed_tasks {
state.record_activity(
"task_update",
&format!("Auto-completed Task {} via commit", tid),
Some("completed"),
);
state.broadcast_task_event(TaskEvent {
task_id: tid.clone(),
status: "completed".to_string(),
action: Some("commit_reconciliation".to_string()),
result: Some(serde_json::json!({ "status": "completed" })),
error: None,
timestamp: now,
session_id: None,
..Default::default()
});
}
}
// 4. Cascade Unblock & Recalculate Milestones if any tasks completed
if !result.completed_tasks.is_empty() {
result.unblocked_tasks = cascade_unblock_tasks(state);
result.updated_milestones = recalculate_milestones(state);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{Milestone, Task};
use tempfile::tempdir;
#[test]
fn test_extract_adrs() {
let text = "feat(api): implement ADR-0102 and ADR-103 with pagination";
let adrs = extract_adrs(text);
assert!(adrs.contains(&"ADR-0102".to_string()));
assert!(adrs.contains(&"ADR-0103".to_string()));
}
#[test]
fn test_extract_tech_debts() {
let text = "refactor: resolve TD-cleanup_old_buffers and TD-002";
let debts = extract_tech_debts(text);
assert!(debts.contains(&"TD-cleanup_old_buffers".to_string()));
assert!(debts.contains(&"TD-002".to_string()));
}
#[test]
fn test_extract_tasks() {
let text = "fix(core): close task-551 and TASK-abc";
let tasks = extract_tasks(text);
assert!(tasks.contains(&"TASK-551".to_string()));
assert!(tasks.contains(&"TASK-abc".to_string()));
}
#[tokio::test]
async fn test_reconcile_adr_implementation() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
state.code.adrs.modify(|adrs| {
adrs.push(Adr {
id: "ADR-0102".to_string(),
title: "Dynamic Neovim Projection Buffer".to_string(),
status: "accepted".to_string(),
..Default::default()
});
});
let result = reconcile_commit_or_code_change(
&state,
"feat: implement ADR-102 projection buffer",
None,
Some("commit123"),
Some("master"),
)
.await;
assert_eq!(result.implemented_adrs, vec!["ADR-0102"]);
let adr = state.code.adrs.read_with(|adrs| adrs[0].clone());
assert_eq!(adr.status, "implemented");
assert_eq!(adr.git_commit.as_deref(), Some("commit123"));
assert_eq!(adr.git_branch.as_deref(), Some("master"));
assert!(adr.resolved_at.is_some());
}
#[tokio::test]
async fn test_cascade_unblock_and_milestone_recalculation() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let task1 = Task {
id: "task-1".to_string(),
title: "Task 1".to_string(),
status: "active".to_string(),
..Default::default()
};
let task2 = Task {
id: "task-2".to_string(),
title: "Task 2".to_string(),
status: "blocked".to_string(),
dependencies: vec!["task-1".to_string()],
..Default::default()
};
state.project.tasks.modify(|t| {
t.push(task1);
t.push(task2);
});
let ms = Milestone {
id: "ms-1".to_string(),
title: "Sprint 1".to_string(),
status: "pending".to_string(),
task_ids: vec!["task-1".to_string(), "task-2".to_string()],
..Default::default()
};
state.project.milestones.modify(|m| m.push(ms));
// Complete task-1 via commit message
let result = reconcile_commit_or_code_change(
&state,
"fix: close task-1 core functionality",
None,
Some("c1"),
Some("master"),
)
.await;
assert_eq!(result.completed_tasks, vec!["task-1"]);
assert_eq!(result.unblocked_tasks, vec!["task-2"]);
assert_eq!(result.updated_milestones, vec!["ms-1"]);
// Task 2 should now be active
let t2 = state.project.tasks.read_with(|t| t[1].clone());
assert_eq!(t2.status, "active");
// Milestone should now be in_progress
let m = state.project.milestones.read_with(|m| m[0].clone());
assert_eq!(m.status, "in_progress");
// Now complete task-2
let result2 = reconcile_commit_or_code_change(
&state,
"fix: close task-2 follow-up",
None,
Some("c2"),
Some("master"),
)
.await;
assert_eq!(result2.completed_tasks, vec!["task-2"]);
let m2 = state.project.milestones.read_with(|m| m[0].clone());
assert_eq!(m2.status, "completed");
assert!(m2.completed_at.is_some());
}
}
+739
View File
@@ -0,0 +1,739 @@
use crate::models::*;
use crate::router::McpTool;
use crate::state::MemoryState;
use crate::tools::*;
use async_trait::async_trait;
use serde_json::Value;
use std::sync::Arc;
pub struct TasksHandler;
#[async_trait]
impl McpTool for TasksHandler {
fn name(&self) -> &'static str {
"tasks"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<TasksTool>(
"tasks",
"Consolidated task management (add, update, delete, list, set_criteria, verify)",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: TasksTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
match req.action {
TaskAction::Add => {
let title = req.title.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'title' for action 'add'. Next step: Provide non-empty 'title' string in request and retry.".to_string())
})?;
let description = req.description.unwrap_or_default();
let now = crate::handlers::utils::now_secs();
let task_id = uuid::Uuid::new_v4().to_string();
let deps = req.dependencies.unwrap_or_default();
let task = Task {
id: task_id.clone(),
title,
status: "pending".to_string(),
description,
created_at: now,
updated_at: now,
git_branch: req.git_branch,
parent_id: req.parent_id,
dependencies: deps,
acceptance_criteria: vec![],
expires_at: None,
namespace: req.namespace.unwrap_or_else(|| "default".to_string()),
repo_name: req.repo_name,
priority: req.priority,
assigned_agent: req.assigned_agent,
verification_command: req.verification_command,
};
let idx = state.get_search_index().await;
drop(idx.index_task(&task));
state.project.tasks.modify(|tasks| {
tasks.push(task.clone());
});
state.record_activity(
"task_create",
&format!("Created task: {}", task.title),
Some(&task.description),
);
state.broadcast_task_event(TaskEvent {
task_id: task_id.clone(),
status: "created".to_string(),
action: Some("add".to_string()),
result: Some(
serde_json::json!({ "title": task.title, "git_branch": task.git_branch }),
),
error: None,
timestamp: now,
session_id: None,
..Default::default()
});
Ok(format!("Task added with ID: {}", task_id))
}
TaskAction::Update => {
let id = req.id.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'id' for action 'update'. Next step: Provide task 'id' string in request and retry.".to_string())
})?;
let status = req.status.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'status' for action 'update'. Next step: Provide valid 'status' ('pending', 'active', 'in_progress', 'blocked', 'completed', or 'cancelled') in request and retry.".to_string())
})?;
let target_status = status.to_lowercase();
let mut found = false;
let mut blocked = false;
let mut blocker_details = String::new();
let mut updated_task: Option<Task> = None;
state.project.tasks.modify(|tasks| {
let target_idx = tasks.iter().position(|t| t.id == id || t.title == id);
let target_idx = match target_idx {
Some(i) => i,
None => return,
};
found = true;
if target_status == "done" || target_status == "completed" {
if tasks[target_idx]
.acceptance_criteria
.iter()
.any(|c| !c.is_met)
{
blocked = true;
blocker_details = "Unmet acceptance criteria exist.".to_string();
}
if !blocked {
let mut uncompleted_deps = Vec::new();
for dep_id in &tasks[target_idx].dependencies {
if let Some(dep_task) = tasks.iter().find(|dt| dt.id == *dep_id)
&& dep_task.status != "completed"
&& dep_task.status != "done"
&& dep_task.status != "cancelled"
{
uncompleted_deps.push(dep_task.title.as_str());
}
}
if !uncompleted_deps.is_empty() {
blocked = true;
blocker_details = format!(
"Blocked by dependencies: {}",
uncompleted_deps.join(", ")
);
}
}
if !blocked {
let target_id_ref = tasks[target_idx].id.as_str();
let mut uncompleted_children = Vec::new();
for child in tasks
.iter()
.filter(|t| t.parent_id.as_deref() == Some(target_id_ref))
{
if child.status != "completed"
&& child.status != "done"
&& child.status != "cancelled"
{
uncompleted_children.push(child.title.as_str());
}
}
if !uncompleted_children.is_empty() {
blocked = true;
blocker_details = format!(
"Blocked by child tasks: {}",
uncompleted_children.join(", ")
);
}
}
}
if !blocked {
tasks[target_idx].status = target_status.clone();
tasks[target_idx].updated_at = crate::handlers::utils::now_secs();
updated_task = Some(tasks[target_idx].clone());
}
});
if blocked {
state.broadcast_task_event(TaskEvent {
task_id: id.clone(),
status: "blocked".to_string(),
action: Some("update".to_string()),
result: None,
error: Some(blocker_details.clone()),
timestamp: crate::handlers::utils::now_secs(),
session_id: None,
..Default::default()
});
Err(crate::error::AppError::Internal(format!(
"Error: Cannot transition task. {}",
blocker_details
)))
} else if found {
if let Some(ref ut) = updated_task {
let idx = state.get_search_index().await;
drop(idx.index_task(ut));
}
state.record_activity(
"task_update",
&format!("Task {} status -> {}", id, status),
None,
);
state.broadcast_task_event(TaskEvent {
task_id: id.clone(),
status: target_status.clone(),
action: Some("update".to_string()),
result: Some(serde_json::json!({ "status": target_status })),
error: None,
timestamp: crate::handlers::utils::now_secs(),
session_id: None,
..Default::default()
});
if target_status == "completed" {
crate::handlers::reconciliation::cascade_unblock_tasks(&state);
crate::handlers::reconciliation::recalculate_milestones(&state);
}
Ok("Task status updated.".to_string())
} else {
Err(crate::error::AppError::Internal(
"Task not found. Please verify the task ID.".to_string(),
))
}
}
TaskAction::Delete => {
let id = req.id.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'id' for action 'delete'. Next step: Provide task 'id' string in request and retry.".to_string())
})?;
let mut deleted_count = 0;
let mut actually_deleted = Vec::new();
state.project.tasks.modify(|tasks| {
let initial_len = tasks.len();
let mut id_to_index = std::collections::HashMap::new();
for (idx, t) in tasks.iter().enumerate() {
id_to_index.insert(t.id.as_str(), idx);
}
let mut children_map: std::collections::HashMap<usize, Vec<usize>> =
std::collections::HashMap::new();
for (idx, t) in tasks.iter().enumerate() {
if let Some(pid) = &t.parent_id
&& let Some(&p_idx) = id_to_index.get(pid.as_str())
{
children_map.entry(p_idx).or_default().push(idx);
}
}
let mut to_delete_idx = std::collections::HashSet::new();
if let Some(&start_idx) = id_to_index.get(id.as_str()) {
let mut queue = std::collections::VecDeque::new();
queue.push_back(start_idx);
while let Some(curr) = queue.pop_front() {
if to_delete_idx.insert(curr)
&& let Some(children) = children_map.get(&curr)
{
queue.extend(children.iter().copied());
}
}
}
for &idx in &to_delete_idx {
actually_deleted.push(tasks[idx].id.clone());
}
tasks.retain(|t| !actually_deleted.contains(&t.id));
deleted_count = initial_len - tasks.len();
});
if deleted_count > 0 {
let idx = state.get_search_index().await;
for deleted_id in actually_deleted {
drop(idx.delete_document(&deleted_id));
}
state.broadcast_task_event(TaskEvent {
task_id: id.clone(),
status: "deleted".to_string(),
action: Some("delete".to_string()),
result: Some(serde_json::json!({ "deleted_count": deleted_count })),
error: None,
timestamp: crate::handlers::utils::now_secs(),
session_id: None,
..Default::default()
});
Ok(format!(
"Deleted task and its children ({} total).",
deleted_count
))
} else {
Err(crate::error::AppError::Internal(
"Task not found. Please verify the task ID.".to_string(),
))
}
}
TaskAction::List => {
let level = req.summary_level.as_deref().unwrap_or("detailed");
let offset = req.offset.unwrap_or(0);
let data = state.project.tasks.read_with(|tasks| {
let matching: Vec<_> = tasks
.iter()
.filter(|t| {
let status_match = match req.status.as_deref() {
Some("all") => true,
Some(s) => t.status.eq_ignore_ascii_case(s),
None => t.is_active(),
};
let branch_match = match &req.git_branch {
Some(branch) => t.git_branch.is_none() || t.git_branch.as_deref() == Some(branch.as_str()),
None => true,
};
status_match && branch_match
})
.collect();
let mut filtered: Vec<_> = matching
.into_iter()
.skip(offset)
.map(|t| match level {
"compact" => serde_json::json!({ "id": t.id, "title": t.title, "status": t.status }),
"full" => serde_json::to_value(t).unwrap_or_default(),
_ => serde_json::json!({ "id": t.id, "title": t.title, "status": t.status, "description": t.description, "git_branch": t.git_branch }),
})
.collect();
let mut json_str = serde_yaml::to_string(&filtered)?;
if let Some(max_t) = req.max_tokens {
let char_limit = max_t * 4;
while json_str.len() > char_limit && !filtered.is_empty() {
filtered.pop();
json_str = serde_yaml::to_string(&filtered)?;
}
}
Ok::<String, crate::error::AppError>(json_str)
})?;
Ok(data)
}
TaskAction::SetCriteria => {
let id = req.id.or(req.title.clone()).ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'id' or 'title' for action 'set_criteria'. Next step: Provide task 'id' string in request and retry.".to_string())
})?;
let criteria_list = req.criteria.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'criteria' for action 'set_criteria'. Next step: Provide array of acceptance criteria strings in request and retry.".to_string())
})?;
let mut success = false;
let mut updated_task: Option<Task> = None;
state.project.tasks.modify(|tasks| {
if let Some(task) = tasks.iter_mut().find(|t| t.id == id || t.title == id) {
task.acceptance_criteria = criteria_list
.into_iter()
.map(|desc| crate::models::AcceptanceCriteria {
id: uuid::Uuid::new_v4().to_string(),
description: desc,
is_met: false,
..Default::default()
})
.collect();
task.updated_at = crate::handlers::utils::now_secs();
updated_task = Some(task.clone());
success = true;
}
});
if success {
if let Some(ref ut) = updated_task {
let idx = state.get_search_index().await;
drop(idx.index_task(ut));
}
state.broadcast_task_event(TaskEvent {
task_id: id.clone(),
status: "criteria_set".to_string(),
action: Some("set_criteria".to_string()),
result: Some(serde_json::json!({ "id": id })),
error: None,
timestamp: crate::handlers::utils::now_secs(),
session_id: None,
..Default::default()
});
Ok("Acceptance criteria set successfully.".to_string())
} else {
Err(crate::error::AppError::Internal(
"Task not found. Please verify the task ID.".to_string(),
))
}
}
TaskAction::Verify => {
let id = req.id.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'id' for action 'verify'. Next step: Provide task 'id' string in request and retry.".to_string())
})?;
let proof_str = req.proof.unwrap_or_else(|| "Verified".to_string());
let mut success = false;
let mut already_met = false;
let mut updated_task: Option<Task> = None;
state.project.tasks.modify(|tasks| {
if let Some(task) = tasks.iter_mut().find(|t| t.id == id || t.title == id) {
if let Some(ac) = task.acceptance_criteria.first_mut() {
if ac.is_met {
already_met = true;
} else {
ac.is_met = true;
success = true;
task.updated_at = crate::handlers::utils::now_secs();
updated_task = Some(task.clone());
}
} else {
task.acceptance_criteria
.push(crate::models::AcceptanceCriteria {
id: uuid::Uuid::new_v4().to_string(),
description: proof_str.clone(),
is_met: true,
..Default::default()
});
task.updated_at = crate::handlers::utils::now_secs();
updated_task = Some(task.clone());
success = true;
}
}
});
if success {
if let Some(ref ut) = updated_task {
let idx = state.get_search_index().await;
drop(idx.index_task(ut));
}
state.broadcast_task_event(TaskEvent {
task_id: id.clone(),
status: "verified".to_string(),
action: Some("verify".to_string()),
result: Some(serde_json::json!({ "proof": proof_str })),
error: None,
timestamp: crate::handlers::utils::now_secs(),
session_id: None,
..Default::default()
});
Ok(format!(
"Acceptance criteria verified with proof: {}",
proof_str
))
} else if already_met {
Ok("Acceptance criteria was already met.".to_string())
} else {
Err(crate::error::AppError::Internal(
"Task not found. Please verify the task ID.".to_string(),
))
}
}
}
}
}
pub struct MilestonesHandler;
#[async_trait]
impl McpTool for MilestonesHandler {
fn name(&self) -> &'static str {
"milestones"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<MilestonesTool>(
"milestones",
"Consolidated milestone management (add, update, list)",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: MilestonesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
match req.action {
MilestoneAction::Add => {
let title = req.title.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'title' for action 'add'. Next step: Provide non-empty 'title' string in request and retry.".to_string())
})?;
let ns = req
.namespace
.unwrap_or_else(crate::models::default_namespace);
state.project.milestones.modify(|ms| {
ms.push(crate::models::Milestone {
id: uuid::Uuid::new_v4().to_string(),
title,
status: "pending".to_string(),
namespace: ns,
target_date: req
.target_date
.as_deref()
.and_then(|s| s.parse::<u64>().ok()),
description: req.description,
task_ids: req.task_ids.unwrap_or_default(),
deliverables: req.deliverables.unwrap_or_default(),
repo_name: req.repo_name,
..Default::default()
})
});
crate::handlers::reconciliation::recalculate_milestones(&state);
Ok("Milestone added".to_string())
}
MilestoneAction::Update => {
let id = req.id.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'id' for action 'update'. Next step: Provide milestone 'id' string in request and retry.".to_string())
})?;
let status = req.status.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'status' for action 'update'. Next step: Provide milestone 'status' in request and retry.".to_string())
})?;
let mut found = false;
state.project.milestones.modify(|ms| {
for m in ms.iter_mut() {
if m.id == id {
m.status = status.clone();
if (status.eq_ignore_ascii_case("completed")
|| status.eq_ignore_ascii_case("done"))
&& m.completed_at.is_none()
{
m.completed_at = Some(crate::handlers::utils::now_secs());
}
if let Some(task_ids) = &req.task_ids {
m.task_ids = task_ids.clone();
if let Some(deliverables) = &req.deliverables {
m.deliverables = deliverables.clone();
}
}
found = true;
break;
}
}
});
if found {
crate::handlers::reconciliation::recalculate_milestones(&state);
Ok("Milestone updated".to_string())
} else {
Err(crate::error::AppError::Internal(
"Milestone not found. Please verify the milestone ID.".to_string(),
))
}
}
MilestoneAction::List => {
let data = state.project.milestones.read_with(|items| {
let filtered: Vec<_> = items
.iter()
.filter(|i| {
if let Some(ns) = &req.namespace {
&i.namespace == ns
} else {
true
}
})
.collect();
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&filtered)?)
})?;
Ok(data)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::tempdir;
#[tokio::test]
async fn test_add_task_and_list() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let handler = TasksHandler;
let args = json!({
"action": "add",
"title": "Fix the hyperdrive",
"description": "It's making a strange noise",
});
let res = handler
.execute(args, state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert!(res.contains("Task added with ID:"));
let res2 = handler
.execute(json!({"action": "list"}), state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert!(res2.contains("Fix the hyperdrive"));
}
#[tokio::test]
async fn test_update_task_status() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let handler = TasksHandler;
let res = handler
.execute(
json!({"action": "add", "title": "Test", "description": "test"}),
state.clone(),
)
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
let id_start = res.find("ID: ").unwrap() + 4;
let task_id = res[id_start..].trim();
let args = json!({
"action": "update",
"id": task_id,
"status": "done"
});
let res3 = handler
.execute(args, state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert_eq!(res3, "Task status updated.");
let res4 = handler
.execute(json!({"action": "list"}), state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert!(!res4.contains(task_id));
}
#[tokio::test]
async fn test_milestones_and_criteria() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let handler_ms = MilestonesHandler;
let args_ms = json!({
"action": "add",
"title": "Release 1.0",
"namespace": "global"
});
let res1 = handler_ms
.execute(args_ms, state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert!(res1.contains("Milestone added"));
let ms_id = state.project.milestones.read_with(|ms| ms[0].id.clone());
let args_ums = json!({
"action": "update",
"id": ms_id,
"status": "completed"
});
let res2 = handler_ms
.execute(args_ums, state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert_eq!(res2, "Milestone updated");
let res3 = handler_ms
.execute(
json!({"action": "list", "namespace": "global"}),
state.clone(),
)
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert!(res3.contains("completed"));
assert!(res3.contains("Release 1.0"));
let handler_t = TasksHandler;
let res_task = handler_t
.execute(
json!({"action": "add", "title": "Test", "description": "desc"}),
state.clone(),
)
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
let task_id = res_task[res_task.find("ID: ").unwrap() + 4..].trim();
let args_ac = json!({
"action": "set_criteria",
"id": task_id,
"criteria": ["Do X", "Do Y"]
});
let res4 = handler_t
.execute(args_ac, state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert_eq!(res4, "Acceptance criteria set successfully.");
let args_vac = json!({
"action": "verify",
"id": task_id,
"proof": "I did X"
});
let res5 = handler_t
.execute(args_vac, state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert!(res5.contains("Acceptance criteria verified"));
}
#[tokio::test]
async fn test_delete_task_with_children() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let handler = TasksHandler;
let parent = handler
.execute(
json!({"action": "add", "title": "Parent", "description": "p"}),
state.clone(),
)
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
let parent_id = parent[parent.find("ID: ").unwrap() + 4..]
.trim()
.to_string();
let child = handler
.execute(
json!({"action": "add", "title": "Child", "description": "c", "parent_id": parent_id}),
state.clone(),
)
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
let _child_id = child[child.find("ID: ").unwrap() + 4..].trim().to_string();
let res_del = handler
.execute(json!({"action": "delete", "id": parent_id}), state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert!(res_del.contains("Deleted task and its children (2 total)."));
}
#[tokio::test]
async fn test_list_milestones_with_namespace() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let handler = MilestonesHandler;
let args_ms = serde_json::json!({
"action": "add",
"title": "Release 1.0",
"namespace": "global"
});
let res1 = handler
.execute(args_ms, state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert!(res1.contains("Milestone added"));
let res2 = handler
.execute(
serde_json::json!({"action": "list", "namespace": "global"}),
state.clone(),
)
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert!(res2.contains("Release 1.0"));
}
}
+51
View File
@@ -0,0 +1,51 @@
pub fn contains_ignore_ascii_case(haystack: &str, needle: &str) -> bool {
if needle.is_empty() {
return true;
}
haystack
.as_bytes()
.windows(needle.len())
.any(|w| w.eq_ignore_ascii_case(needle.as_bytes()))
}
pub fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub fn validate_safe_path(path_str: &str) -> crate::error::Result<std::path::PathBuf> {
let path = std::path::Path::new(path_str);
for component in path.components() {
if component == std::path::Component::ParentDir {
return Err(crate::error::AppError::BadRequest(format!(
"Path traversal forbidden: '{}' contains relative parent directory components ('..')",
path_str
)));
}
}
Ok(path.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_contains_ignore_ascii_case() {
assert!(contains_ignore_ascii_case("Hello World", "world"));
assert!(contains_ignore_ascii_case("Hello World", "HELLO"));
assert!(contains_ignore_ascii_case("test", ""));
assert!(!contains_ignore_ascii_case("test", "testing"));
assert!(!contains_ignore_ascii_case("Hello", "world"));
}
#[test]
fn test_now_secs() {
let t1 = now_secs();
assert!(t1 > 1700000000); // Sanity check for recent epoch
std::thread::sleep(std::time::Duration::from_millis(1000));
let t2 = now_secs();
assert!(t2 >= t1);
}
}
File diff suppressed because it is too large. Load diff
+591
View File
@@ -0,0 +1,591 @@
use crate::models::*;
use crate::router::McpTool;
use crate::state::MemoryState;
use crate::tools::*;
use async_trait::async_trait;
use serde_json::Value;
use std::sync::Arc;
pub struct SnippetsHandler;
#[async_trait]
impl McpTool for SnippetsHandler {
fn name(&self) -> &'static str {
"snippets"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<SnippetsTool>(
"snippets",
"Consolidated reusable code snippets management (store, search, delete, tag)",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: SnippetsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
match req.action {
SnippetAction::Store => {
let name = req.name.or(req.query).or(req.id).ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'name', 'query', or 'id' as snippet name for action 'store'. Next step: Provide snippet name in 'name' or 'query' field and retry.".to_string())
})?;
let lang = req.language.unwrap_or_else(|| "text".to_string());
let code = req.code.unwrap_or_default();
let desc = req.description.unwrap_or_default();
let text_to_embed = format!(
"Name: {}\nLanguage: {}\nDescription: {}\nCode: {}",
name, lang, desc, code
);
let embedding = crate::embedding::generate_embedding_async(text_to_embed)
.await
.ok();
let snippet = Snippet {
name: name.clone(),
language: lang,
code,
description: desc,
updated_at: crate::handlers::utils::now_secs(),
tags: req.tags.unwrap_or_default(),
embedding,
namespace: req
.namespace
.unwrap_or_else(crate::models::default_namespace),
repo_name: req.repo_name,
origin_file: req.origin_file,
line_range: req.line_range,
};
let idx = state.get_search_index().await;
drop(idx.index_snippet(&snippet));
state.code.snippets.modify(|snippets| {
snippets.retain(|s| s.name != name);
snippets.push(snippet);
});
Ok(format!("Snippet '{}' stored.", name))
}
SnippetAction::Search => {
let query = req.query.or(req.name).unwrap_or_default();
let tags_filter = req.tags.unwrap_or_default();
let is_hybrid = req.hybrid.unwrap_or(false);
if is_hybrid {
let snippets = state.code.snippets.read_with(|s| s.clone());
let q_tokens: Vec<_> = query
.to_lowercase()
.split_whitespace()
.map(|s| s.to_string())
.collect();
let mut scored: Vec<_> = snippets
.into_iter()
.filter_map(|s| {
let text =
format!("{} {} {} {}", s.name, s.description, s.language, s.code)
.to_lowercase();
let mut score = 0.0;
for tok in &q_tokens {
if text.contains(tok) {
score += 1.0;
}
}
if !tags_filter.is_empty() {
for tag in &tags_filter {
if s.tags
.iter()
.any(|t| t.to_lowercase() == tag.to_lowercase())
{
score += 2.0;
}
}
}
if score > 0.0 {
Some(serde_json::json!({
"name": s.name,
"description": s.description,
"language": s.language,
"tags": s.tags,
"score": score,
"code": s.code
}))
} else {
None
}
})
.collect();
scored.sort_by(|a, b| {
b["score"]
.as_f64()
.unwrap_or(0.0)
.total_cmp(&a["score"].as_f64().unwrap_or(0.0))
});
scored.truncate(10);
Ok(serde_yaml::to_string(&scored)?)
} else {
let data = state.code.snippets.read_with(|snippets| {
let results: Vec<_> = snippets
.iter()
.filter(|s| {
let matches_text = query.is_empty()
|| contains_ignore_ascii_case(&s.name, &query)
|| contains_ignore_ascii_case(&s.description, &query)
|| contains_ignore_ascii_case(&s.language, &query)
|| contains_ignore_ascii_case(&s.code, &query)
|| s.tags.iter().any(|t| contains_ignore_ascii_case(t, &query));
let matches_tags = tags_filter.is_empty()
|| tags_filter.iter().any(|tf| {
s.tags.iter().any(|st| contains_ignore_ascii_case(st, tf))
});
matches_text && matches_tags
})
.take(10)
.map(|s| {
serde_json::json!({
"name": s.name,
"description": s.description,
"language": s.language,
"tags": s.tags,
"code": s.code,
})
})
.collect::<Vec<_>>();
serde_yaml::to_string(&results)
})?;
Ok(data)
}
}
SnippetAction::Delete => {
let id = req.name.or(req.id).or(req.query).ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'name', 'id', or 'query' for action 'delete'. Next step: Provide snippet ID/name in request and retry.".to_string())
})?;
let mut deleted = false;
state.code.snippets.modify(|snippets| {
let initial = snippets.len();
snippets.retain(|s| s.name != id);
deleted = snippets.len() < initial;
});
if deleted {
Ok(format!("Snippet '{}' deleted.", id))
} else {
Err(crate::error::AppError::Internal(format!(
"Snippet '{}' not found.",
id
)))
}
}
SnippetAction::Tag => {
let id = req.name.or(req.id).or(req.query).ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'name', 'id', or 'query' for action 'tag'. Next step: Provide snippet ID/name and 'tags' array in request and retry.".to_string())
})?;
let tags = req.tags.unwrap_or_default();
let mut found = false;
state.code.snippets.modify(|snippets| {
for s in snippets.iter_mut() {
if s.name == id {
for tag in &tags {
if !s.tags.contains(tag) {
s.tags.push(tag.clone());
}
}
found = true;
break;
}
}
});
if found {
Ok(format!("Snippet '{}' tagged with: {:?}", id, tags))
} else {
Err(crate::error::AppError::Internal(format!(
"Snippet '{}' not found.",
id
)))
}
}
}
}
}
use crate::handlers::utils::*;
use crate::tools::ReadDirectoryArchitectureTool;
use std::fs;
pub struct ReadDirectoryArchitectureHandler;
#[async_trait]
impl McpTool for ReadDirectoryArchitectureHandler {
fn name(&self) -> &'static str {
"read_directory_architecture"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ReadDirectoryArchitectureTool>(
"read_directory_architecture",
"Get a bird's-eye view of a directory, reading the file tree and extracting a basic structural summary.",
)
}
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> {
let tool_args: ReadDirectoryArchitectureTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let dir = tool_args.directory.clone();
let result = tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
let mut summary = String::new();
fn visit_dirs(
dir: &std::path::Path,
summary: &mut String,
depth: usize,
) -> std::io::Result<()> {
if depth > 10 {
return Ok(());
}
if dir.is_dir() {
let mut entries =
fs::read_dir(dir)?.collect::<Result<Vec<_>, std::io::Error>>()?;
entries.sort_by_key(|e| e.path());
for entry in entries {
let path = entry.path();
let indent = " ".repeat(depth);
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.')
|| name == "target"
|| name == "node_modules"
|| name == "dist"
{
continue;
}
if path.is_dir() {
summary.push_str(&format!("{}- {}/\n", indent, name));
visit_dirs(&path, summary, depth + 1)?;
} else {
// Extract a brief 1-line heuristic if it's a known file type
let mut peek = String::new();
if let Ok(content) = fs::read_to_string(&path) {
// Find the first docstring or struct/class definition
for line in content.lines() {
let t = line.trim();
if t.starts_with("///")
|| t.starts_with("# ")
|| t.starts_with("struct ")
|| t.starts_with("class ")
|| t.starts_with("function ")
{
let truncated: String = t.chars().take(80).collect();
peek = format!(" -> {}", truncated);
break;
}
}
}
summary.push_str(&format!("{}- {}{}\n", indent, name, peek));
}
}
}
Ok(())
}
let path = std::path::Path::new(&dir);
if !path.exists() {
return Err(crate::error::AppError::Internal(format!(
"Directory does not exist: {}",
dir
)));
}
summary.push_str(&format!("Architecture of {}:\n", dir));
visit_dirs(path, &mut summary, 0).map_err(|e| e.to_string())?;
Ok(summary)
})
.await
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??;
Ok(result)
}
}
use crate::tools::SemanticCodeSearchTool;
pub struct SemanticCodeSearchHandler;
#[async_trait]
impl McpTool for SemanticCodeSearchHandler {
fn name(&self) -> &'static str {
"semantic_code_search"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<SemanticCodeSearchTool>(
"semantic_code_search",
"Perform a semantic vector search across indexed code snippets and knowledge graph nodes using fastembed.",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let tool_args: SemanticCodeSearchTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let results = state
.search()
.semantic_search(&tool_args.query, None, 5)
.await?;
if results.is_empty() {
return Ok(format!(
"No semantic matches found for query: {}",
tool_args.query
));
}
let mut out = format!("Semantic Search Results for '{}':\n", tool_args.query);
for res in &results {
out.push_str(&format!(
"- [{:.2}] {}: {}\n",
res.score, res.title, res.body
));
}
if state.ollama.is_available().await {
let prompt = format!(
"You are an expert software architect. Synthesize a concise GraphRAG summary of the following graph nodes relevant to the query: '{}'.\n\nData:\n{}",
tool_args.query, out
);
if let Ok(summary) = state
.ollama
.generate(&prompt, None, Some("Respond clearly and concisely."), None)
.await
{
out.push_str("\n\n--- Local GraphRAG Summary ---\n");
out.push_str(&summary);
}
}
Ok(out)
}
}
pub struct ManageSubagentNamespaceHandler;
#[async_trait]
impl McpTool for ManageSubagentNamespaceHandler {
fn name(&self) -> &'static str {
"manage_subagent_namespace"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ManageSubagentNamespaceTool>(
"manage_subagent_namespace",
"Manage isolated memory namespaces for subagent sessions (create, condense/promote, or purge)",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: ManageSubagentNamespaceTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let subagent_ns = format!("subagent-{}", req.subagent_id);
match req.action {
SubagentNamespaceAction::Create => {
state.record_activity(
"namespace",
&format!("Created subagent namespace {}", subagent_ns),
None,
);
Ok(format!(
"Subagent memory namespace '{}' initialized.",
subagent_ns
))
}
SubagentNamespaceAction::Purge => {
state.modify_graph(|g| {
g.entities.retain(|_, e| e.namespace != subagent_ns);
g.relations.retain(|r| r.namespace != subagent_ns);
});
state.record_activity(
"namespace",
&format!("Purged subagent namespace {}", subagent_ns),
None,
);
Ok(format!(
"Subagent memory namespace '{}' purged.",
subagent_ns
))
}
SubagentNamespaceAction::Condense => {
let purge = req.purge_after_promotion.unwrap_or(true);
let mut promoted_entities = 0;
let mut promoted_relations = 0;
state.modify_graph(|g| {
let sub_entities: Vec<Entity> = g
.entities
.values()
.filter(|e| e.namespace == subagent_ns)
.cloned()
.collect();
for mut e in sub_entities {
promoted_entities += 1;
e.namespace = "global".to_string();
let global_entry =
g.entities.entry(e.name.clone()).or_insert_with(|| Entity {
name: e.name.clone(),
entity_type: e.entity_type.clone(),
observations: Vec::new(),
namespace: "global".to_string(),
git_branch: e.git_branch.clone(),
..Default::default()
});
global_entry.namespace = "global".to_string();
for obs in e.observations {
if !global_entry.observations.contains(&obs) {
global_entry.observations.push(obs);
}
}
}
for r in &mut g.relations {
if r.namespace == subagent_ns {
promoted_relations += 1;
r.namespace = "global".to_string();
}
}
if purge {
g.entities.retain(|_, e| e.namespace != subagent_ns);
g.relations.retain(|r| r.namespace != subagent_ns);
}
});
Ok(format!(
"Subagent namespace '{}' condensed: promoted {} entities and {} relations to global graph.",
subagent_ns, promoted_entities, promoted_relations
))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::tempdir;
#[tokio::test]
async fn test_snippets_lifecycle() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let snippets_handler = SnippetsHandler;
// 1. Store
let store_args = json!({
"action": "store",
"name": "init_db",
"language": "sql",
"description": "Initialize database",
"code": "SELECT 1;",
"tags": ["db", "sql"]
});
let res1 = snippets_handler
.execute(store_args, state.clone())
.await
.unwrap();
assert_eq!(res1, "Snippet 'init_db' stored.");
// 2. Search
let search_args = json!({
"action": "search",
"query": "SELECT"
});
let res2 = snippets_handler
.execute(search_args, state.clone())
.await
.unwrap();
assert!(res2.contains("init_db"));
// 3. Tag
let tag_args = json!({
"action": "tag",
"name": "init_db",
"tags": ["postgres"]
});
let res3 = snippets_handler
.execute(tag_args, state.clone())
.await
.unwrap();
assert!(res3.contains("tagged with"));
// 4. Delete
let delete_args = json!({
"action": "delete",
"name": "init_db"
});
let res4 = snippets_handler
.execute(delete_args, state.clone())
.await
.unwrap();
assert_eq!(res4, "Snippet 'init_db' deleted.");
}
#[tokio::test]
async fn test_subagent_namespace_lifecycle() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let manage_sub_ns = ManageSubagentNamespaceHandler;
// Create
let create_res = manage_sub_ns
.execute(
json!({
"subagent_id": "auditor_1",
"action": "create"
}),
state.clone(),
)
.await
.unwrap();
assert!(create_res.contains("initialized"));
// Add subagent entity into graph
state.modify_graph(|g| {
g.entities.insert(
"SubNode".to_string(),
Entity {
name: "SubNode".to_string(),
entity_type: "Module".to_string(),
observations: vec!["test observation".to_string()],
namespace: "subagent-auditor_1".to_string(),
..Default::default()
},
);
});
// Condense
let condense_res = manage_sub_ns
.execute(
json!({
"subagent_id": "auditor_1",
"action": "condense",
"purge_after_promotion": true
}),
state.clone(),
)
.await
.unwrap();
assert!(condense_res.contains("condensed: promoted 1 entities"));
// Verify entity is now global
let graph = state.graph.cache.read().unwrap();
assert_eq!(graph.entities.get("SubNode").unwrap().namespace, "global");
}
}
-169
View File
@@ -1,169 +0,0 @@
use crate::router::McpTool;
use crate::state::MemoryState;
use crate::tools::*;
use async_trait::async_trait;
use serde_json::Value;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
pub struct UpdateEnvFingerprintHandler;
#[async_trait]
impl McpTool for UpdateEnvFingerprintHandler {
fn name(&self) -> &'static str {
"update_env_fingerprint"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<UpdateEnvFingerprintTool>(
"update_env_fingerprint",
"Execute update_env_fingerprint",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: UpdateEnvFingerprintTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
state.env_fingerprints.modify(|fps| {
fps.insert(
req.namespace.clone(),
crate::models::EnvFingerprint {
namespace: req.namespace.clone(),
os: std::env::consts::OS.to_string(),
shell: std::env::var("SHELL").unwrap_or_else(|_| "unknown".to_string()),
tool_versions: req.tool_versions,
updated_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
},
);
});
Ok("Env fingerprint updated".to_string())
}
}
pub struct ReadEnvFingerprintHandler;
#[async_trait]
impl McpTool for ReadEnvFingerprintHandler {
fn name(&self) -> &'static str {
"read_env_fingerprint"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ReadEnvFingerprintTool>(
"read_env_fingerprint",
"Execute read_env_fingerprint",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: ReadEnvFingerprintTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let data = state.env_fingerprints.read_with(|fps| {
fps.get(&req.namespace).cloned()
});
if let Some(fp) = data {
let data = serde_json::to_string(&fp).unwrap_or_default();
Ok(data.to_string())
} else {
Ok("{}".to_string())
}
}
}
pub struct LogEnvRequirementHandler;
#[async_trait]
impl McpTool for LogEnvRequirementHandler {
fn name(&self) -> &'static str {
"log_env_requirement"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<LogEnvRequirementTool>(
"log_env_requirement",
"Execute log_env_requirement",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: LogEnvRequirementTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
state.env_requirements.modify(|reqs| {
reqs.retain(|r| !(r.namespace == req.namespace && r.key == req.key));
reqs.push(crate::models::EnvRequirement {
namespace: req.namespace,
key: req.key,
description: req.description,
is_secret: req.is_secret,
});
});
Ok("Env requirement logged".to_string())
}
}
pub struct RegisterEnvironmentHandler;
#[async_trait]
impl McpTool for RegisterEnvironmentHandler {
fn name(&self) -> &'static str {
"register_environment"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<RegisterEnvironmentTool>(
"register_environment",
"Execute register_environment",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: RegisterEnvironmentTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
state.environments.modify(|envs| {
envs.retain(|e| !(e.namespace == req.namespace && e.name == req.name));
envs.push(crate::models::EnvironmentDetail {
namespace: req.namespace,
name: req.name,
url: req.url,
description: req.description,
requires_vpn: req.requires_vpn,
updated_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
});
});
Ok("Environment registered".to_string())
}
}
pub struct GetEnvironmentDetailsHandler;
#[async_trait]
impl McpTool for GetEnvironmentDetailsHandler {
fn name(&self) -> &'static str {
"get_environment_details"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<GetEnvironmentDetailsTool>(
"get_environment_details",
"Execute get_environment_details",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: GetEnvironmentDetailsTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let data = state.environments.read_with(|envs| {
let filtered: Vec<_> = envs.iter().filter(|e| e.namespace == req.namespace).collect();
serde_json::to_string(&filtered).unwrap_or_default()
});
Ok(data.to_string())
}
}
-556
View File
@@ -1,556 +0,0 @@
use crate::router::McpTool;
use crate::state::MemoryState;
use crate::tools::*;
use async_trait::async_trait;
use serde_json::Value;
use std::collections::HashSet;
use std::sync::Arc;
#[derive(serde::Serialize)]
#[derive(Default)]
struct BorrowedGraph<'a> {
entities: std::collections::HashMap<&'a String, &'a crate::models::Entity>,
relations: Vec<&'a crate::models::Relation>,
}
pub struct QueryGraphPathHandler;
#[async_trait]
impl McpTool for QueryGraphPathHandler {
fn name(&self) -> &'static str {
"query_graph_path"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<QueryGraphPathTool>("query_graph_path", "Execute query_graph_path")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: crate::tools::QueryGraphPathTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
state.read_graph(|graph| {
let max_depth = req.max_depth.unwrap_or(5);
let mut queue = std::collections::VecDeque::new();
let mut visited = std::collections::HashSet::new();
let mut parents: std::collections::HashMap<String, (String, String)> =
std::collections::HashMap::new();
queue.push_back(req.start_node.clone());
visited.insert(req.start_node.clone());
let mut found = false;
let mut current_depth = 0;
let mut nodes_at_current_depth = 1;
let mut nodes_at_next_depth = 0;
while let Some(current) = queue.pop_front() {
if current == req.end_node {
found = true;
break;
}
nodes_at_current_depth -= 1;
if current_depth < max_depth {
for rel in &graph.relations {
if rel.from == current && !visited.contains(&rel.to) {
visited.insert(rel.to.clone());
parents.insert(
rel.to.clone(),
(current.clone(), rel.relation_type.clone()),
);
queue.push_back(rel.to.clone());
nodes_at_next_depth += 1;
} else if rel.to == current && !visited.contains(&rel.from) {
visited.insert(rel.from.clone());
parents.insert(
rel.from.clone(),
(current.clone(), format!("inverse({})", rel.relation_type)),
);
queue.push_back(rel.from.clone());
nodes_at_next_depth += 1;
}
}
}
if nodes_at_current_depth == 0 {
current_depth += 1;
nodes_at_current_depth = nodes_at_next_depth;
nodes_at_next_depth = 0;
}
}
if found {
let mut path = Vec::new();
let mut curr = req.end_node.clone();
while curr != req.start_node {
if let Some((parent, rel_type)) = parents.get(&curr) {
path.push(format!("{} -[{}]-> {}", parent, rel_type, curr));
curr = parent.clone();
} else {
break;
}
}
path.reverse();
Ok(format!("Path found:\n{}", path.join("\n")))
} else {
Ok(format!(
"No path found between {} and {} within depth {}",
req.start_node, req.end_node, max_depth
))
}
})
}
}
pub struct CreateEntitiesHandler;
#[async_trait]
impl McpTool for CreateEntitiesHandler {
fn name(&self) -> &'static str {
"create_entities"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<CreateEntitiesTool>("create_entities", "Execute create_entities")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: CreateEntitiesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let mut inserted = Vec::new();
state.modify_graph(|g| {
for entity in req.entities {
if !entity.name.is_empty() {
inserted.push(entity.clone());
g.entities.insert(entity.name.clone(), entity);
}
}
});
let idx = state.search_index.read().unwrap_or_else(|e| e.into_inner()).clone();
for entity in inserted {
let _ = idx.index_entity(&entity).await;
}
Ok("Entities created".to_string())
}
}
pub struct CreateRelationsHandler;
#[async_trait]
impl McpTool for CreateRelationsHandler {
fn name(&self) -> &'static str {
"create_relations"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<CreateRelationsTool>("create_relations", "Execute create_relations")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: CreateRelationsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
state.modify_graph(|g| {
for relation in req.relations {
if !relation.from.is_empty() && !relation.to.is_empty() {
g.relations.push(relation);
}
}
});
Ok("Relations created".to_string())
}
}
pub struct AddObservationsHandler;
#[async_trait]
impl McpTool for AddObservationsHandler {
fn name(&self) -> &'static str {
"add_observations"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<AddObservationsTool>("add_observations", "Execute add_observations")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: AddObservationsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
state.modify_graph(|g| {
for o in req.observations {
if let Some(e) = g.entities.get_mut(&o.entity_name) {
e.observations.extend(o.contents);
}
}
});
Ok("Observations added".to_string())
}
}
pub struct DeleteEntitiesHandler;
#[async_trait]
impl McpTool for DeleteEntitiesHandler {
fn name(&self) -> &'static str {
"delete_entities"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<DeleteEntitiesTool>("delete_entities", "Execute delete_entities")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: DeleteEntitiesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let to_delete: HashSet<_> = req.entity_names.into_iter().collect();
state.modify_graph(|master| {
for name in &to_delete {
master.entities.remove(name);
}
master
.relations
.retain(|r| !to_delete.contains(&r.from) && !to_delete.contains(&r.to));
});
Ok("Entities deleted".to_string())
}
}
pub struct DeleteObservationsHandler;
#[async_trait]
impl McpTool for DeleteObservationsHandler {
fn name(&self) -> &'static str {
"delete_observations"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<DeleteObservationsTool>(
"delete_observations",
"Execute delete_observations",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: DeleteObservationsTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
state.modify_graph(|master| {
for d in req.deletions {
if let Some(e) = master.entities.get_mut(&d.entity_name) {
let to_rem: HashSet<_> = d.observations.into_iter().collect();
e.observations.retain(|o| !to_rem.contains(o));
}
}
});
Ok("Observations deleted".to_string())
}
}
pub struct DeleteRelationsHandler;
#[async_trait]
impl McpTool for DeleteRelationsHandler {
fn name(&self) -> &'static str {
"delete_relations"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<DeleteRelationsTool>("delete_relations", "Execute delete_relations")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: DeleteRelationsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
state.modify_graph(|master| {
let to_rem: HashSet<_> = req.relations.into_iter().collect();
master.relations.retain(|r| !to_rem.contains(r));
});
Ok("Relations deleted".to_string())
}
}
pub struct ReadGraphHandler;
#[async_trait]
impl McpTool for ReadGraphHandler {
fn name(&self) -> &'static str {
"read_graph"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ReadGraphTool>("read_graph", "Execute read_graph")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: ReadGraphTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let data = state.read_graph(|full| {
if let Some(ns) = req.namespace {
let mut filtered = BorrowedGraph::default();
for (k, v) in &full.entities {
if v.namespace == ns {
filtered.entities.insert(k, v);
}
}
for r in &full.relations {
if r.namespace == ns {
filtered.relations.push(r);
}
}
serde_json::to_string(&filtered).unwrap_or_default()
} else {
serde_json::to_string(full).unwrap_or_default()
}
});
Ok(data)
}
}
pub struct SearchNodesHandler;
#[async_trait]
impl McpTool for SearchNodesHandler {
fn name(&self) -> &'static str {
"search_nodes"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<SearchNodesTool>("search_nodes", "Execute search_nodes")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: SearchNodesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let matches = if let Ok(idx) = state.search_index.read() {
idx.search(&req.query, req.namespace.as_deref())
.unwrap_or_default()
} else {
vec![]
};
let data = state.read_graph(|full| {
let mut result = BorrowedGraph::default();
for (id, doc_type, _, _, _) in &matches {
if doc_type == "entity"
&& let Some(e) = full.entities.get(id)
{
result.entities.insert(id, e);
}
}
serde_json::to_string(&result).unwrap_or_default()
});
Ok(data)
}
}
pub struct OpenNodesHandler;
#[async_trait]
impl McpTool for OpenNodesHandler {
fn name(&self) -> &'static str {
"open_nodes"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<OpenNodesTool>("open_nodes", "Execute open_nodes")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: OpenNodesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let data = state.read_graph(|full| {
let targets: HashSet<&str> = req.names.iter().map(|s| s.as_str()).collect();
let mut result = BorrowedGraph::default();
let mut connected: HashSet<&str> = HashSet::new();
for r in &full.relations {
if targets.contains(r.from.as_str()) {
connected.insert(r.to.as_str());
result.relations.push(r);
} else if targets.contains(r.to.as_str()) {
connected.insert(r.from.as_str());
result.relations.push(r);
}
}
for (name, e) in &full.entities {
if targets.contains(name.as_str()) || connected.contains(name.as_str()) {
result.entities.insert(name, e);
}
}
serde_json::to_string(&result).unwrap_or_default()
});
Ok(data)
}
}
pub struct VisualizeGraphHandler;
#[async_trait]
impl McpTool for VisualizeGraphHandler {
fn name(&self) -> &'static str {
"visualize_graph"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<VisualizeGraphTool>("visualize_graph", "Execute visualize_graph")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: VisualizeGraphTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let query = req.query.unwrap_or_default();
use std::fmt::Write;
let mut output = state.read_graph(|full| {
let mut included: HashSet<&str> = HashSet::new();
let mut to_draw = Vec::new();
for (name, e) in &full.entities {
if let Some(ns) = &req.namespace
&& e.namespace != *ns
{
continue;
}
if query.is_empty()
|| contains_ignore_ascii_case(name, &query)
|| contains_ignore_ascii_case(&e.entity_type, &query)
{
included.insert(name.as_str());
}
}
for r in &full.relations {
if let Some(ns) = &req.namespace
&& r.namespace != *ns
{
continue;
}
if query.is_empty() || included.contains(r.from.as_str()) || included.contains(r.to.as_str()) {
included.insert(r.from.as_str());
included.insert(r.to.as_str());
to_draw.push(r.clone());
}
}
let mut out = String::with_capacity(included.len() * 40 + to_draw.len() * 60);
out.push_str("graph TD;\n");
let sanitize = |s: &str, id_mode: bool| -> String {
let mut o = String::with_capacity(s.len());
for c in s.chars() {
if c != '"' && c != '(' && c != ')' {
if id_mode && (c == ' ' || c == '-' || c == '.') {
o.push('_');
} else {
o.push(c);
}
}
}
o
};
for name in &included {
let _ = writeln!(
out,
" id_{}[\"{}\"];",
sanitize(name, true),
sanitize(name, false)
);
}
for r in to_draw {
let _ = writeln!(
out,
" id_{}-->|\"{}\"|id_{};",
sanitize(&r.from, true),
r.relation_type.replace("\"", ""),
sanitize(&r.to, true)
);
}
out
});
if output == "graph TD;\n" {
output = "No nodes found to visualize.".to_string();
}
Ok(output.to_string())
}
}
pub struct CondenseEntityHandler;
#[async_trait]
impl McpTool for CondenseEntityHandler {
fn name(&self) -> &'static str {
"condense_entity"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<CondenseEntityTool>("condense_entity", "Execute condense_entity")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: CondenseEntityTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
state.modify_graph(|master| {
if let Some(e) = master.entities.get_mut(&req.entity_name) {
e.observations = req.summarized_observations;
}
});
Ok("Entity condensed".to_string())
}
}
pub struct MergeEntitiesHandler;
#[async_trait]
impl McpTool for MergeEntitiesHandler {
fn name(&self) -> &'static str {
"merge_entities"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<MergeEntitiesTool>("merge_entities", "Execute merge_entities")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: MergeEntitiesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
state.modify_graph(|master| {
if let Some(src) = master.entities.remove(&req.source_entity) {
if let Some(tgt) = master.entities.get_mut(&req.target_entity) {
tgt.observations.extend(src.observations);
MemoryState::deduplicate(&mut tgt.observations);
} else {
let mut new_tgt = src.clone();
new_tgt.name = req.target_entity.clone();
master.entities.insert(req.target_entity.clone(), new_tgt);
}
}
for r in &mut master.relations {
if r.from == req.source_entity {
r.from = req.target_entity.clone();
}
if r.to == req.source_entity {
r.to = req.target_entity.clone();
}
}
MemoryState::deduplicate(&mut master.relations);
});
Ok("Entities merged".to_string())
}
}
pub struct FindOrphansHandler;
#[async_trait]
impl McpTool for FindOrphansHandler {
fn name(&self) -> &'static str {
"find_orphans"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<FindOrphansTool>("find_orphans", "Execute find_orphans")
}
async fn execute(&self, _args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let orphans = state.read_graph(|full| {
let mut connected = std::collections::HashSet::new();
for r in &full.relations {
connected.insert(r.from.as_str());
connected.insert(r.to.as_str());
}
full.entities
.keys()
.filter(|k| !connected.contains(k.as_str()))
.cloned()
.collect::<Vec<String>>()
});
let data = serde_json::to_string(&orphans).unwrap_or_default();
Ok(data.to_string())
}
}
use crate::handlers_v2::utils::*;
-463
View File
@@ -1,463 +0,0 @@
use crate::models::*;
use crate::router::McpTool;
use crate::state::MemoryState;
use crate::tools::*;
use async_trait::async_trait;
use serde_json::Value;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
pub struct LogDecisionHandler;
#[async_trait]
impl McpTool for LogDecisionHandler {
fn name(&self) -> &'static str {
"log_decision"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<LogDecisionTool>("log_decision", "Execute log_decision")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: LogDecisionTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let mut adr_id = String::new();
let mut new_adr = None;
state.adrs.modify(|adrs| {
adr_id = format!("ADR-{:04}", adrs.len() + 1);
let a = Adr {
id: adr_id.clone(),
title: req.title,
context: req.context,
decision: req.decision,
consequence: req.consequence,
timestamp: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
};
new_adr = Some(a.clone());
adrs.push(a);
});
if let Some(adr) = new_adr {
let idx = state.search_index.read().unwrap_or_else(|e| e.into_inner()).clone();
let _ = idx.index_adr(&adr).await;
}
Ok(format!("Decision logged as {}", adr_id).to_string())
}
}
pub struct QueryDecisionsHandler;
#[async_trait]
impl McpTool for QueryDecisionsHandler {
fn name(&self) -> &'static str {
"query_decisions"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<QueryDecisionsTool>("query_decisions", "Execute query_decisions")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: QueryDecisionsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let data = state.adrs.read_with(|adrs| {
let filtered: Vec<_> = adrs.iter().filter(|a| {
if let Some(q) = &req.query {
contains_ignore_ascii_case(&a.title, q)
|| contains_ignore_ascii_case(&a.context, q)
|| contains_ignore_ascii_case(&a.decision, q)
} else {
true
}
}).collect();
serde_json::to_string(&filtered).unwrap_or_default()
});
Ok(data.to_string())
}
}
pub struct LogErrorFixHandler;
#[async_trait]
impl McpTool for LogErrorFixHandler {
fn name(&self) -> &'static str {
"log_error_fix"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<LogErrorFixTool>("log_error_fix", "Execute log_error_fix")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: LogErrorFixTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
state.error_fixes.modify(|fixes| {
fixes.push(crate::models::ErrorFix {
signature: req.signature,
solution: req.solution,
timestamp: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
git_commit: req.git_commit,
git_branch: req.git_branch,
})
});
Ok("Error fix logged".to_string())
}
}
pub struct SearchErrorFixesHandler;
#[async_trait]
impl McpTool for SearchErrorFixesHandler {
fn name(&self) -> &'static str {
"search_error_fixes"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<SearchErrorFixesTool>(
"search_error_fixes",
"Execute search_error_fixes",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: SearchErrorFixesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let q = req.query;
let data = state.error_fixes.read_with(|fixes| {
let filtered: Vec<_> = fixes.iter().filter(|f| {
contains_ignore_ascii_case(&f.signature, &q)
|| contains_ignore_ascii_case(&f.solution, &q)
}).collect();
serde_json::to_string(&filtered).unwrap_or_default()
});
Ok(data.to_string())
}
}
pub struct LogCodeChangeHandler;
#[async_trait]
impl McpTool for LogCodeChangeHandler {
fn name(&self) -> &'static str {
"log_code_change"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<LogCodeChangeTool>("log_code_change", "Execute log_code_change")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: LogCodeChangeTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
state.ledger.modify(|ledger| {
ledger.push(CodeChange {
timestamp: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
file_path: req.file_path,
description: req.description,
git_commit: req.git_commit,
git_branch: req.git_branch,
});
});
Ok("Code change logged".to_string())
}
}
pub struct QueryRecentChangesHandler;
#[async_trait]
impl McpTool for QueryRecentChangesHandler {
fn name(&self) -> &'static str {
"query_recent_changes"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<QueryRecentChangesTool>(
"query_recent_changes",
"Execute query_recent_changes",
)
}
async fn execute(&self, _args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let data = state.ledger.read_with(|l| serde_json::to_string(l).unwrap_or_else(|_| "[]".to_string()));
Ok(data.to_string())
}
}
pub struct LearnPreferenceHandler;
#[async_trait]
impl McpTool for LearnPreferenceHandler {
fn name(&self) -> &'static str {
"learn_preference"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<LearnPreferenceTool>("learn_preference", "Execute learn_preference")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: LearnPreferenceTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
state.prefs.modify(|prefs| {
prefs.insert(
req.key.clone(),
crate::models::Preference {
key: req.key.clone(),
value: req.value,
updated_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
},
);
});
Ok("Preference learned".to_string())
}
}
pub struct ReadPreferencesHandler;
#[async_trait]
impl McpTool for ReadPreferencesHandler {
fn name(&self) -> &'static str {
"read_preferences"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ReadPreferencesTool>("read_preferences", "Execute read_preferences")
}
async fn execute(&self, _args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let data = state.prefs.read_with(|prefs| serde_json::to_string(prefs).unwrap_or_default());
Ok(data.to_string())
}
}
pub struct LogTechDebtHandler;
#[async_trait]
impl McpTool for LogTechDebtHandler {
fn name(&self) -> &'static str {
"log_tech_debt"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<LogTechDebtTool>("log_tech_debt", "Execute log_tech_debt")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: LogTechDebtTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
state.tech_debts.modify(|debts| {
debts.push(crate::models::TechDebt {
id: uuid::Uuid::new_v4().to_string(),
namespace: req.namespace,
description: req.description,
ideal_solution: req.ideal_solution,
is_resolved: false,
created_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
git_commit: req.git_commit,
git_branch: req.git_branch,
})
});
Ok("Tech debt logged".to_string())
}
}
pub struct ResolveTechDebtHandler;
#[async_trait]
impl McpTool for ResolveTechDebtHandler {
fn name(&self) -> &'static str {
"resolve_tech_debt"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ResolveTechDebtTool>(
"resolve_tech_debt",
"Execute resolve_tech_debt",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: ResolveTechDebtTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let mut found = false;
state.tech_debts.modify(|debts| {
for d in debts.iter_mut() {
if d.id == req.id {
d.is_resolved = true;
found = true;
break;
}
}
});
if found {
Ok("Tech debt resolved".to_string())
} else {
Ok("Tech debt not found".to_string())
}
}
}
pub struct ListTechDebtHandler;
#[async_trait]
impl McpTool for ListTechDebtHandler {
fn name(&self) -> &'static str {
"list_tech_debt"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ListTechDebtTool>("list_tech_debt", "Execute list_tech_debt")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: ListTechDebtTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let data = state.tech_debts.read_with(|debts| {
let filtered: Vec<_> = debts.iter().filter(|d| {
d.namespace == req.namespace && (req.include_resolved || !d.is_resolved)
}).collect();
serde_json::to_string(&filtered).unwrap_or_default()
});
Ok(data.to_string())
}
}
pub struct OmniSearchHandler;
#[async_trait]
impl McpTool for OmniSearchHandler {
fn name(&self) -> &'static str {
"omni_search"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<OmniSearchTool>("omni_search", "Execute omni_search")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: OmniSearchTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let matches = {
let idx = state.search_index.read().unwrap_or_else(|e| e.into_inner());
idx.search(&req.query, req.namespace.as_deref())
.unwrap_or_default()
};
let kg_json = state.read_graph(|full| {
let mut kg_entities = std::collections::HashMap::new();
for (id, doc_type, _, _, _) in &matches {
if doc_type == "entity"
&& let Some(e) = full.entities.get(id)
{
kg_entities.insert(id, e);
}
}
serde_json::to_value(&kg_entities).unwrap_or_default()
});
let tasks_json = state.tasks.read_with(|all_tasks| {
let filtered: Vec<_> = all_tasks.iter().filter(|t| {
matches.iter().any(|(id, typ, _, _, _)| id == &t.id && typ == "task")
}).collect();
serde_json::to_value(&filtered).unwrap_or_default()
});
let snippets_json = state.snippets.read_with(|all_snippets| {
let filtered: Vec<_> = all_snippets.iter().filter(|s| {
matches.iter().any(|(id, typ, _, _, _)| id == &s.name && typ == "snippet")
}).collect();
serde_json::to_value(&filtered).unwrap_or_default()
});
let adrs_json = state.adrs.read_with(|all_adrs| {
let filtered: Vec<_> = all_adrs.iter().filter(|a| {
matches.iter().any(|(id, typ, _, _, _)| id == &a.id && typ == "adr")
}).collect();
serde_json::to_value(&filtered).unwrap_or_default()
});
let q = req.query;
let tech_debts_json = state.tech_debts.read_with(|debts| {
let filtered: Vec<_> = debts.iter().filter(|d| {
req.namespace.as_ref().is_none_or(|ns| d.namespace == *ns)
&& (contains_ignore_ascii_case(&d.description, &q)
|| contains_ignore_ascii_case(&d.ideal_solution, &q))
}).collect();
serde_json::to_value(&filtered).unwrap_or_default()
});
let memos_json = state.handoff_memos.read_with(|memos| {
let filtered: Vec<_> = memos.iter().filter(|m| {
req.namespace.as_ref().is_none_or(|ns| m.namespace == *ns)
&& contains_ignore_ascii_case(&m.content, &q)
}).collect();
serde_json::to_value(&filtered).unwrap_or_default()
});
let error_fixes_json = state.error_fixes.read_with(|fixes| {
let filtered: Vec<_> = fixes.iter().filter(|f| {
contains_ignore_ascii_case(&f.signature, &q)
|| contains_ignore_ascii_case(&f.solution, &q)
}).collect();
serde_json::to_value(&filtered).unwrap_or_default()
});
let report = serde_json::json!({
"knowledge_graph": kg_json,
"tasks": tasks_json,
"snippets": snippets_json,
"adrs": adrs_json,
"tech_debts": tech_debts_json,
"handoff_memos": memos_json,
"error_fixes": error_fixes_json
});
Ok(report.to_string())
}
}
pub struct GetProjectHealthHandler;
#[async_trait]
impl McpTool for GetProjectHealthHandler {
fn name(&self) -> &'static str {
"get_project_health"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<GetProjectHealthTool>(
"get_project_health",
"Execute get_project_health",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: GetProjectHealthTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let active_tasks = state.tasks.read_with(|tasks| tasks.iter().filter(|t| t.status != "done").count());
let unresolved_debt = state.tech_debts.read_with(|debts| debts.iter().filter(|d| d.namespace == req.namespace && !d.is_resolved).count());
let unread_memos = state.handoff_memos.read_with(|memos| memos.iter().filter(|m| m.namespace == req.namespace).count());
let active_milestones = state.milestones.read_with(|milestones| milestones.iter().filter(|m| m.namespace == req.namespace && m.status != "done").count());
let remaining_checklists = state.pr_checklists.read_with(|checklists| checklists.iter().filter(|c| c.namespace == req.namespace).count());
let report = serde_json::json!({
"active_tasks": active_tasks,
"unresolved_tech_debt": unresolved_debt,
"unread_handoff_memos": unread_memos,
"active_milestones": active_milestones,
"remaining_pr_checklist_items": remaining_checklists
});
Ok(report.to_string())
}
}
use crate::handlers_v2::utils::*;
-264
View File
@@ -1,264 +0,0 @@
use crate::models::*;
use crate::router::McpTool;
use crate::state::MemoryState;
use crate::tools::*;
use async_trait::async_trait;
use serde_json::Value;
use std::collections::HashSet;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
pub struct AddStickyNoteHandler;
#[async_trait]
impl McpTool for AddStickyNoteHandler {
fn name(&self) -> &'static str {
"add_sticky_note"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<AddStickyNoteTool>("add_sticky_note", "Execute add_sticky_note")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: AddStickyNoteTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
state.sticky.modify(|notes| {
notes.push(StickyNote {
timestamp: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
content: req.content,
});
});
Ok("Sticky note added.".to_string())
}
}
pub struct ReadStickyNotesHandler;
#[async_trait]
impl McpTool for ReadStickyNotesHandler {
fn name(&self) -> &'static str {
"read_sticky_notes"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ReadStickyNotesTool>(
"read_sticky_notes",
"Execute read_sticky_notes",
)
}
async fn execute(&self, _args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let data = state.sticky.read_with(|s| serde_json::to_string(s).unwrap_or_else(|_| "[]".to_string()));
Ok(data.to_string())
}
}
pub struct DeleteStickyNoteHandler;
#[async_trait]
impl McpTool for DeleteStickyNoteHandler {
fn name(&self) -> &'static str {
"delete_sticky_note"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<DeleteStickyNoteTool>(
"delete_sticky_note",
"Execute delete_sticky_note",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: DeleteStickyNoteTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let mut success = false;
state.sticky.modify(|notes| {
if req.index > 0 && req.index <= notes.len() {
notes.remove(req.index - 1);
success = true;
}
});
if success {
Ok("Sticky note deleted.".to_string())
} else {
Err("Invalid sticky note index.".to_string())
}
}
}
pub struct ClearStickyNotesHandler;
#[async_trait]
impl McpTool for ClearStickyNotesHandler {
fn name(&self) -> &'static str {
"clear_sticky_notes"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ClearStickyNotesTool>(
"clear_sticky_notes",
"Execute clear_sticky_notes",
)
}
async fn execute(&self, _args: Value, state: Arc<MemoryState>) -> Result<String, String> {
state.sticky.modify(|notes| {
notes.clear();
});
Ok("All sticky notes cleared.".to_string())
}
}
pub struct LeaveHandoffMemoHandler;
#[async_trait]
impl McpTool for LeaveHandoffMemoHandler {
fn name(&self) -> &'static str {
"leave_handoff_memo"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<LeaveHandoffMemoTool>(
"leave_handoff_memo",
"Execute leave_handoff_memo",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: LeaveHandoffMemoTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
state.handoff_memos.modify(|memos| {
memos.push(crate::models::HandoffMemo {
id: uuid::Uuid::new_v4().to_string(),
author: "agy".to_string(),
content: req.content,
namespace: req.namespace,
timestamp: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
})
});
Ok("Handoff memo left".to_string())
}
}
pub struct ReadHandoffMemosHandler;
#[async_trait]
impl McpTool for ReadHandoffMemosHandler {
fn name(&self) -> &'static str {
"read_handoff_memos"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ReadHandoffMemosTool>(
"read_handoff_memos",
"Execute read_handoff_memos",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: ReadHandoffMemosTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let data = state.handoff_memos.read_with(|items| {
let filtered: Vec<_> = items.iter().filter(|i| {
if let Some(ns) = &req.namespace {
&i.namespace == ns
} else {
true
}
}).collect();
serde_json::to_string(&filtered).unwrap_or_default()
});
Ok(data.to_string())
}
}
pub struct ClearHandoffMemosHandler;
#[async_trait]
impl McpTool for ClearHandoffMemosHandler {
fn name(&self) -> &'static str {
"clear_handoff_memos"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ClearHandoffMemosTool>(
"clear_handoff_memos",
"Execute clear_handoff_memos",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: ClearHandoffMemosTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let ids: HashSet<_> = req.ids.into_iter().collect();
state
.handoff_memos
.modify(|memos| memos.retain(|m| !ids.contains(&m.id)));
Ok("Handoff memos cleared".to_string())
}
}
pub struct AddSessionSummaryHandler;
#[async_trait]
impl McpTool for AddSessionSummaryHandler {
fn name(&self) -> &'static str {
"add_session_summary"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<AddSessionSummaryTool>(
"add_session_summary",
"Execute add_session_summary",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: AddSessionSummaryTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
state.session_summaries.modify(|summaries| {
summaries.push(crate::models::SessionSummary {
summary: req.summary,
namespace: req.namespace,
timestamp: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
})
});
Ok("Session summary added".to_string())
}
}
pub struct GenerateStandupReportHandler;
#[async_trait]
impl McpTool for GenerateStandupReportHandler {
fn name(&self) -> &'static str {
"generate_standup_report"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<GenerateStandupReportTool>(
"generate_standup_report",
"Execute generate_standup_report",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: GenerateStandupReportTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let cutoff = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.saturating_sub(req.hours_lookback * 3600);
let tasks = state.tasks.read_with(|items| items.iter().filter(|t| t.updated_at >= cutoff).cloned().collect::<Vec<_>>());
let changes = state.ledger.read_with(|items| items.iter().filter(|c| c.timestamp >= cutoff).cloned().collect::<Vec<_>>());
let summaries = state.session_summaries.read_with(|items| items.iter().filter(|s| s.namespace == req.namespace && s.timestamp >= cutoff).cloned().collect::<Vec<_>>());
let report = serde_json::json!({ "tasks_updated": tasks, "code_changes": changes, "session_summaries": summaries });
Ok(report.to_string())
}
}
-472
View File
@@ -1,472 +0,0 @@
use crate::models::*;
use crate::router::McpTool;
use crate::state::MemoryState;
use crate::tools::*;
use async_trait::async_trait;
use serde_json::Value;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
pub struct AddTaskHandler;
#[async_trait]
impl McpTool for AddTaskHandler {
fn name(&self) -> &'static str {
"add_task"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<AddTaskTool>("add_task", "Execute add_task")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: AddTaskTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let task_id = uuid::Uuid::new_v4().to_string();
let parent_id = req.parent_id.clone();
let deps = req.dependencies.clone().unwrap_or_default();
let task = Task {
id: task_id.clone(),
title: req.title,
status: "pending".to_string(),
description: req.description,
created_at: now,
updated_at: now,
git_branch: req.git_branch,
parent_id,
dependencies: deps,
acceptance_criteria: vec![],
};
let idx = state.search_index.read().unwrap_or_else(|e| e.into_inner()).clone();
let _ = idx.index_task(&task).await;
state.tasks.modify(|tasks| {
tasks.push(task);
});
Ok(format!("Task added with ID: {}", task_id).to_string())
}
}
pub struct DeleteTaskHandler;
#[async_trait]
impl McpTool for DeleteTaskHandler {
fn name(&self) -> &'static str {
"delete_task"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<DeleteTaskTool>("delete_task", "Execute delete_task")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: DeleteTaskTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let mut deleted_count = 0;
state.tasks.modify(|tasks| {
let initial_len = tasks.len();
// Collect IDs of tasks to delete (this task + all its recursive children)
let mut to_delete = std::collections::HashSet::new();
to_delete.insert(req.id.clone());
let mut children_map: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for t in tasks.iter() {
if let Some(pid) = &t.parent_id {
children_map
.entry(pid.clone())
.or_default()
.push(t.id.clone());
}
}
let mut queue = std::collections::VecDeque::new();
queue.push_back(req.id.clone());
while let Some(curr) = queue.pop_front() {
if to_delete.insert(curr.clone())
&& let Some(children) = children_map.get(&curr)
{
queue.extend(children.iter().cloned());
}
}
tasks.retain(|t| !to_delete.contains(&t.id));
deleted_count = initial_len - tasks.len();
});
if deleted_count > 0 {
Ok(vec![
format!("Deleted task and its children ({} total).", deleted_count).to_string(),
][0]
.clone())
} else {
Ok("Task not found.".to_string())
}
}
}
pub struct UpdateTaskStatusHandler;
#[async_trait]
impl McpTool for UpdateTaskStatusHandler {
fn name(&self) -> &'static str {
"update_task_status"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<UpdateTaskStatusTool>(
"update_task_status",
"Execute update_task_status",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: UpdateTaskStatusTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let mut found = false;
let mut blocked = false;
let mut blocker_details = String::new();
let target_status = req.status.to_lowercase();
state.tasks.modify(|tasks| {
// Find target task
let mut target_id = String::new();
if let Some(t) = tasks.iter().find(|t| t.id == req.id || t.title == req.id) {
target_id = t.id.clone();
}
if target_id.is_empty() {
return;
}
found = true;
if target_status == "done" || target_status == "completed" {
// 1. Check Acceptance Criteria
if let Some(t) = tasks.iter().find(|t| t.id == target_id)
&& t.acceptance_criteria.iter().any(|c| !c.is_met)
{
blocked = true;
blocker_details = "Unmet acceptance criteria exist.".to_string();
}
// 2. Check dependencies
if !blocked {
let mut uncompleted_deps = Vec::new();
if let Some(t) = tasks.iter().find(|t| t.id == target_id) {
for dep_id in &t.dependencies {
if let Some(dep_task) = tasks.iter().find(|dt| dt.id == *dep_id)
&& dep_task.status != "completed"
&& dep_task.status != "done"
{
uncompleted_deps.push(dep_task.title.clone());
}
}
}
if !uncompleted_deps.is_empty() {
blocked = true;
blocker_details =
format!("Blocked by dependencies: {}", uncompleted_deps.join(", "));
}
}
// 3. Check child tasks
if !blocked {
let mut uncompleted_children = Vec::new();
for child in tasks
.iter()
.filter(|t| t.parent_id.as_ref() == Some(&target_id))
{
if child.status != "completed" && child.status != "done" {
uncompleted_children.push(child.title.clone());
}
}
if !uncompleted_children.is_empty() {
blocked = true;
blocker_details = format!(
"Blocked by child tasks: {}",
uncompleted_children.join(", ")
);
}
}
}
if !blocked {
// Apply update
if let Some(t) = tasks.iter_mut().find(|t| t.id == target_id) {
t.status = target_status.clone();
t.updated_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
}
// Cascade cancellation to children
if target_status == "cancelled" || target_status == "abandoned" {
let mut children_map: std::collections::HashMap<usize, Vec<usize>> =
std::collections::HashMap::new();
// First pass: map string ID to index to build the adjacency list by index
let mut id_to_idx = std::collections::HashMap::new();
for (idx, t) in tasks.iter().enumerate() {
id_to_idx.insert(t.id.as_str(), idx);
}
for (idx, t) in tasks.iter().enumerate() {
if let Some(pid) = &t.parent_id
&& let Some(&p_idx) = id_to_idx.get(pid.as_str()) {
children_map.entry(p_idx).or_default().push(idx);
}
}
if let Some(&start_idx) = id_to_idx.get(target_id.as_str()) {
let mut queue = std::collections::VecDeque::new();
queue.push_back(start_idx);
while let Some(curr) = queue.pop_front() {
if let Some(child_indices) = children_map.get(&curr) {
for &idx in child_indices {
if tasks[idx].status != "completed"
&& tasks[idx].status != target_status
{
tasks[idx].status = target_status.clone();
queue.push_back(idx);
}
}
}
}
}
}
}
});
if blocked {
Ok(format!(
"Error: Cannot transition task. {}",
blocker_details
))
} else if found {
Ok("Task status updated.".to_string())
} else {
Ok("Task not found.".to_string())
}
}
}
pub struct ListActiveTasksHandler;
#[async_trait]
impl McpTool for ListActiveTasksHandler {
fn name(&self) -> &'static str {
"list_active_tasks"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ListActiveTasksTool>(
"list_active_tasks",
"Execute list_active_tasks",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: ListActiveTasksTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let data = state.tasks.read_with(|tasks| {
let filtered: Vec<_> = tasks.iter().filter(|t| {
let status_match = t.status != "done";
let branch_match = match &req.git_branch {
Some(branch) => t.git_branch.is_none() || t.git_branch.as_deref() == Some(branch.as_str()),
None => true,
};
status_match && branch_match
}).collect();
serde_json::to_string(&filtered).unwrap_or_default()
});
Ok(data)
}
}
pub struct SetAcceptanceCriteriaHandler;
#[async_trait]
impl McpTool for SetAcceptanceCriteriaHandler {
fn name(&self) -> &'static str {
"set_acceptance_criteria"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<SetAcceptanceCriteriaTool>(
"set_acceptance_criteria",
"Execute set_acceptance_criteria",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: SetAcceptanceCriteriaTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let mut success = false;
state.tasks.modify(|tasks| {
if let Some(task) = tasks.iter_mut().rev().find(|t| t.title == req.task_title) {
task.acceptance_criteria = req
.criteria
.into_iter()
.map(|desc| crate::models::AcceptanceCriteria {
id: uuid::Uuid::new_v4().to_string(),
description: desc,
is_met: false,
})
.collect();
task.updated_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
success = true;
}
});
if success {
Ok("Acceptance criteria set successfully.".to_string())
} else {
Ok("Task not found.".to_string())
}
}
}
pub struct VerifyAcceptanceCriteriaHandler;
#[async_trait]
impl McpTool for VerifyAcceptanceCriteriaHandler {
fn name(&self) -> &'static str {
"verify_acceptance_criteria"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<VerifyAcceptanceCriteriaTool>(
"verify_acceptance_criteria",
"Execute verify_acceptance_criteria",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: VerifyAcceptanceCriteriaTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let mut success = false;
let mut already_met = false;
state.tasks.modify(|tasks| {
if let Some(task) = tasks.iter_mut().find(|t| t.id == req.task_id)
&& let Some(ac) = task
.acceptance_criteria
.iter_mut()
.find(|c| c.id == req.criteria || c.description == req.criteria)
{
if ac.is_met {
already_met = true;
} else {
ac.is_met = true;
success = true;
task.updated_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
}
}
});
if success {
Ok(format!(
"Acceptance criteria verified with proof: {}",
req.proof
))
} else if already_met {
Ok("Acceptance criteria was already met.".to_string())
} else {
Ok("Acceptance criteria or task not found.".to_string())
}
}
}
pub struct AddMilestoneHandler;
#[async_trait]
impl McpTool for AddMilestoneHandler {
fn name(&self) -> &'static str {
"add_milestone"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<AddMilestoneTool>("add_milestone", "Execute add_milestone")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: AddMilestoneTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
state.milestones.modify(|ms| {
ms.push(crate::models::Milestone {
id: uuid::Uuid::new_v4().to_string(),
title: req.title,
status: "pending".to_string(),
namespace: req.namespace,
target_date: None,
})
});
Ok("Milestone added".to_string())
}
}
pub struct UpdateMilestoneHandler;
#[async_trait]
impl McpTool for UpdateMilestoneHandler {
fn name(&self) -> &'static str {
"update_milestone"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<UpdateMilestoneTool>("update_milestone", "Execute update_milestone")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: UpdateMilestoneTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let mut found = false;
state.milestones.modify(|ms| {
for m in ms.iter_mut() {
if m.id == req.id {
m.status = req.status.clone();
found = true;
break;
}
}
});
if found {
Ok("Milestone updated".to_string())
} else {
Ok("Milestone not found".to_string())
}
}
}
pub struct ListMilestonesHandler;
#[async_trait]
impl McpTool for ListMilestonesHandler {
fn name(&self) -> &'static str {
"list_milestones"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ListMilestonesTool>("list_milestones", "Execute list_milestones")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let req: ListMilestonesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let data = state.milestones.read_with(|items| {
let filtered: Vec<_> = items.iter().filter(|i| {
if let Some(ns) = &req.namespace {
&i.namespace == ns
} else {
true
}
}).collect();
serde_json::to_string(&filtered).unwrap_or_default()
});
Ok(data)
}
}
-9
View File
@@ -1,9 +0,0 @@
pub fn contains_ignore_ascii_case(haystack: &str, needle: &str) -> bool {
if needle.is_empty() {
return true;
}
haystack
.as_bytes()
.windows(needle.len())
.any(|w| w.eq_ignore_ascii_case(needle.as_bytes()))
}
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff. Show more