Compare commits
10
Commits
05e84e98bd
...
1fd1d119e6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fd1d119e6 | ||
|
|
67b6a0407e | ||
|
|
28f70aafdb | ||
|
|
e057dc0fdd | ||
|
|
721f564910 | ||
|
|
9a8b6e52e9 | ||
|
|
7f0286dc40 | ||
|
|
0de78e8fae | ||
|
|
0ecd862e89 | ||
|
|
938b8dbf45 |
No files matched your search
Generated
+132
-4
@@ -157,6 +157,15 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bincode"
|
||||||
|
version = "1.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.13.1"
|
version = "2.13.1"
|
||||||
@@ -392,6 +401,20 @@ dependencies = [
|
|||||||
"syn 3.0.5",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dashmap"
|
||||||
|
version = "6.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"crossbeam-utils",
|
||||||
|
"hashbrown 0.14.5",
|
||||||
|
"lock_api",
|
||||||
|
"once_cell",
|
||||||
|
"parking_lot_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "datasketches"
|
name = "datasketches"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -626,6 +649,12 @@ version = "0.3.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.14.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.16.1"
|
version = "0.16.1"
|
||||||
@@ -981,7 +1010,7 @@ version = "0.16.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
|
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashbrown",
|
"hashbrown 0.16.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1002,18 +1031,33 @@ version = "0.8.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mcp-memory-linux-nvim"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"rmp-serde",
|
||||||
|
"rmpv",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mcp-memory-server"
|
name = "mcp-memory-server"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
|
"bincode",
|
||||||
"clap",
|
"clap",
|
||||||
|
"dashmap",
|
||||||
"dirs",
|
"dirs",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"glob",
|
"glob",
|
||||||
|
"redb",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"schemars",
|
"schemars 1.2.2",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tantivy",
|
"tantivy",
|
||||||
@@ -1036,6 +1080,18 @@ dependencies = [
|
|||||||
"tokio-util",
|
"tokio-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mcp-memory-win-nvim"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"rmp-serde",
|
||||||
|
"rmpv",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "measure_time"
|
name = "measure_time"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -1357,6 +1413,15 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redb"
|
||||||
|
version = "4.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "de6c3b63e007e90ce536ec2ae4690826136a20ec8dbbbb400daef1bb999d2e36"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -1481,6 +1546,34 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rmp"
|
||||||
|
version = "0.8.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rmp-serde"
|
||||||
|
version = "1.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
|
||||||
|
dependencies = [
|
||||||
|
"rmp",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rmpv"
|
||||||
|
version = "1.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417"
|
||||||
|
dependencies = [
|
||||||
|
"rmp",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rust-stemmers"
|
name = "rust-stemmers"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -1557,6 +1650,18 @@ version = "1.0.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "schemars"
|
||||||
|
version = "0.8.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
|
||||||
|
dependencies = [
|
||||||
|
"dyn-clone",
|
||||||
|
"schemars_derive 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schemars"
|
name = "schemars"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -1565,11 +1670,23 @@ checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"dyn-clone",
|
"dyn-clone",
|
||||||
"ref-cast",
|
"ref-cast",
|
||||||
"schemars_derive",
|
"schemars_derive 1.2.2",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "schemars_derive"
|
||||||
|
version = "0.8.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"serde_derive_internals 0.29.1",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schemars_derive"
|
name = "schemars_derive"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -1578,7 +1695,7 @@ checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"serde_derive_internals",
|
"serde_derive_internals 0.30.0",
|
||||||
"syn 3.0.5",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1618,6 +1735,17 @@ dependencies = [
|
|||||||
"syn 3.0.5",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive_internals"
|
||||||
|
version = "0.29.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_derive_internals"
|
name = "serde_derive_internals"
|
||||||
version = "0.30.0"
|
version = "0.30.0"
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,8 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
"server",
|
"server",
|
||||||
"stub"
|
"stub",
|
||||||
|
"win-nvim",
|
||||||
|
"linux-nvim"
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
@@ -23,6 +23,11 @@ Copy-Item target\release\mcp-memory-stub.exe C:\Users\reazul.ashraf\.local\bin\m
|
|||||||
# Add this to your PowerShell profile:
|
# 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 }
|
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 }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Shutting Down:** If you need to stop the background daemon (e.g., to replace the executable after a recompile), use the built-in `--exit` flag instead of forcefully killing the process.
|
||||||
|
```powershell
|
||||||
|
mcp-memory-server.exe --exit
|
||||||
|
```
|
||||||
`
|
`
|
||||||
**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):
|
**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
|
`json
|
||||||
@@ -51,7 +56,7 @@ Update your WSL ~/.gemini/config/mcp_config.json:
|
|||||||
"command": "/home/riz/.local/bin/mcp-memory-stub",
|
"command": "/home/riz/.local/bin/mcp-memory-stub",
|
||||||
"args": [
|
"args": [
|
||||||
"--target", "http://127.0.0.1:3000",
|
"--target", "http://127.0.0.1:3000",
|
||||||
"--wake-cmd", "/mnt/c/Windows/System32/cmd.exe /c start /B C:\\Users\\reazul.ashraf\\.local\\bin\\mcp-memory-server.exe --daemon"
|
"--wake-cmd", "/mnt/c/Users/reazul.ashraf/.local/bin/mcp-memory-server.exe --daemon"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,3 +82,18 @@ You can also programmatically query these live metrics via the API endpoint:
|
|||||||
|
|
||||||
## Further Reading
|
## Further Reading
|
||||||
For a deep dive into the architecture, Write-Ahead Logging (WAL), locking mechanisms, and the HTTP SSE event loop, consult the design.md file in this repository.
|
For a deep dive into the architecture, Write-Ahead Logging (WAL), locking mechanisms, 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, you must add the provided `gemini-integration.lua` file to your Neovim `lua/` configuration directory and require it in your `init.lua`:
|
||||||
|
```lua
|
||||||
|
require('gemini-integration')
|
||||||
|
```
|
||||||
|
This script allows Neovim to broadcast its active socket to `~/.gemini/active_nvim.txt` when it gains focus, allowing the MCP servers to bridge the connection.
|
||||||
|
|
||||||
|
These MCP servers provide the following two-way integration tools:
|
||||||
|
* **`nvim_goto_line`**: Open files and jump cursors directly from the LLM.
|
||||||
|
* **`nvim_set_diagnostics`**: Push inline code review warnings as virtual text.
|
||||||
|
* **`nvim_get_active_buffer`**: Read live, unsaved buffer contents.
|
||||||
|
* **`nvim_get_cursor`**: Fetch precise line/column coordinates.
|
||||||
|
* **`nvim_get_visual_selection`**: Read highlighted code blocks.
|
||||||
@@ -1,12 +1,47 @@
|
|||||||
@echo off
|
@echo off
|
||||||
echo Building mcp-memory in release mode...
|
setlocal
|
||||||
cargo build --release
|
|
||||||
|
|
||||||
if %ERRORLEVEL% EQU 0 (
|
echo Building mcp-memory (server and stub) for Windows...
|
||||||
echo.
|
rustup run stable cargo build --release --workspace --exclude mcp-memory-linux-nvim
|
||||||
echo Build successful!
|
if %ERRORLEVEL% NEQ 0 (
|
||||||
echo Executable is located at: target\release\mcp-memory.exe
|
echo Windows build failed!
|
||||||
) else (
|
exit /b %ERRORLEVEL%
|
||||||
echo.
|
|
||||||
echo Build failed with error code %ERRORLEVEL%.
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
echo Building mcp-memory (server and stub) for Linux (WSL)...
|
||||||
|
rustup run stable cargo zigbuild --release --workspace --exclude mcp-memory-win-nvim --target x86_64-unknown-linux-musl
|
||||||
|
if %ERRORLEVEL% NEQ 0 (
|
||||||
|
echo Linux build failed!
|
||||||
|
exit /b %ERRORLEVEL%
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Build successful! Gracefully shutting down running servers...
|
||||||
|
:: Attempt shutdown via CLI flag
|
||||||
|
if exist "C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe" (
|
||||||
|
"C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe" --exit >nul 2>&1
|
||||||
|
)
|
||||||
|
:: Fallback shutdown via HTTP endpoint
|
||||||
|
curl -X POST http://127.0.0.1:3000/shutdown -s -o nul >nul 2>&1
|
||||||
|
|
||||||
|
:: Give the server a moment to flush checkpoints and release file locks
|
||||||
|
timeout /t 2 /nobreak >nul
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Copying Windows executables...
|
||||||
|
copy /Y target\release\mcp-memory-server.exe "C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe"
|
||||||
|
copy /Y target\release\mcp-memory-stub.exe "C:\Users\reazul.ashraf\.local\bin\mcp-memory-stub.exe"
|
||||||
|
copy /Y target\release\mcp-memory-win-nvim.exe "C:\Users\reazul.ashraf\.local\bin\mcp-memory-win-nvim.exe"
|
||||||
|
|
||||||
|
echo Copying Linux executables to WSL...
|
||||||
|
wsl -d Ubuntu -e bash -c "cp /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory/target/x86_64-unknown-linux-musl/release/mcp-memory-server /home/riz/.local/bin/mcp-memory-server && chmod +x /home/riz/.local/bin/mcp-memory-server"
|
||||||
|
wsl -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 /home/riz/.local/bin/mcp-memory-stub && chmod +x /home/riz/.local/bin/mcp-memory-stub"
|
||||||
|
wsl -d Ubuntu -e bash -c "cp /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory/target/x86_64-unknown-linux-musl/release/mcp-memory-linux-nvim /home/riz/.local/bin/mcp-memory-linux-nvim && chmod +x /home/riz/.local/bin/mcp-memory-linux-nvim"
|
||||||
|
|
||||||
|
echo Copying instructions.md...
|
||||||
|
copy /Y instructions.md "C:\Users\reazul.ashraf\.gemini\antigravity-cli\mcp\memory\instructions.md"
|
||||||
|
wsl -d Ubuntu -e bash -c "mkdir -p /home/riz/.gemini/antigravity-cli/mcp/memory && cp /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory/instructions.md /home/riz/.gemini/antigravity-cli/mcp/memory/instructions.md"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Build and deployment complete!
|
||||||
|
endlocal
|
||||||
@@ -63,9 +63,9 @@ econcile_worker) on a 5-second polling loop responsible for:
|
|||||||
* **Ephemeral Data Cleanup:** Automatically expiring and purging sticky_notes.json that are older than 24 hours.
|
* **Ephemeral Data Cleanup:** Automatically expiring and purging sticky_notes.json that are older than 24 hours.
|
||||||
|
|
||||||
## 8. The Gate System (Push Safety Verification)
|
## 8. The Gate System (Push Safety Verification)
|
||||||
The binary includes dedicated CLI subcommands (gate set and gate verify) that interact with a persistent gates.json store to enforce safety policies (like ensuring tests pass before a git push).
|
The binary supports a flexible safety gate authorization system, accessible both via the HTTP API and CLI subcommands.
|
||||||
* **mcp-memory-stub gate set**: Records an authorization status (authorized, blocked, or pending) for a specific target and namespace, alongside optional failure reasons and parameters.
|
* **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`.
|
||||||
* **mcp-memory-stub gate verify**: Evaluates a pending action against the gate store. It returns exit code if authorized, 1 if explicitly blocked, and 2 if no gate record exists. It also supports a --consume flag to immediately revoke the authorization after a successful check.
|
* **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
|
## 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.
|
The physical storage location of the knowledge graph and all persistent stores is strictly controlled by the MCP_MEMORY_STORE_DIR environment variable.
|
||||||
@@ -96,7 +96,8 @@ Every time a Windows gy session starts, it blindly spawns mcp-memory-stub.exe a
|
|||||||
### WSL (Linux) Client Lifecycle (Permanent Stub)
|
### WSL (Linux) Client Lifecycle (Permanent Stub)
|
||||||
To provide a seamless experience without complex configuration drift, the WSL environment utilizes a native Linux binary (`/home/riz/.local/bin/mcp-memory-stub`) that acts as a **Permanent Stub**.
|
To provide a seamless experience without complex configuration drift, the WSL environment utilizes a native Linux binary (`/home/riz/.local/bin/mcp-memory-stub`) that acts as a **Permanent Stub**.
|
||||||
* **Transparent Proxying:** The WSL `agy` CLI spawns this Linux binary via standard `stdio`. The binary immediately proxies all `stdio` JSON-RPC requests over HTTP to the Windows Leader at `http://127.0.0.1:3000/messages`, handling SSE streams transparently.
|
* **Transparent Proxying:** The WSL `agy` CLI spawns this Linux binary via standard `stdio`. The binary immediately proxies all `stdio` JSON-RPC requests over HTTP to the Windows Leader at `http://127.0.0.1:3000/messages`, handling SSE streams transparently.
|
||||||
* **Startup via Interop:** Upon launch, the Linux Stub pings the Windows host. If port 3000 is dead, the Linux binary automatically executes WSL interop (`cmd.exe /c start /B C:\Users\reazul.ashraf\.local\bin\mcp-memory.exe`) 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.
|
||||||
|
* **Startup via Interop:** Upon launch, the Linux Stub pings the Windows host. If port 3000 is dead, the Linux binary automatically executes the provided `wake-cmd` (e.g. `/mnt/c/Users/reazul.ashraf/.local/bin/mcp-memory-server.exe --daemon`) to silently wake up the Windows Leader before commencing the proxy loop.
|
||||||
* **Zero I/O Penalty:** This ensures the Linux binary never directly touches the Windows NTFS files, reserving all heavy disk operations for the native Windows host.
|
* **Zero I/O Penalty:** 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
|
## 13. Cargo Workspace & Binary Artifacts
|
||||||
To optimize for different environments, the codebase is structured as a Cargo Workspace containing two distinct crates:
|
To optimize for different environments, the codebase is structured as a Cargo Workspace containing two distinct crates:
|
||||||
@@ -113,3 +114,4 @@ To optimize for different environments, the codebase is structured as a Cargo Wo
|
|||||||
eqwest and okio).
|
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.
|
* **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.
|
* **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.
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import re
|
||||||
|
with open(r'C:\Users\reazul.ashraf\workspace\rust\mcp-memory\server\src\main.rs', 'r', encoding='utf-8') as f:
|
||||||
|
text = f.read()
|
||||||
|
|
||||||
|
text = re.sub(r'#\[cfg\(not\(target_os = "windows"\)\)\].*?return Ok\(\(\);\n \}', '', text, flags=re.DOTALL)
|
||||||
|
|
||||||
|
with open(r'C:\Users\reazul.ashraf\workspace\rust\mcp-memory\server\src\main.rs', 'w', encoding='utf-8') as f:
|
||||||
|
f.write(text)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- gemini-integration.lua
|
||||||
|
-- Registers the active Neovim instance for the Antigravity MCP Server
|
||||||
|
vim.api.nvim_create_autocmd({"VimEnter", "FocusGained"}, {
|
||||||
|
callback = function()
|
||||||
|
-- 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
|
||||||
|
local home = os.getenv("HOME")
|
||||||
|
if not home then
|
||||||
|
home = os.getenv("USERPROFILE")
|
||||||
|
end
|
||||||
|
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
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
desc = "Register active Neovim instance for Gemini CLI",
|
||||||
|
})
|
||||||
@@ -131,3 +131,10 @@ When starting a new session, get a numerical aggregate of the project's current
|
|||||||
To maintain absolute traceability, we link memory items directly to the exact git commits they occurred on.
|
To maintain absolute traceability, we link memory items directly to the exact git commits they occurred on.
|
||||||
- When calling **log_code_change**, **log_error_fix**, or **log_tech_debt**, you should execute git rev-parse HEAD and git branch --show-current in the project directory first (if it's a git repo).
|
- When calling **log_code_change**, **log_error_fix**, or **log_tech_debt**, you should execute git rev-parse HEAD and git branch --show-current in the project directory first (if it's a git repo).
|
||||||
- Pass the resulting hash and branch name into the git_commit and git_branch arguments of those tools to permanently link the memory item to the VCS state.
|
- Pass the resulting hash and branch name into the git_commit and git_branch arguments of those tools to permanently link the memory item to the VCS state.
|
||||||
|
|
||||||
|
## 13. Server Lifecycle & Updates (Graceful Shutdown)
|
||||||
|
When instructed to restart, update, or stop the mcp-memory-server binary, never use brute-force OS kill commands (e.g. Stop-Process, pkill, kill).
|
||||||
|
- Why: The server uses asynchronous checkpointing. Force-killing it bypasses the shutdown flush, risking data loss in the master JSON graph, and can lead to lingering file locks.
|
||||||
|
- How: Always use the built-in graceful shutdown mechanisms:
|
||||||
|
1. CLI Flag: mcp-memory-server --exit (or --restart)
|
||||||
|
2. HTTP Endpoint: POST http://127.0.0.1:3000/shutdown
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[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"
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
mod mcp;
|
||||||
|
|
||||||
|
use mcp::{read_message, send_response, send_error, JsonRpcResponse};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tokio::net::UnixStream;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
loop {
|
||||||
|
let msg = match read_message().await {
|
||||||
|
Some(m) => m,
|
||||||
|
None => break,
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let id = msg.id.clone().unwrap_or(json!(null));
|
||||||
|
|
||||||
|
match msg.method.as_str() {
|
||||||
|
"initialize" => {
|
||||||
|
send_response(JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
id,
|
||||||
|
result: Some(json!({
|
||||||
|
"capabilities": {},
|
||||||
|
"serverInfo": {
|
||||||
|
"name": "mcp-memory-linux-nvim",
|
||||||
|
"version": "0.1.0"
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
error: None,
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
"tools/list" => {
|
||||||
|
send_response(JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
id,
|
||||||
|
result: Some(json!({
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "nvim_goto_line",
|
||||||
|
"description": "Open a file and jump to a specific line",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"file": { "type": "string" },
|
||||||
|
"line": { "type": "integer" }
|
||||||
|
},
|
||||||
|
"required": ["file", "line"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "nvim_get_active_buffer",
|
||||||
|
"description": "Get the contents of the currently active Neovim buffer",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "nvim_get_cursor",
|
||||||
|
"description": "Get the current cursor position (line and column) in the active Neovim buffer",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "nvim_get_visual_selection",
|
||||||
|
"description": "Get the text that is currently highlighted or was last highlighted in Visual mode",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "nvim_set_diagnostics",
|
||||||
|
"description": "Push a diagnostic message (like an LSP warning) to a specific line in the active buffer",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"line": { "type": "integer" },
|
||||||
|
"message": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["line", "message"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})),
|
||||||
|
error: None,
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
"tools/call" => {
|
||||||
|
let params = msg.params.clone().unwrap_or(json!({}));
|
||||||
|
let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let args = params.get("arguments").cloned().unwrap_or(json!({}));
|
||||||
|
|
||||||
|
match name {
|
||||||
|
"nvim_goto_line" => {
|
||||||
|
let file = args.get("file").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(1);
|
||||||
|
|
||||||
|
let cmd = format!("edit {} | {} | normal! zz", file, line);
|
||||||
|
match send_nvim_command(&cmd).await {
|
||||||
|
Ok(_) => {
|
||||||
|
send_response(JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
id,
|
||||||
|
result: Some(json!({
|
||||||
|
"content": [
|
||||||
|
{ "type": "text", "text": format!("Successfully jumped to {}:{}", file, line) }
|
||||||
|
]
|
||||||
|
})),
|
||||||
|
error: None,
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
send_error(id, -32603, &format!("Failed to execute command: {}", e)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"nvim_get_active_buffer" => {
|
||||||
|
match get_nvim_active_buffer().await {
|
||||||
|
Ok(content) => {
|
||||||
|
send_response(JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
id,
|
||||||
|
result: Some(json!({
|
||||||
|
"content": [
|
||||||
|
{ "type": "text", "text": content }
|
||||||
|
]
|
||||||
|
})),
|
||||||
|
error: None,
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
send_error(id, -32603, &format!("Failed to get active buffer: {}", e)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"nvim_get_cursor" => {
|
||||||
|
match get_nvim_cursor().await {
|
||||||
|
Ok(content) => {
|
||||||
|
send_response(JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
id,
|
||||||
|
result: Some(json!({
|
||||||
|
"content": [
|
||||||
|
{ "type": "text", "text": content }
|
||||||
|
]
|
||||||
|
})),
|
||||||
|
error: None,
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
send_error(id, -32603, &format!("Failed to get cursor: {}", e)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"nvim_get_visual_selection" => {
|
||||||
|
match get_nvim_visual_selection().await {
|
||||||
|
Ok(content) => {
|
||||||
|
send_response(JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
id,
|
||||||
|
result: Some(json!({
|
||||||
|
"content": [
|
||||||
|
{ "type": "text", "text": content }
|
||||||
|
]
|
||||||
|
})),
|
||||||
|
error: None,
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
send_error(id, -32603, &format!("Failed to get visual selection: {}", e)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"nvim_set_diagnostics" => {
|
||||||
|
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(1);
|
||||||
|
let message = args.get("message").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
|
||||||
|
match set_nvim_diagnostics(line, message).await {
|
||||||
|
Ok(_) => {
|
||||||
|
send_response(JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
id,
|
||||||
|
result: Some(json!({
|
||||||
|
"content": [
|
||||||
|
{ "type": "text", "text": format!("Successfully set diagnostic on line {}", line) }
|
||||||
|
]
|
||||||
|
})),
|
||||||
|
error: None,
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
send_error(id, -32603, &format!("Failed to set diagnostic: {}", e)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
send_error(id, -32601, "Tool not found").await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Ignore other methods
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_socket_path() -> Result<String, String> {
|
||||||
|
// 1. Try active_nvim.txt first
|
||||||
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
|
let path = format!("{}/.gemini/active_nvim.txt", home);
|
||||||
|
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||||
|
let p = content.trim().to_string();
|
||||||
|
if !p.is_empty() {
|
||||||
|
return Ok(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback: search /tmp/nvim.*/0
|
||||||
|
if let Ok(entries) = std::fs::read_dir("/tmp") {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
if let Ok(name) = entry.file_name().into_string() {
|
||||||
|
if name.starts_with("nvim.") {
|
||||||
|
let path = entry.path().join("0");
|
||||||
|
if path.exists() {
|
||||||
|
return Ok(path.to_string_lossy().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err("Could not find Neovim socket".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
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 socket_path = get_socket_path().await?;
|
||||||
|
let mut stream = UnixStream::connect(socket_path).await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
rmpv::encode::write_value(&mut buf, &req).map_err(|e| e.to_string())?;
|
||||||
|
stream.write_all(&buf).await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
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()) && arr[1] == msgid {
|
||||||
|
return Ok(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
},
|
||||||
|
Err(_) => {
|
||||||
|
let n = stream.read(&mut chunk).await.map_err(|e| e.to_string())?;
|
||||||
|
if n == 0 {
|
||||||
|
return Err("Unexpected EOF from Neovim".to_string());
|
||||||
|
}
|
||||||
|
resp_buf.extend_from_slice(&chunk[..n]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_nvim_command(cmd: &str) -> Result<(), String> {
|
||||||
|
use rmpv::Value as RmpValue;
|
||||||
|
let req = RmpValue::Array(vec![
|
||||||
|
RmpValue::Integer(0.into()),
|
||||||
|
RmpValue::Integer(1.into()), // msgid
|
||||||
|
RmpValue::String("nvim_command".into()),
|
||||||
|
RmpValue::Array(vec![RmpValue::String(cmd.into())]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let resp = call_nvim(req).await?;
|
||||||
|
if let RmpValue::Array(arr) = resp {
|
||||||
|
if !arr[2].is_nil() {
|
||||||
|
return Err(format!("Neovim error: {:?}", arr[2]));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err("Invalid response".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_nvim_active_buffer() -> Result<String, String> {
|
||||||
|
use rmpv::Value as RmpValue;
|
||||||
|
let req = RmpValue::Array(vec![
|
||||||
|
RmpValue::Integer(0.into()),
|
||||||
|
RmpValue::Integer(2.into()), // msgid
|
||||||
|
RmpValue::String("nvim_buf_get_lines".into()),
|
||||||
|
RmpValue::Array(vec![
|
||||||
|
RmpValue::Integer(0.into()),
|
||||||
|
RmpValue::Integer(0.into()),
|
||||||
|
RmpValue::Integer((-1).into()),
|
||||||
|
RmpValue::Boolean(true),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let resp = call_nvim(req).await?;
|
||||||
|
if let RmpValue::Array(arr) = resp {
|
||||||
|
if !arr[2].is_nil() {
|
||||||
|
return Err(format!("Neovim error: {:?}", arr[2]));
|
||||||
|
}
|
||||||
|
if let RmpValue::Array(lines) = &arr[3] {
|
||||||
|
let mut text = String::new();
|
||||||
|
for line in lines {
|
||||||
|
if let RmpValue::String(s) = line {
|
||||||
|
if let Some(s) = s.as_str() {
|
||||||
|
text.push_str(s);
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err("Invalid response".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_nvim_cursor() -> Result<String, String> {
|
||||||
|
use rmpv::Value as RmpValue;
|
||||||
|
let req = RmpValue::Array(vec![
|
||||||
|
RmpValue::Integer(0.into()),
|
||||||
|
RmpValue::Integer(3.into()), // msgid
|
||||||
|
RmpValue::String("nvim_win_get_cursor".into()),
|
||||||
|
RmpValue::Array(vec![
|
||||||
|
RmpValue::Integer(0.into()),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let resp = call_nvim(req).await?;
|
||||||
|
if let RmpValue::Array(arr) = resp {
|
||||||
|
if !arr[2].is_nil() {
|
||||||
|
return Err(format!("Neovim error: {:?}", arr[2]));
|
||||||
|
}
|
||||||
|
if let RmpValue::Array(pos) = &arr[3] {
|
||||||
|
if pos.len() == 2 {
|
||||||
|
if let (RmpValue::Integer(row), RmpValue::Integer(col)) = (&pos[0], &pos[1]) {
|
||||||
|
return Ok(format!("Line: {}, Column: {}", row, col));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err("Invalid response".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_nvim_visual_selection() -> Result<String, String> {
|
||||||
|
let lua_script = r#"
|
||||||
|
local _, csrow, cscol, _ = unpack(vim.fn.getpos("'<"))
|
||||||
|
local _, cerow, cecol, _ = unpack(vim.fn.getpos("'>"))
|
||||||
|
local lines = vim.fn.getline(csrow, cerow)
|
||||||
|
if type(lines) == "table" then
|
||||||
|
return table.concat(lines, "\n")
|
||||||
|
else
|
||||||
|
return lines
|
||||||
|
end
|
||||||
|
"#;
|
||||||
|
|
||||||
|
use rmpv::Value as RmpValue;
|
||||||
|
let req = RmpValue::Array(vec![
|
||||||
|
RmpValue::Integer(0.into()),
|
||||||
|
RmpValue::Integer(4.into()), // msgid
|
||||||
|
RmpValue::String("nvim_exec_lua".into()),
|
||||||
|
RmpValue::Array(vec![
|
||||||
|
RmpValue::String(lua_script.into()),
|
||||||
|
RmpValue::Array(vec![]),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let resp = call_nvim(req).await?;
|
||||||
|
if let RmpValue::Array(arr) = resp {
|
||||||
|
if !arr[2].is_nil() {
|
||||||
|
return Err(format!("Neovim error: {:?}", arr[2]));
|
||||||
|
}
|
||||||
|
if let RmpValue::String(s) = &arr[3] {
|
||||||
|
if let Some(text) = s.as_str() {
|
||||||
|
return Ok(text.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err("Invalid response".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_nvim_diagnostics(line: i64, message: &str) -> Result<(), String> {
|
||||||
|
let escaped_message = message.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||||
|
let lua_script = format!(r#"
|
||||||
|
local ns = vim.api.nvim_create_namespace("gemini_diagnostics")
|
||||||
|
local diagnostics = {{{{
|
||||||
|
lnum = {} - 1,
|
||||||
|
col = 0,
|
||||||
|
severity = vim.diagnostic.severity.WARN,
|
||||||
|
message = "{}",
|
||||||
|
}}}}
|
||||||
|
vim.diagnostic.set(ns, 0, diagnostics, {{}})
|
||||||
|
"#, line, escaped_message);
|
||||||
|
|
||||||
|
use rmpv::Value as RmpValue;
|
||||||
|
let req = RmpValue::Array(vec![
|
||||||
|
RmpValue::Integer(0.into()),
|
||||||
|
RmpValue::Integer(5.into()), // msgid
|
||||||
|
RmpValue::String("nvim_exec_lua".into()),
|
||||||
|
RmpValue::Array(vec![
|
||||||
|
RmpValue::String(lua_script.into()),
|
||||||
|
RmpValue::Array(vec![]),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let resp = call_nvim(req).await?;
|
||||||
|
if let RmpValue::Array(arr) = resp {
|
||||||
|
if !arr[2].is_nil() {
|
||||||
|
return Err(format!("Neovim error: {:?}", arr[2]));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err("Invalid response".to_string())
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct JsonRpcRequest {
|
||||||
|
pub jsonrpc: String,
|
||||||
|
pub id: Option<Value>,
|
||||||
|
pub method: String,
|
||||||
|
pub params: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Debug, Clone)]
|
||||||
|
pub struct JsonRpcResponse {
|
||||||
|
pub jsonrpc: String,
|
||||||
|
pub id: Value,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub result: Option<Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read_message() -> Option<JsonRpcRequest> {
|
||||||
|
let mut stdin = BufReader::new(tokio::io::stdin());
|
||||||
|
let mut length = 0;
|
||||||
|
loop {
|
||||||
|
let mut line = String::new();
|
||||||
|
if stdin.read_line(&mut line).await.unwrap_or(0) == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
let mut buffer = vec![0; length];
|
||||||
|
stdin.read_exact(&mut buffer).await.unwrap_or(0);
|
||||||
|
serde_json::from_slice(&buffer).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_response(response: JsonRpcResponse) {
|
||||||
|
let msg = serde_json::to_string(&response).unwrap();
|
||||||
|
let payload = format!("Content-Length: {}\r\n\r\n{}", msg.len(), msg);
|
||||||
|
let mut stdout = tokio::io::stdout();
|
||||||
|
let _ = stdout.write_all(payload.as_bytes()).await;
|
||||||
|
let _ = stdout.flush().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_error(id: Value, code: i32, message: &str) {
|
||||||
|
send_response(JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
id,
|
||||||
|
result: None,
|
||||||
|
error: Some(serde_json::json!({"code": code, "message": message})),
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
@@ -6,10 +6,13 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait = "0.1.92"
|
async-trait = "0.1.92"
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
|
bincode = "1.3.3"
|
||||||
clap = { version = "4.6.6", features = ["derive"] }
|
clap = { version = "4.6.6", features = ["derive"] }
|
||||||
|
dashmap = "6.2.1"
|
||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
futures-util = "0.3.34"
|
futures-util = "0.3.34"
|
||||||
glob = "0.3.4"
|
glob = "0.3.4"
|
||||||
|
redb = "4.2.0"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["stream", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["stream", "rustls-tls"] }
|
||||||
schemars = "1.2.2"
|
schemars = "1.2.2"
|
||||||
serde = { version = "1.0.229", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
<style>
|
<style>
|
||||||
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; padding: 40px; background-color: #f0f2f5; color: #333; }
|
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; padding: 40px; background-color: #f0f2f5; color: #333; }
|
||||||
h1 { color: #2c3e50; text-align: center; margin-bottom: 30px; }
|
h1 { color: #2c3e50; text-align: center; margin-bottom: 30px; }
|
||||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; max-width: 800px; margin: 0 auto; }
|
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; max-width: 1000px; margin: 0 auto; }
|
||||||
.stat-card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); text-align: center; transition: transform 0.2s ease; }
|
.stat-card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); text-align: center; transition: transform 0.2s ease; }
|
||||||
.stat-card:hover { transform: translateY(-5px); }
|
.stat-card:hover { transform: translateY(-5px); }
|
||||||
.stat-value { font-size: 2.5em; font-weight: bold; color: #3498db; margin: 10px 0; }
|
.stat-value { font-size: 2.5em; font-weight: bold; color: #3498db; margin: 10px 0; }
|
||||||
.stat-label { font-size: 1.1em; color: #7f8c8d; text-transform: uppercase; letter-spacing: 1px; }
|
.stat-label { font-size: 1.1em; color: #7f8c8d; text-transform: uppercase; letter-spacing: 1px; }
|
||||||
.status-dot { display: inline-block; width: 10px; height: 10px; background-color: #2ecc71; border-radius: 50%; margin-right: 8px; box-shadow: 0 0 5px #2ecc71; }
|
.status-dot { display: inline-block; width: 10px; height: 10px; background-color: #2ecc71; border-radius: 50%; margin-right: 8px; box-shadow: 0 0 5px #2ecc71; }
|
||||||
.header-bar { max-width: 800px; margin: 0 auto 20px; display: flex; justify-content: space-between; align-items: center; }
|
.header-bar { max-width: 1000px; margin: 0 auto 20px; display: flex; justify-content: space-between; align-items: center; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -45,6 +45,58 @@
|
|||||||
<div class="stat-label">ADRs</div>
|
<div class="stat-label">ADRs</div>
|
||||||
<div class="stat-value" id="val-adrs">-</div>
|
<div class="stat-value" id="val-adrs">-</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Audit Ledger</div>
|
||||||
|
<div class="stat-value" id="val-ledger">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Sticky Notes</div>
|
||||||
|
<div class="stat-value" id="val-sticky">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Error Fixes</div>
|
||||||
|
<div class="stat-value" id="val-error_fixes">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Pinned Files</div>
|
||||||
|
<div class="stat-value" id="val-pinned_files">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Session Summaries</div>
|
||||||
|
<div class="stat-value" id="val-session_summaries">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Handoff Memos</div>
|
||||||
|
<div class="stat-value" id="val-handoff_memos">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Env Fingerprints</div>
|
||||||
|
<div class="stat-value" id="val-env_fingerprints">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Env Requirements</div>
|
||||||
|
<div class="stat-value" id="val-env_requirements">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Milestones</div>
|
||||||
|
<div class="stat-value" id="val-milestones">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Environments</div>
|
||||||
|
<div class="stat-value" id="val-environments">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">PR Checklists</div>
|
||||||
|
<div class="stat-value" id="val-pr_checklists">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Safety Gates</div>
|
||||||
|
<div class="stat-value" id="val-gates">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Workspaces</div>
|
||||||
|
<div class="stat-value" id="val-context_workspaces">-</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -59,6 +111,20 @@
|
|||||||
document.getElementById('val-snippets').innerText = data.snippets;
|
document.getElementById('val-snippets').innerText = data.snippets;
|
||||||
document.getElementById('val-tech_debts').innerText = data.tech_debts;
|
document.getElementById('val-tech_debts').innerText = data.tech_debts;
|
||||||
document.getElementById('val-adrs').innerText = data.adrs;
|
document.getElementById('val-adrs').innerText = data.adrs;
|
||||||
|
|
||||||
|
document.getElementById('val-ledger').innerText = data.ledger;
|
||||||
|
document.getElementById('val-sticky').innerText = data.sticky;
|
||||||
|
document.getElementById('val-error_fixes').innerText = data.error_fixes;
|
||||||
|
document.getElementById('val-pinned_files').innerText = data.pinned_files;
|
||||||
|
document.getElementById('val-session_summaries').innerText = data.session_summaries;
|
||||||
|
document.getElementById('val-handoff_memos').innerText = data.handoff_memos;
|
||||||
|
document.getElementById('val-env_fingerprints').innerText = data.env_fingerprints;
|
||||||
|
document.getElementById('val-env_requirements').innerText = data.env_requirements;
|
||||||
|
document.getElementById('val-milestones').innerText = data.milestones;
|
||||||
|
document.getElementById('val-environments').innerText = data.environments;
|
||||||
|
document.getElementById('val-pr_checklists').innerText = data.pr_checklists;
|
||||||
|
document.getElementById('val-gates').innerText = data.gates;
|
||||||
|
document.getElementById('val-context_workspaces').innerText = data.context_workspaces;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch stats:", err);
|
console.error("Failed to fetch stats:", err);
|
||||||
}
|
}
|
||||||
|
|||||||
+678
-282
File diff suppressed because it is too large.
Load diff
+343
-58
@@ -1,10 +1,10 @@
|
|||||||
mod handlers;
|
mod handlers;
|
||||||
mod models;
|
|
||||||
mod mcp;
|
mod mcp;
|
||||||
|
mod models;
|
||||||
|
mod search;
|
||||||
mod state;
|
mod state;
|
||||||
mod store;
|
mod store;
|
||||||
mod tools;
|
mod tools;
|
||||||
mod search;
|
|
||||||
|
|
||||||
use crate::handlers::MemoryHandler;
|
use crate::handlers::MemoryHandler;
|
||||||
use crate::models::*;
|
use crate::models::*;
|
||||||
@@ -15,24 +15,34 @@ use std::fs;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
use redb::ReadableTable;
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(author, version, about, long_about = None)]
|
#[command(author, version, about = "Antigravity MCP Memory Server", long_about = None)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Option<Commands>,
|
command: Option<Commands>,
|
||||||
|
/// Target URL for the proxy to connect to (e.g., http://127.0.0.1:3000)
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
target: Option<String>,
|
target: Option<String>,
|
||||||
|
/// Run the server as a background daemon process (Windows only)
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
daemon: bool,
|
daemon: bool,
|
||||||
|
/// Send a shutdown request to the currently running server
|
||||||
|
#[arg(long)]
|
||||||
|
exit: bool,
|
||||||
|
/// Send a shutdown request to the existing server and wait for it to exit
|
||||||
|
#[arg(long)]
|
||||||
|
restart: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Commands {
|
enum Commands {
|
||||||
|
/// Manage authorization gates and verification for actions
|
||||||
Gate {
|
Gate {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
subcmd: GateCommands,
|
subcmd: GateCommands,
|
||||||
@@ -71,7 +81,6 @@ enum GateCommands {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async fn reconcile_worker(state: Arc<MemoryState>) {
|
async fn reconcile_worker(state: Arc<MemoryState>) {
|
||||||
loop {
|
loop {
|
||||||
sleep(Duration::from_secs(5)).await;
|
sleep(Duration::from_secs(5)).await;
|
||||||
@@ -104,21 +113,18 @@ async fn reconcile_worker(state: Arc<MemoryState>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{State, Query},
|
|
||||||
response::sse::{Event, Sse},
|
|
||||||
routing::{get, post},
|
|
||||||
Json, Router,
|
Json, Router,
|
||||||
|
extract::{Query, State},
|
||||||
|
response::sse::{Event, Sse},
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::{get, post},
|
||||||
};
|
};
|
||||||
use futures_util::stream::Stream;
|
use futures_util::stream::Stream;
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio_stream::wrappers::ReceiverStream;
|
use tokio_stream::wrappers::ReceiverStream;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
||||||
|
|
||||||
struct AppState {
|
struct AppState {
|
||||||
handler: Arc<MemoryHandler>,
|
handler: Arc<MemoryHandler>,
|
||||||
@@ -126,6 +132,103 @@ struct AppState {
|
|||||||
next_id: AtomicUsize,
|
next_id: AtomicUsize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct GateVerifyReq {
|
||||||
|
action: String,
|
||||||
|
target: String,
|
||||||
|
namespace: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
params: HashMap<String, String>,
|
||||||
|
#[serde(default)]
|
||||||
|
consume: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct GateSetReq {
|
||||||
|
action: String,
|
||||||
|
target: String,
|
||||||
|
namespace: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
params: HashMap<String, String>,
|
||||||
|
authorize: Option<bool>,
|
||||||
|
block: Option<bool>,
|
||||||
|
reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn gate_verify_handler(
|
||||||
|
State(app_state): State<Arc<AppState>>,
|
||||||
|
Query(q): Query<GateVerifyReq>,
|
||||||
|
) -> axum::response::Response {
|
||||||
|
let mut found = None;
|
||||||
|
let mut to_remove = None;
|
||||||
|
app_state.handler.state.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
|
||||||
|
}) {
|
||||||
|
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" {
|
||||||
|
(axum::http::StatusCode::OK, "Authorized").into_response()
|
||||||
|
} else {
|
||||||
|
let msg = if let Some(r) = record.reason {
|
||||||
|
format!("Action blocked. Reason: {}", r)
|
||||||
|
} else {
|
||||||
|
"Action blocked.".to_string()
|
||||||
|
};
|
||||||
|
(axum::http::StatusCode::FORBIDDEN, msg).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
(axum::http::StatusCode::NOT_FOUND, "Action not yet authorized (no gate record found).").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn gate_set_handler(
|
||||||
|
State(app_state): State<Arc<AppState>>,
|
||||||
|
Json(body): Json<GateSetReq>,
|
||||||
|
) -> axum::response::Response {
|
||||||
|
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: SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs(),
|
||||||
|
};
|
||||||
|
app_state.handler.state.gates.modify(|gates| {
|
||||||
|
gates.retain(|g| !(g.action == record.action && g.target == record.target));
|
||||||
|
gates.push(record);
|
||||||
|
});
|
||||||
|
(axum::http::StatusCode::OK, "Gate state updated.").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>> {
|
fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
@@ -140,10 +243,25 @@ fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>>
|
|||||||
.route("/sse", get(sse_handler))
|
.route("/sse", get(sse_handler))
|
||||||
.route("/messages", post(message_handler))
|
.route("/messages", post(message_handler))
|
||||||
.route("/health", get(health_handler))
|
.route("/health", get(health_handler))
|
||||||
.route("/", get(|| async move {
|
.route("/gate/verify", get(gate_verify_handler))
|
||||||
axum::response::Html(include_str!("dashboard.html"))
|
.route("/gate/set", post(gate_set_handler))
|
||||||
}))
|
.route(
|
||||||
.route("/api/stats", get({
|
"/shutdown",
|
||||||
|
post(|| async move {
|
||||||
|
std::thread::spawn(|| {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
std::process::exit(0);
|
||||||
|
});
|
||||||
|
"Shutting down..."
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/",
|
||||||
|
get(|| async move { axum::response::Html(include_str!("dashboard.html")) }),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/stats",
|
||||||
|
get({
|
||||||
let state_clone = app_state.handler.state.clone();
|
let state_clone = app_state.handler.state.clone();
|
||||||
move || async move {
|
move || async move {
|
||||||
let (entities, relations) = {
|
let (entities, relations) = {
|
||||||
@@ -155,16 +273,44 @@ fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>>
|
|||||||
let tech_debts = state_clone.tech_debts.read().len();
|
let tech_debts = state_clone.tech_debts.read().len();
|
||||||
let adrs = state_clone.adrs.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();
|
||||||
|
|
||||||
axum::Json(serde_json::json!({
|
axum::Json(serde_json::json!({
|
||||||
"entities": entities,
|
"entities": entities,
|
||||||
"relations": relations,
|
"relations": relations,
|
||||||
"tasks": tasks,
|
"tasks": tasks,
|
||||||
"snippets": snippets,
|
"snippets": snippets,
|
||||||
"tech_debts": tech_debts,
|
"tech_debts": tech_debts,
|
||||||
"adrs": adrs
|
"adrs": adrs,
|
||||||
|
"ledger": ledger,
|
||||||
|
"sticky": sticky,
|
||||||
|
"error_fixes": error_fixes,
|
||||||
|
"pinned_files": pinned_files,
|
||||||
|
"session_summaries": session_summaries,
|
||||||
|
"handoff_memos": handoff_memos,
|
||||||
|
"env_fingerprints": env_fingerprints,
|
||||||
|
"env_requirements": env_requirements,
|
||||||
|
"milestones": milestones,
|
||||||
|
"environments": environments,
|
||||||
|
"pr_checklists": pr_checklists,
|
||||||
|
"gates": gates,
|
||||||
|
"context_workspaces": context_workspaces
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}))
|
}),
|
||||||
|
)
|
||||||
.with_state(app_state);
|
.with_state(app_state);
|
||||||
|
|
||||||
let mut retries = 0;
|
let mut retries = 0;
|
||||||
@@ -172,16 +318,48 @@ fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>>
|
|||||||
match tokio::net::TcpListener::bind("127.0.0.1:3000").await {
|
match tokio::net::TcpListener::bind("127.0.0.1:3000").await {
|
||||||
Ok(l) => break l,
|
Ok(l) => break l,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
// Check if it's already running and healthy
|
||||||
|
if let Ok(mut stream) = std::net::TcpStream::connect("127.0.0.1:3000") {
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
let _ = stream.write_all(
|
||||||
|
b"GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n",
|
||||||
|
);
|
||||||
|
let mut response = String::new();
|
||||||
|
let _ = stream.read_to_string(&mut response);
|
||||||
|
if response.contains("200 OK") {
|
||||||
|
// Already healthy! Just exit cleanly instead of panicking/retrying loop.
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
retries += 1;
|
retries += 1;
|
||||||
if retries > 15 {
|
if retries > 15 {
|
||||||
let log_path = dirs::home_dir().unwrap_or_default().join(".gemini/mcp_memory/daemon_fatal.log");
|
let log_path = dirs::home_dir()
|
||||||
let _ = std::fs::write(&log_path, format!("FATAL: Could not bind to 127.0.0.1:3000 after 15 seconds: {}\n", e));
|
.unwrap_or_default()
|
||||||
|
.join(".gemini/mcp_memory/daemon_fatal.log");
|
||||||
|
let _ = std::fs::write(
|
||||||
|
&log_path,
|
||||||
|
format!(
|
||||||
|
"FATAL: Could not bind to 127.0.0.1:3000 after 15 seconds: {}\n",
|
||||||
|
e
|
||||||
|
),
|
||||||
|
);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
let log_path = dirs::home_dir().unwrap_or_default().join(".gemini/mcp_memory/daemon_error.log");
|
let log_path = dirs::home_dir()
|
||||||
if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(&log_path) {
|
.unwrap_or_default()
|
||||||
|
.join(".gemini/mcp_memory/daemon_error.log");
|
||||||
|
if let Ok(mut file) = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&log_path)
|
||||||
|
{
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
let _ = writeln!(file, "Failed to bind to 127.0.0.1:3000 (attempt {}): {}. Retrying in 1s...", retries, e);
|
let _ = writeln!(
|
||||||
|
file,
|
||||||
|
"Failed to bind to 127.0.0.1:3000 (attempt {}): {}. Retrying in 1s...",
|
||||||
|
retries, e
|
||||||
|
);
|
||||||
}
|
}
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||||
}
|
}
|
||||||
@@ -189,7 +367,9 @@ fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>>
|
|||||||
};
|
};
|
||||||
eprintln!("MCP Memory Server running on http://127.0.0.1:3000/sse");
|
eprintln!("MCP Memory Server running on http://127.0.0.1:3000/sse");
|
||||||
if let Err(e) = axum::serve(listener, app).await {
|
if let Err(e) = axum::serve(listener, app).await {
|
||||||
let log_path = dirs::home_dir().unwrap_or_default().join(".gemini/mcp_memory/daemon_error.log");
|
let log_path = dirs::home_dir()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.join(".gemini/mcp_memory/daemon_error.log");
|
||||||
let _ = std::fs::write(&log_path, format!("Server crashed: {}\n", e));
|
let _ = std::fs::write(&log_path, format!("Server crashed: {}\n", e));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -202,9 +382,17 @@ async fn sse_handler(
|
|||||||
let session_id = format!("{}", state.next_id.fetch_add(1, Ordering::SeqCst));
|
let session_id = format!("{}", state.next_id.fetch_add(1, Ordering::SeqCst));
|
||||||
let (tx, rx) = mpsc::channel::<Result<Event, Infallible>>(100);
|
let (tx, rx) = mpsc::channel::<Result<Event, Infallible>>(100);
|
||||||
|
|
||||||
state.clients.write().unwrap().insert(session_id.clone(), tx.clone());
|
state
|
||||||
|
.clients
|
||||||
|
.write()
|
||||||
|
.unwrap()
|
||||||
|
.insert(session_id.clone(), tx.clone());
|
||||||
|
|
||||||
let _ = tx.send(Ok(Event::default().event("endpoint").data(format!("/messages?sessionId={}", session_id)))).await;
|
let _ = tx
|
||||||
|
.send(Ok(Event::default()
|
||||||
|
.event("endpoint")
|
||||||
|
.data(format!("/messages?sessionId={}", session_id))))
|
||||||
|
.await;
|
||||||
|
|
||||||
let stream = ReceiverStream::new(rx);
|
let stream = ReceiverStream::new(rx);
|
||||||
Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::new())
|
Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::new())
|
||||||
@@ -234,7 +422,9 @@ async fn message_handler(
|
|||||||
let tx_opt = clients.clients.read().unwrap().get(&session_id).cloned();
|
let tx_opt = clients.clients.read().unwrap().get(&session_id).cloned();
|
||||||
if let Some(tx) = tx_opt {
|
if let Some(tx) = tx_opt {
|
||||||
let data = serde_json::to_string(&response).unwrap();
|
let data = serde_json::to_string(&response).unwrap();
|
||||||
let _ = tx.send(Ok(Event::default().event("message").data(data))).await;
|
let _ = tx
|
||||||
|
.send(Ok(Event::default().event("message").data(data)))
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -244,16 +434,38 @@ async fn message_handler(
|
|||||||
|
|
||||||
mod proxy;
|
mod proxy;
|
||||||
|
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
if cli.exit {
|
||||||
|
if let Ok(mut stream) = std::net::TcpStream::connect("127.0.0.1:3000") {
|
||||||
|
use std::io::Write;
|
||||||
|
let _ = stream.write_all(
|
||||||
|
b"POST /shutdown HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!("Sent shutdown request to server.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if cli.restart {
|
||||||
|
if let Ok(mut stream) = std::net::TcpStream::connect("127.0.0.1:3000") {
|
||||||
|
use std::io::Write;
|
||||||
|
let _ = stream.write_all(
|
||||||
|
b"POST /shutdown HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n",
|
||||||
|
);
|
||||||
|
println!("Sent shutdown request to existing server. Waiting for it to exit...");
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(1500));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
use std::os::windows::process::CommandExt;
|
use std::os::windows::process::CommandExt;
|
||||||
if !cli.daemon {
|
if !cli.daemon {
|
||||||
loop {
|
loop {
|
||||||
if let Err(_) = std::net::TcpListener::bind("127.0.0.1:3000") {
|
if std::net::TcpListener::bind("127.0.0.1:3000").is_err() {
|
||||||
// Port in use, become a stub proxy!
|
// Port in use, become a stub proxy!
|
||||||
let target_url = cli.target.as_deref().unwrap_or("http://127.0.0.1:3000");
|
let target_url = cli.target.as_deref().unwrap_or("http://127.0.0.1:3000");
|
||||||
match proxy::run_proxy(target_url) {
|
match proxy::run_proxy(target_url) {
|
||||||
@@ -266,7 +478,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Port is free. We must spawn the daemon, then loop again to become proxy
|
// Port is free. We must spawn the daemon, then loop again to become proxy
|
||||||
std::process::Command::new(std::env::current_exe().unwrap())
|
#[allow(clippy::zombie_processes)]
|
||||||
|
let _ = std::process::Command::new(std::env::current_exe().unwrap())
|
||||||
.arg("--daemon")
|
.arg("--daemon")
|
||||||
.stdin(std::process::Stdio::null())
|
.stdin(std::process::Stdio::null())
|
||||||
.stdout(std::process::Stdio::null())
|
.stdout(std::process::Stdio::null())
|
||||||
@@ -300,40 +513,99 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let base = PathBuf::from(base_dir);
|
let base = PathBuf::from(base_dir);
|
||||||
fs::create_dir_all(&base).expect("Failed to create store dir");
|
fs::create_dir_all(&base).expect("Failed to create store dir");
|
||||||
|
|
||||||
|
let redb_path = base.join("mcp_store.redb");
|
||||||
|
let db = Arc::new(redb::Database::create(&redb_path).unwrap());
|
||||||
|
|
||||||
|
// Ensure table exists and migrate old JSON files
|
||||||
|
{
|
||||||
|
let write_txn = db.begin_write().unwrap();
|
||||||
|
{
|
||||||
|
let mut table = write_txn.open_table(crate::store::STORE_TABLE).unwrap();
|
||||||
|
|
||||||
|
let stores = [
|
||||||
|
("audit_ledger", "audit_ledger.json"),
|
||||||
|
("sticky_notes", "sticky_notes.json"),
|
||||||
|
("tasks", "tasks.json"),
|
||||||
|
("snippets", "snippets.json"),
|
||||||
|
("adrs", "adrs.json"),
|
||||||
|
("preferences", "preferences.json"),
|
||||||
|
("error_fixes", "error_fixes.json"),
|
||||||
|
("pinned_files", "pinned_files.json"),
|
||||||
|
("session_summaries", "session_summaries.json"),
|
||||||
|
("handoff_memos", "handoff_memos.json"),
|
||||||
|
("env_fingerprints", "env_fingerprints.json"),
|
||||||
|
("env_requirements", "env_requirements.json"),
|
||||||
|
("milestones", "milestones.json"),
|
||||||
|
("environments", "environments.json"),
|
||||||
|
("pr_checklists", "pr_checklists.json"),
|
||||||
|
("tech_debts", "tech_debts.json"),
|
||||||
|
("gates", "gates.json"),
|
||||||
|
("context_workspaces", "context_workspaces.json"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (key, file_name) in stores.iter() {
|
||||||
|
if table.get(*key).unwrap().is_none() {
|
||||||
|
let json_path = base.join(file_name);
|
||||||
|
if json_path.exists() {
|
||||||
|
if let Ok(data) = fs::read(&json_path) {
|
||||||
|
if serde_json::from_slice::<serde_json::Value>(&data).is_ok() {
|
||||||
|
table.insert(*key, data.as_slice()).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write_txn.commit().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
let state = Arc::new(MemoryState {
|
let state = Arc::new(MemoryState {
|
||||||
master_path: base.join("knowledge_graph_master.json"),
|
master_path: base.join("knowledge_graph_master.json"),
|
||||||
session_graph: RwLock::new(KnowledgeGraph::default()),
|
session_graph: RwLock::new(KnowledgeGraph::default()),
|
||||||
base_dir: base.clone(),
|
base_dir: base.clone(),
|
||||||
master_cache: RwLock::new((KnowledgeGraph::default(), SystemTime::UNIX_EPOCH)),
|
master_cache: RwLock::new((KnowledgeGraph::default(), SystemTime::UNIX_EPOCH)),
|
||||||
search_index: RwLock::new(crate::search::MemoryIndex::new().unwrap()),
|
search_index: RwLock::new(crate::search::MemoryIndex::new(&base).unwrap()),
|
||||||
ledger: Store::new(base.join("audit_ledger.json")),
|
ledger: Store::new("audit_ledger", db.clone()),
|
||||||
sticky: Store::new(base.join("sticky_notes.json")),
|
sticky: Store::new("sticky_notes", db.clone()),
|
||||||
tasks: Store::new(base.join("tasks.json")),
|
tasks: Store::new("tasks", db.clone()),
|
||||||
snippets: Store::new(base.join("snippets.json")),
|
snippets: Store::new("snippets", db.clone()),
|
||||||
adrs: Store::new(base.join("adrs.json")),
|
adrs: Store::new("adrs", db.clone()),
|
||||||
prefs: Store::new(base.join("preferences.json")),
|
prefs: Store::new("preferences", db.clone()),
|
||||||
error_fixes: Store::new(base.join("error_fixes.json")),
|
error_fixes: Store::new("error_fixes", db.clone()),
|
||||||
pinned_files: Store::new(base.join("pinned_files.json")),
|
pinned_files: Store::new("pinned_files", db.clone()),
|
||||||
session_summaries: Store::new(base.join("session_summaries.json")),
|
session_summaries: Store::new("session_summaries", db.clone()),
|
||||||
handoff_memos: Store::new(base.join("handoff_memos.json")),
|
handoff_memos: Store::new("handoff_memos", db.clone()),
|
||||||
env_fingerprints: Store::new(base.join("env_fingerprints.json")),
|
env_fingerprints: Store::new("env_fingerprints", db.clone()),
|
||||||
env_requirements: Store::new(base.join("env_requirements.json")),
|
env_requirements: Store::new("env_requirements", db.clone()),
|
||||||
milestones: Store::new(base.join("milestones.json")),
|
milestones: Store::new("milestones", db.clone()),
|
||||||
environments: Store::new(base.join("environments.json")),
|
environments: Store::new("environments", db.clone()),
|
||||||
pr_checklists: Store::new(base.join("pr_checklists.json")),
|
pr_checklists: Store::new("pr_checklists", db.clone()),
|
||||||
tech_debts: Store::new(base.join("tech_debts.json")),
|
tech_debts: Store::new("tech_debts", db.clone()),
|
||||||
gates: Store::new(base.join("gates.json")),
|
gates: Store::new("gates", db.clone()),
|
||||||
context_workspaces: Store::new(base.join("context_workspaces.json")),
|
context_workspaces: Store::new("context_workspaces", db.clone()),
|
||||||
});
|
});
|
||||||
|
|
||||||
state.rebuild_index();
|
state.rebuild_index();
|
||||||
|
|
||||||
if let Some(command) = cli.command {
|
if let Some(command) = cli.command {
|
||||||
match command {
|
match command {
|
||||||
Commands::Gate { subcmd } => {
|
Commands::Gate { subcmd } => match subcmd {
|
||||||
match subcmd {
|
GateCommands::Set {
|
||||||
GateCommands::Set { action, target, namespace, params, authorize, block, reason } => {
|
action,
|
||||||
let status = if authorize { "authorized".to_string() } else if block { "blocked".to_string() } else { "pending".to_string() };
|
target,
|
||||||
|
namespace,
|
||||||
|
params,
|
||||||
|
authorize,
|
||||||
|
block,
|
||||||
|
reason,
|
||||||
|
} => {
|
||||||
|
let status = if authorize {
|
||||||
|
"authorized".to_string()
|
||||||
|
} else if block {
|
||||||
|
"blocked".to_string()
|
||||||
|
} else {
|
||||||
|
"pending".to_string()
|
||||||
|
};
|
||||||
let mut param_map = HashMap::new();
|
let mut param_map = HashMap::new();
|
||||||
for p in params {
|
for p in params {
|
||||||
if let Some((k, v)) = p.split_once('=') {
|
if let Some((k, v)) = p.split_once('=') {
|
||||||
@@ -348,7 +620,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
params: param_map,
|
params: param_map,
|
||||||
status,
|
status,
|
||||||
reason,
|
reason,
|
||||||
timestamp: SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(),
|
timestamp: SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs(),
|
||||||
};
|
};
|
||||||
state.gates.modify(|gates| {
|
state.gates.modify(|gates| {
|
||||||
gates.retain(|g| !(g.action == record.action && g.target == record.target));
|
gates.retain(|g| !(g.action == record.action && g.target == record.target));
|
||||||
@@ -357,7 +632,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
println!("Gate state updated.");
|
println!("Gate state updated.");
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
GateCommands::Verify { action, target, namespace, params, consume } => {
|
GateCommands::Verify {
|
||||||
|
action,
|
||||||
|
target,
|
||||||
|
namespace,
|
||||||
|
params,
|
||||||
|
consume,
|
||||||
|
} => {
|
||||||
let mut param_map = HashMap::new();
|
let mut param_map = HashMap::new();
|
||||||
for p in params {
|
for p in params {
|
||||||
if let Some((k, v)) = p.split_once('=') {
|
if let Some((k, v)) = p.split_once('=') {
|
||||||
@@ -367,7 +648,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let mut found = None;
|
let mut found = None;
|
||||||
let mut to_remove = None;
|
let mut to_remove = None;
|
||||||
state.gates.modify(|gates| {
|
state.gates.modify(|gates| {
|
||||||
if let Some(idx) = gates.iter().position(|g| g.action == action && g.target == target && g.namespace == namespace && g.params == param_map) {
|
if let Some(idx) = gates.iter().position(|g| {
|
||||||
|
g.action == action
|
||||||
|
&& g.target == target
|
||||||
|
&& g.namespace == namespace
|
||||||
|
&& g.params == param_map
|
||||||
|
}) {
|
||||||
found = Some(gates[idx].clone());
|
found = Some(gates[idx].clone());
|
||||||
if consume {
|
if consume {
|
||||||
to_remove = Some(idx);
|
to_remove = Some(idx);
|
||||||
@@ -397,8 +683,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -165,8 +165,7 @@ pub struct ContextWorkspace {
|
|||||||
pub saved_at: u64,
|
pub saved_at: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
#[derive(Default)]
|
|
||||||
pub struct GateRecord {
|
pub struct GateRecord {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub action: String,
|
pub action: String,
|
||||||
|
|||||||
+36
-18
@@ -1,9 +1,9 @@
|
|||||||
use tokio_util::io::StreamReader;
|
|
||||||
use tokio::io::AsyncBufReadExt;
|
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tokio::io::AsyncBufReadExt;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
use tokio_util::io::StreamReader;
|
||||||
|
|
||||||
pub fn run_proxy(target_url: &str) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
pub fn run_proxy(target_url: &str) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
@@ -16,7 +16,9 @@ pub fn run_proxy(target_url: &str) -> Result<bool, Box<dyn std::error::Error + S
|
|||||||
let mut handle = stdin.lock();
|
let mut handle = stdin.lock();
|
||||||
let mut buffer = String::new();
|
let mut buffer = String::new();
|
||||||
while let Ok(bytes) = std::io::BufRead::read_line(&mut handle, &mut buffer) {
|
while let Ok(bytes) = std::io::BufRead::read_line(&mut handle, &mut buffer) {
|
||||||
if bytes == 0 { break; }
|
if bytes == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
let _ = msg_tx.blocking_send(buffer.clone());
|
let _ = msg_tx.blocking_send(buffer.clone());
|
||||||
buffer.clear();
|
buffer.clear();
|
||||||
}
|
}
|
||||||
@@ -34,11 +36,13 @@ pub fn run_proxy(target_url: &str) -> Result<bool, Box<dyn std::error::Error + S
|
|||||||
loop {
|
loop {
|
||||||
let url = post_url_clone.read().await.clone();
|
let url = post_url_clone.read().await.clone();
|
||||||
if !url.is_empty() {
|
if !url.is_empty() {
|
||||||
let res = client.post(&url)
|
let res = client
|
||||||
|
.post(&url)
|
||||||
.header("Accept", "application/json, text/event-stream")
|
.header("Accept", "application/json, text/event-stream")
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.body(msg.clone())
|
.body(msg.clone())
|
||||||
.send().await;
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
if res.is_ok() {
|
if res.is_ok() {
|
||||||
break;
|
break;
|
||||||
@@ -49,7 +53,6 @@ pub fn run_proxy(target_url: &str) -> Result<bool, Box<dyn std::error::Error + S
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
loop {
|
|
||||||
if shutdown_rx.try_recv().is_ok() {
|
if shutdown_rx.try_recv().is_ok() {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
@@ -57,19 +60,33 @@ pub fn run_proxy(target_url: &str) -> Result<bool, Box<dyn std::error::Error + S
|
|||||||
let sse_url = format!("{}/sse", target_url);
|
let sse_url = format!("{}/sse", target_url);
|
||||||
let client = reqwest::Client::builder().build().unwrap();
|
let client = reqwest::Client::builder().build().unwrap();
|
||||||
|
|
||||||
match client.get(&sse_url).header("Accept", "text/event-stream").send().await {
|
match client
|
||||||
|
.get(&sse_url)
|
||||||
|
.header("Accept", "text/event-stream")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
if resp.status() == reqwest::StatusCode::GONE {
|
if resp.status() == reqwest::StatusCode::GONE {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
let stream = resp.bytes_stream().map(|res| res.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)));
|
let stream = resp.bytes_stream().map(|res| {
|
||||||
|
res.map_err(std::io::Error::other)
|
||||||
|
});
|
||||||
let mut reader = tokio::io::BufReader::new(StreamReader::new(stream));
|
let mut reader = tokio::io::BufReader::new(StreamReader::new(stream));
|
||||||
let mut line = String::new();
|
let mut line = String::new();
|
||||||
let mut is_message = false;
|
let mut is_message = false;
|
||||||
let mut is_endpoint = false;
|
let mut is_endpoint = false;
|
||||||
|
|
||||||
while let Ok(bytes) = reader.read_line(&mut line).await {
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = shutdown_rx.recv() => {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
res = reader.read_line(&mut line) => {
|
||||||
|
match res {
|
||||||
|
Ok(bytes) => {
|
||||||
if bytes == 0 { break; }
|
if bytes == 0 { break; }
|
||||||
let trimmed = line.trim();
|
let trimmed = line.trim();
|
||||||
if trimmed.starts_with("event: message") {
|
if trimmed.starts_with("event: message") {
|
||||||
@@ -78,26 +95,27 @@ pub fn run_proxy(target_url: &str) -> Result<bool, Box<dyn std::error::Error + S
|
|||||||
} else if trimmed.starts_with("event: endpoint") {
|
} else if trimmed.starts_with("event: endpoint") {
|
||||||
is_endpoint = true;
|
is_endpoint = true;
|
||||||
is_message = false;
|
is_message = false;
|
||||||
} else if trimmed.starts_with("data: ") {
|
} else if let Some(stripped) = trimmed.strip_prefix("data: ") {
|
||||||
if is_message {
|
if is_message {
|
||||||
println!("{}", &trimmed[6..]);
|
println!("{}", stripped);
|
||||||
is_message = false;
|
is_message = false;
|
||||||
} else if is_endpoint {
|
} else if is_endpoint {
|
||||||
let ep = &trimmed[6..];
|
|
||||||
let mut p = post_url.write().await;
|
let mut p = post_url.write().await;
|
||||||
*p = format!("{}{}", target_url, ep);
|
*p = format!("{}{}", target_url, stripped);
|
||||||
is_endpoint = false;
|
is_endpoint = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
line.clear();
|
line.clear();
|
||||||
}
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
*post_url.write().await = String::new();
|
*post_url.write().await = String::new();
|
||||||
return Ok(true);
|
Ok(true)
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Err(_) => Ok(true),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
+37
-14
@@ -1,7 +1,7 @@
|
|||||||
use tantivy::schema::*;
|
use crate::models::{Adr, Entity, Snippet, Task};
|
||||||
use tantivy::{doc, Index, IndexWriter, IndexReader, ReloadPolicy};
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use crate::models::{Entity, Task, Adr, Snippet};
|
use tantivy::schema::*;
|
||||||
|
use tantivy::{Index, IndexReader, IndexWriter, ReloadPolicy, doc};
|
||||||
|
|
||||||
pub struct MemoryIndex {
|
pub struct MemoryIndex {
|
||||||
index: Index,
|
index: Index,
|
||||||
@@ -17,7 +17,7 @@ pub struct MemoryIndex {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryIndex {
|
impl MemoryIndex {
|
||||||
pub fn new() -> tantivy::Result<Self> {
|
pub fn new(store_dir: &std::path::Path) -> tantivy::Result<Self> {
|
||||||
let mut schema_builder = Schema::builder();
|
let mut schema_builder = Schema::builder();
|
||||||
let id_field = schema_builder.add_text_field("id", STRING | STORED);
|
let id_field = schema_builder.add_text_field("id", STRING | STORED);
|
||||||
let title_field = schema_builder.add_text_field("title", TEXT | STORED);
|
let title_field = schema_builder.add_text_field("title", TEXT | STORED);
|
||||||
@@ -26,7 +26,10 @@ impl MemoryIndex {
|
|||||||
let namespace_field = schema_builder.add_text_field("namespace", STRING | STORED);
|
let namespace_field = schema_builder.add_text_field("namespace", STRING | STORED);
|
||||||
let schema = schema_builder.build();
|
let schema = schema_builder.build();
|
||||||
|
|
||||||
let index = Index::create_in_ram(schema.clone());
|
let index_dir = store_dir.join("tantivy_index");
|
||||||
|
std::fs::create_dir_all(&index_dir).unwrap();
|
||||||
|
let index = Index::open_in_dir(&index_dir).unwrap_or_else(|_| Index::create_in_dir(&index_dir, schema.clone()).unwrap());
|
||||||
|
|
||||||
let writer = index.writer(50_000_000)?;
|
let writer = index.writer(50_000_000)?;
|
||||||
let reader = index
|
let reader = index
|
||||||
.reader_builder()
|
.reader_builder()
|
||||||
@@ -71,23 +74,43 @@ impl MemoryIndex {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn search(&self, query: &str, namespace: Option<&str>) -> tantivy::Result<Vec<(String, String)>> {
|
pub fn search(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
namespace: Option<&str>,
|
||||||
|
) -> tantivy::Result<Vec<(String, String)>> {
|
||||||
let searcher = self.reader.searcher();
|
let searcher = self.reader.searcher();
|
||||||
let query_parser = tantivy::query::QueryParser::for_index(&self.index, vec![self.title_field, self.body_field]);
|
let query_parser = tantivy::query::QueryParser::for_index(
|
||||||
|
&self.index,
|
||||||
|
vec![self.title_field, self.body_field],
|
||||||
|
);
|
||||||
let q = query_parser.parse_query(query)?;
|
let q = query_parser.parse_query(query)?;
|
||||||
|
|
||||||
let top_docs = searcher.search(&q, &tantivy::collector::TopDocs::with_limit(50).order_by_score())?;
|
let top_docs = searcher.search(
|
||||||
|
&q,
|
||||||
|
&tantivy::collector::TopDocs::with_limit(50).order_by_score(),
|
||||||
|
)?;
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
for (_score, doc_address) in top_docs {
|
for (_score, doc_address) in top_docs {
|
||||||
let retrieved_doc = searcher.doc::<tantivy::TantivyDocument>(doc_address)?;
|
let retrieved_doc = searcher.doc::<tantivy::TantivyDocument>(doc_address)?;
|
||||||
let id = retrieved_doc.get_first(self.id_field).and_then(|v| v.as_str()).unwrap_or("").to_string();
|
let id = retrieved_doc
|
||||||
let doc_type = retrieved_doc.get_first(self.type_field).and_then(|v| v.as_str()).unwrap_or("").to_string();
|
.get_first(self.id_field)
|
||||||
let doc_ns = retrieved_doc.get_first(self.namespace_field).and_then(|v| v.as_str()).unwrap_or("");
|
.and_then(|v| v.as_str())
|
||||||
if let Some(ns) = namespace {
|
.unwrap_or("")
|
||||||
if doc_ns != ns && doc_ns != "global" {
|
.to_string();
|
||||||
|
let doc_type = retrieved_doc
|
||||||
|
.get_first(self.type_field)
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let doc_ns = retrieved_doc
|
||||||
|
.get_first(self.namespace_field)
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
if let Some(ns) = namespace
|
||||||
|
&& doc_ns != ns && doc_ns != "global" {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
results.push((id, doc_type));
|
results.push((id, doc_type));
|
||||||
}
|
}
|
||||||
Ok(results)
|
Ok(results)
|
||||||
|
|||||||
+42
-11
@@ -1,6 +1,6 @@
|
|||||||
use crate::models::*;
|
use crate::models::*;
|
||||||
use crate::store::{Store, read_json_file, write_json_atomic};
|
|
||||||
use crate::search::MemoryIndex;
|
use crate::search::MemoryIndex;
|
||||||
|
use crate::store::Store;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -76,7 +76,21 @@ impl MemoryState {
|
|||||||
let mut lock = self.master_cache.write().unwrap();
|
let mut lock = self.master_cache.write().unwrap();
|
||||||
let new_mtime = self.master_mtime();
|
let new_mtime = self.master_mtime();
|
||||||
if lock.1 != new_mtime {
|
if lock.1 != new_mtime {
|
||||||
lock.0 = read_json_file(&self.master_path);
|
if let Ok(data) = fs::read(&self.master_path)
|
||||||
|
&& let Ok(parsed) = serde_json::from_slice(&data)
|
||||||
|
{
|
||||||
|
lock.0 = parsed;
|
||||||
|
} else {
|
||||||
|
let bak_path = self.master_path.with_extension("json.bak");
|
||||||
|
if let Ok(data) = fs::read(&bak_path)
|
||||||
|
&& let Ok(parsed) = serde_json::from_slice(&data)
|
||||||
|
{
|
||||||
|
let _ = fs::write(&self.master_path, data);
|
||||||
|
lock.0 = parsed;
|
||||||
|
} else {
|
||||||
|
lock.0 = KnowledgeGraph::default();
|
||||||
|
}
|
||||||
|
}
|
||||||
lock.1 = new_mtime;
|
lock.1 = new_mtime;
|
||||||
}
|
}
|
||||||
lock.0.clone()
|
lock.0.clone()
|
||||||
@@ -100,13 +114,16 @@ impl MemoryState {
|
|||||||
let mut session_graph = self.session_graph.write().unwrap();
|
let mut session_graph = self.session_graph.write().unwrap();
|
||||||
update_fn(&mut session_graph);
|
update_fn(&mut session_graph);
|
||||||
let wal_path = self.base_dir.join("wal.jsonl");
|
let wal_path = self.base_dir.join("wal.jsonl");
|
||||||
if let Ok(payload) = serde_json::to_string(&*session_graph) {
|
if let Ok(payload) = serde_json::to_string(&*session_graph)
|
||||||
if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(&wal_path) {
|
&& let Ok(mut file) = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&wal_path)
|
||||||
|
{
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
let _ = writeln!(file, "{}", payload);
|
let _ = writeln!(file, "{}", payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
pub fn apply_sync_write<F: FnOnce(&mut KnowledgeGraph)>(&self, update_fn: F) {
|
pub fn apply_sync_write<F: FnOnce(&mut KnowledgeGraph)>(&self, update_fn: F) {
|
||||||
let lock_path = self.base_dir.join("master.lock");
|
let lock_path = self.base_dir.join("master.lock");
|
||||||
let mut attempts = 0;
|
let mut attempts = 0;
|
||||||
@@ -130,7 +147,18 @@ impl MemoryState {
|
|||||||
let _ = fs::remove_file(&wal_path);
|
let _ = fs::remove_file(&wal_path);
|
||||||
*self.session_graph.write().unwrap() = KnowledgeGraph::default();
|
*self.session_graph.write().unwrap() = KnowledgeGraph::default();
|
||||||
update_fn(&mut master);
|
update_fn(&mut master);
|
||||||
let _ = write_json_atomic(&self.master_path, &master);
|
|
||||||
|
let write_json = |path: &std::path::Path, data: &KnowledgeGraph| -> std::io::Result<()> {
|
||||||
|
if path.exists() {
|
||||||
|
let bak_path = path.with_extension("json.bak");
|
||||||
|
let _ = fs::copy(path, &bak_path);
|
||||||
|
}
|
||||||
|
let tmp_path = path.with_extension("json.tmp");
|
||||||
|
let json_data = serde_json::to_string_pretty(data)?;
|
||||||
|
fs::write(&tmp_path, json_data)?;
|
||||||
|
fs::rename(&tmp_path, path)
|
||||||
|
};
|
||||||
|
let _ = write_json(&self.master_path, &master);
|
||||||
|
|
||||||
let mut cache_lock = self.master_cache.write().unwrap();
|
let mut cache_lock = self.master_cache.write().unwrap();
|
||||||
cache_lock.0 = master;
|
cache_lock.0 = master;
|
||||||
@@ -140,9 +168,14 @@ impl MemoryState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn rebuild_index(&self) {
|
pub fn rebuild_index(&self) {
|
||||||
if let Ok(new_idx) = MemoryIndex::new() {
|
if let Ok(new_idx) = MemoryIndex::new(&self.base_dir) {
|
||||||
let full = self.get_full_graph();
|
let session = self.session_graph.read().unwrap();
|
||||||
for (_, e) in &full.entities {
|
let mut full = {
|
||||||
|
let cache = self.master_cache.read().unwrap();
|
||||||
|
cache.0.clone()
|
||||||
|
};
|
||||||
|
Self::merge_graphs(&mut full, &session);
|
||||||
|
for e in full.entities.values() {
|
||||||
let _ = new_idx.index_entity(e);
|
let _ = new_idx.index_entity(e);
|
||||||
}
|
}
|
||||||
for t in self.tasks.read() {
|
for t in self.tasks.read() {
|
||||||
@@ -160,5 +193,3 @@ impl MemoryState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
+36
-63
@@ -1,82 +1,55 @@
|
|||||||
use serde::{de::DeserializeOwned, Serialize};
|
use redb::{Database, ReadableDatabase, TableDefinition};
|
||||||
use std::fs;
|
use serde::{Serialize, de::DeserializeOwned};
|
||||||
use std::path::{Path, PathBuf};
|
use std::sync::{Arc, RwLock};
|
||||||
use std::sync::RwLock;
|
|
||||||
use std::time::SystemTime;
|
|
||||||
|
|
||||||
pub fn read_json_file<T: DeserializeOwned + Default>(path: &Path) -> T {
|
pub const STORE_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("store");
|
||||||
if let Ok(data) = fs::read(path)
|
|
||||||
&& let Ok(parsed) = serde_json::from_slice(&data) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
let bak_path = path.with_extension("json.bak");
|
|
||||||
if let Ok(data) = fs::read(&bak_path)
|
|
||||||
&& let Ok(parsed) = serde_json::from_slice(&data) {
|
|
||||||
let _ = fs::write(path, data);
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
T::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn write_json_atomic<T: Serialize>(path: &Path, data: &T) -> std::io::Result<()> {
|
|
||||||
if path.exists() {
|
|
||||||
let bak_path = path.with_extension("json.bak");
|
|
||||||
let _ = fs::copy(path, &bak_path);
|
|
||||||
}
|
|
||||||
let tmp_path = path.with_extension("json.tmp");
|
|
||||||
let json_data = serde_json::to_string_pretty(data)?;
|
|
||||||
fs::write(&tmp_path, json_data)?;
|
|
||||||
fs::rename(&tmp_path, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Store<T> {
|
pub struct Store<T> {
|
||||||
pub path: PathBuf,
|
pub key: String,
|
||||||
pub cache: RwLock<(T, SystemTime)>,
|
pub db: Arc<Database>,
|
||||||
|
pub cache: RwLock<T>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: DeserializeOwned + Default + Serialize + Clone> Store<T> {
|
impl<T: DeserializeOwned + Default + Serialize + Clone> Store<T> {
|
||||||
fn mtime(&self) -> SystemTime {
|
pub fn new(key: &str, db: Arc<Database>) -> Self {
|
||||||
fs::metadata(&self.path)
|
let initial_data = Self::load_from_db(key, &db);
|
||||||
.and_then(|m| m.modified())
|
Self {
|
||||||
.unwrap_or(SystemTime::UNIX_EPOCH)
|
key: key.to_string(),
|
||||||
|
db,
|
||||||
|
cache: RwLock::new(initial_data),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new(path: PathBuf) -> Self {
|
fn load_from_db(key: &str, db: &Database) -> T {
|
||||||
let mtime = fs::metadata(&path)
|
let read_txn = db.begin_read().unwrap();
|
||||||
.and_then(|m| m.modified())
|
if let Ok(table) = read_txn.open_table(STORE_TABLE) {
|
||||||
.unwrap_or(SystemTime::UNIX_EPOCH);
|
if let Ok(Some(value)) = table.get(key) {
|
||||||
let initial_data = read_json_file(&path);
|
if let Ok(parsed) = serde_json::from_slice::<T>(value.value()) {
|
||||||
Self {
|
return parsed;
|
||||||
path,
|
|
||||||
cache: RwLock::new((initial_data, mtime)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
T::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_to_db(key: &str, db: &Database, data: &T) {
|
||||||
|
let write_txn = db.begin_write().unwrap();
|
||||||
|
{
|
||||||
|
let mut table = write_txn.open_table(STORE_TABLE).unwrap();
|
||||||
|
let json_data = serde_json::to_vec(data).unwrap();
|
||||||
|
table.insert(key, json_data.as_slice()).unwrap();
|
||||||
|
}
|
||||||
|
write_txn.commit().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn read(&self) -> T {
|
pub fn read(&self) -> T {
|
||||||
let current_mtime = self.mtime();
|
|
||||||
{
|
|
||||||
let lock = self.cache.read().unwrap();
|
let lock = self.cache.read().unwrap();
|
||||||
if lock.1 == current_mtime {
|
lock.clone()
|
||||||
return lock.0.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut lock = self.cache.write().unwrap();
|
|
||||||
let new_mtime = self.mtime();
|
|
||||||
if lock.1 != new_mtime {
|
|
||||||
lock.0 = read_json_file(&self.path);
|
|
||||||
lock.1 = new_mtime;
|
|
||||||
}
|
|
||||||
lock.0.clone()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn modify<F: FnOnce(&mut T)>(&self, f: F) {
|
pub fn modify<F: FnOnce(&mut T)>(&self, f: F) {
|
||||||
let mut lock = self.cache.write().unwrap();
|
let mut lock = self.cache.write().unwrap();
|
||||||
let current_mtime = self.mtime();
|
f(&mut lock);
|
||||||
if lock.1 != current_mtime {
|
Self::save_to_db(&self.key, &self.db, &lock);
|
||||||
lock.0 = read_json_file(&self.path);
|
|
||||||
}
|
|
||||||
f(&mut lock.0);
|
|
||||||
let _ = write_json_atomic(&self.path, &lock.0);
|
|
||||||
lock.1 = self.mtime();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
-1
@@ -78,7 +78,9 @@ pub struct UpdateTaskStatusTool {
|
|||||||
pub status: String,
|
pub status: String,
|
||||||
}
|
}
|
||||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||||
pub struct ListActiveTasksTool { pub git_branch: Option<String>, }
|
pub struct ListActiveTasksTool {
|
||||||
|
pub git_branch: Option<String>,
|
||||||
|
}
|
||||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||||
pub struct StoreSnippetTool {
|
pub struct StoreSnippetTool {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|||||||
+51
-24
@@ -3,14 +3,16 @@ use futures_util::StreamExt;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::io::AsyncBufReadExt;
|
use tokio::io::AsyncBufReadExt;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tokio_util::io::StreamReader;
|
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
use tokio_util::io::StreamReader;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "mcp-memory-stub")]
|
#[command(name = "mcp-memory-stub", author, version, about = "Antigravity MCP Memory Stub / Proxy", long_about = None)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
|
/// Target URL for the stub to proxy messages to
|
||||||
#[arg(long, default_value = "http://localhost:3000")]
|
#[arg(long, default_value = "http://localhost:3000")]
|
||||||
target: String,
|
target: String,
|
||||||
|
/// Optional command to execute if the target server is unreachable
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
wake_cmd: Option<String>,
|
wake_cmd: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -22,46 +24,51 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let (msg_tx, mut msg_rx) = mpsc::channel::<String>(100);
|
let (msg_tx, mut msg_rx) = mpsc::channel::<String>(100);
|
||||||
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
||||||
|
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::spawn(async move {
|
||||||
let stdin = std::io::stdin();
|
let mut stdin = tokio::io::BufReader::new(tokio::io::stdin());
|
||||||
let mut handle = stdin.lock();
|
|
||||||
let mut buffer = String::new();
|
let mut buffer = String::new();
|
||||||
while let Ok(bytes) = std::io::BufRead::read_line(&mut handle, &mut buffer) {
|
while let Ok(bytes) = stdin.read_line(&mut buffer).await {
|
||||||
if bytes == 0 { break; }
|
if bytes == 0 {
|
||||||
let _ = msg_tx.blocking_send(buffer.clone());
|
break;
|
||||||
|
}
|
||||||
|
let _ = msg_tx.send(buffer.clone()).await;
|
||||||
buffer.clear();
|
buffer.clear();
|
||||||
}
|
}
|
||||||
let _ = shutdown_tx.blocking_send(());
|
let _ = shutdown_tx.send(()).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
let target_url = cli.target;
|
let target_url = cli.target;
|
||||||
let post_url = Arc::new(RwLock::new(String::new()));
|
let post_url = Arc::new(RwLock::new(String::new()));
|
||||||
let post_url_clone = Arc::clone(&post_url);
|
let post_url_proxy = Arc::clone(&post_url);
|
||||||
|
|
||||||
let client = reqwest::Client::builder().build()?;
|
let client = reqwest::Client::builder().build()?;
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(msg) = msg_rx.recv().await {
|
while let Some(msg) = msg_rx.recv().await {
|
||||||
|
let post_url_clone = Arc::clone(&post_url_proxy);
|
||||||
|
let client = client.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
let mut attempts = 0;
|
let mut attempts = 0;
|
||||||
loop {
|
loop {
|
||||||
let url = post_url_clone.read().await.clone();
|
let url = post_url_clone.read().await.clone();
|
||||||
if !url.is_empty() {
|
if !url.is_empty() {
|
||||||
let res = client.post(&url)
|
let res = client
|
||||||
|
.post(&url)
|
||||||
.header("Accept", "application/json, text/event-stream")
|
.header("Accept", "application/json, text/event-stream")
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.body(msg.clone())
|
.body(msg.clone())
|
||||||
.send().await;
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
if res.is_ok() {
|
if let Ok(resp) = res { if resp.status().is_success() { break; } }
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
|
||||||
attempts += 1;
|
attempts += 1;
|
||||||
if attempts % 10 == 0 {
|
if attempts % 10 == 0 {
|
||||||
eprintln!("[PROXY] Waiting for server to accept messages...");
|
eprintln!("[PROXY] Waiting for server to accept messages...");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,20 +81,34 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let sse_url = format!("{}/sse", target_url);
|
let sse_url = format!("{}/sse", target_url);
|
||||||
let client = reqwest::Client::builder().build()?;
|
let client = reqwest::Client::builder().build()?;
|
||||||
|
|
||||||
match client.get(&sse_url).header("Accept", "text/event-stream").send().await {
|
match client
|
||||||
|
.get(&sse_url)
|
||||||
|
.header("Accept", "text/event-stream")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
if resp.status() == reqwest::StatusCode::GONE {
|
if resp.status() == reqwest::StatusCode::GONE {
|
||||||
eprintln!("[PROXY] Target gone, exiting.");
|
eprintln!("[PROXY] Target gone, exiting.");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let stream = resp.bytes_stream().map(|res| res.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)));
|
let stream = resp.bytes_stream().map(|res| {
|
||||||
|
res.map_err(std::io::Error::other)
|
||||||
|
});
|
||||||
let mut reader = tokio::io::BufReader::new(StreamReader::new(stream));
|
let mut reader = tokio::io::BufReader::new(StreamReader::new(stream));
|
||||||
let mut line = String::new();
|
let mut line = String::new();
|
||||||
let mut is_message = false;
|
let mut is_message = false;
|
||||||
let mut is_endpoint = false;
|
let mut is_endpoint = false;
|
||||||
|
|
||||||
while let Ok(bytes) = reader.read_line(&mut line).await {
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = shutdown_rx.recv() => {
|
||||||
|
return Ok(()); // Stdin closed, exit entirely
|
||||||
|
}
|
||||||
|
res = reader.read_line(&mut line) => {
|
||||||
|
match res {
|
||||||
|
Ok(bytes) => {
|
||||||
if bytes == 0 { break; }
|
if bytes == 0 { break; }
|
||||||
let trimmed = line.trim();
|
let trimmed = line.trim();
|
||||||
if trimmed.starts_with("event: message") {
|
if trimmed.starts_with("event: message") {
|
||||||
@@ -96,21 +117,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
} else if trimmed.starts_with("event: endpoint") {
|
} else if trimmed.starts_with("event: endpoint") {
|
||||||
is_endpoint = true;
|
is_endpoint = true;
|
||||||
is_message = false;
|
is_message = false;
|
||||||
} else if trimmed.starts_with("data: ") {
|
} else if let Some(stripped) = trimmed.strip_prefix("data: ") {
|
||||||
if is_message {
|
if is_message {
|
||||||
println!("{}", &trimmed[6..]);
|
println!("{}", stripped);
|
||||||
is_message = false;
|
is_message = false;
|
||||||
} else if is_endpoint {
|
} else if is_endpoint {
|
||||||
let ep = &trimmed[6..];
|
|
||||||
let mut p = post_url.write().await;
|
let mut p = post_url.write().await;
|
||||||
*p = format!("{}{}", target_url, ep);
|
*p = format!("{}{}", target_url, stripped);
|
||||||
is_endpoint = false;
|
is_endpoint = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
line.clear();
|
line.clear();
|
||||||
}
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
*post_url.write().await = String::new();
|
*post_url.write().await = String::new();
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
if let Some(ref cmd) = wake_cmd {
|
if let Some(ref cmd) = wake_cmd {
|
||||||
@@ -128,3 +153,5 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "mcp-memory-win-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"
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
mod mcp;
|
||||||
|
|
||||||
|
use mcp::{read_message, send_response, send_error, JsonRpcResponse};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tokio::net::windows::named_pipe::ClientOptions;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
loop {
|
||||||
|
let msg = match read_message().await {
|
||||||
|
Some(m) => m,
|
||||||
|
None => break,
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let id = msg.id.clone().unwrap_or(json!(null));
|
||||||
|
|
||||||
|
match msg.method.as_str() {
|
||||||
|
"initialize" => {
|
||||||
|
send_response(JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
id,
|
||||||
|
result: Some(json!({
|
||||||
|
"capabilities": {},
|
||||||
|
"serverInfo": {
|
||||||
|
"name": "mcp-memory-win-nvim",
|
||||||
|
"version": "0.1.0"
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
error: None,
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
"tools/list" => {
|
||||||
|
send_response(JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
id,
|
||||||
|
result: Some(json!({
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "nvim_goto_line",
|
||||||
|
"description": "Open a file and jump to a specific line",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"file": { "type": "string" },
|
||||||
|
"line": { "type": "integer" }
|
||||||
|
},
|
||||||
|
"required": ["file", "line"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "nvim_get_active_buffer",
|
||||||
|
"description": "Get the contents of the currently active Neovim buffer",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "nvim_get_cursor",
|
||||||
|
"description": "Get the current cursor position (line and column) in the active Neovim buffer",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "nvim_get_visual_selection",
|
||||||
|
"description": "Get the text that is currently highlighted or was last highlighted in Visual mode",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "nvim_set_diagnostics",
|
||||||
|
"description": "Push a diagnostic message (like an LSP warning) to a specific line in the active buffer",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"line": { "type": "integer" },
|
||||||
|
"message": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["line", "message"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})),
|
||||||
|
error: None,
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
"tools/call" => {
|
||||||
|
let params = msg.params.clone().unwrap_or(json!({}));
|
||||||
|
let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let args = params.get("arguments").cloned().unwrap_or(json!({}));
|
||||||
|
|
||||||
|
match name {
|
||||||
|
"nvim_goto_line" => {
|
||||||
|
let file = args.get("file").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(1);
|
||||||
|
|
||||||
|
let cmd = format!("edit {} | {} | normal! zz", file, line);
|
||||||
|
match send_nvim_command(&cmd).await {
|
||||||
|
Ok(_) => {
|
||||||
|
send_response(JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
id,
|
||||||
|
result: Some(json!({
|
||||||
|
"content": [
|
||||||
|
{ "type": "text", "text": format!("Successfully jumped to {}:{}", file, line) }
|
||||||
|
]
|
||||||
|
})),
|
||||||
|
error: None,
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
send_error(id, -32603, &format!("Failed to execute command: {}", e)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"nvim_get_active_buffer" => {
|
||||||
|
match get_nvim_active_buffer().await {
|
||||||
|
Ok(content) => {
|
||||||
|
send_response(JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
id,
|
||||||
|
result: Some(json!({
|
||||||
|
"content": [
|
||||||
|
{ "type": "text", "text": content }
|
||||||
|
]
|
||||||
|
})),
|
||||||
|
error: None,
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
send_error(id, -32603, &format!("Failed to get active buffer: {}", e)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"nvim_get_cursor" => {
|
||||||
|
match get_nvim_cursor().await {
|
||||||
|
Ok(content) => {
|
||||||
|
send_response(JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
id,
|
||||||
|
result: Some(json!({
|
||||||
|
"content": [
|
||||||
|
{ "type": "text", "text": content }
|
||||||
|
]
|
||||||
|
})),
|
||||||
|
error: None,
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
send_error(id, -32603, &format!("Failed to get cursor: {}", e)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"nvim_get_visual_selection" => {
|
||||||
|
match get_nvim_visual_selection().await {
|
||||||
|
Ok(content) => {
|
||||||
|
send_response(JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
id,
|
||||||
|
result: Some(json!({
|
||||||
|
"content": [
|
||||||
|
{ "type": "text", "text": content }
|
||||||
|
]
|
||||||
|
})),
|
||||||
|
error: None,
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
send_error(id, -32603, &format!("Failed to get visual selection: {}", e)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"nvim_set_diagnostics" => {
|
||||||
|
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(1);
|
||||||
|
let message = args.get("message").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
|
||||||
|
match set_nvim_diagnostics(line, message).await {
|
||||||
|
Ok(_) => {
|
||||||
|
send_response(JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
id,
|
||||||
|
result: Some(json!({
|
||||||
|
"content": [
|
||||||
|
{ "type": "text", "text": format!("Successfully set diagnostic on line {}", line) }
|
||||||
|
]
|
||||||
|
})),
|
||||||
|
error: None,
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
send_error(id, -32603, &format!("Failed to set diagnostic: {}", e)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
send_error(id, -32601, "Tool not found").await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Ignore other methods
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_socket_path() -> Result<String, String> {
|
||||||
|
// 1. Try auto-discovery in \\.\pipe\
|
||||||
|
if let Ok(dir) = std::fs::read_dir(r"\\.\pipe\") {
|
||||||
|
for entry in dir.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name_str = name.to_string_lossy();
|
||||||
|
if name_str.starts_with("nvim.") {
|
||||||
|
return Ok(format!(r"\\.\pipe\{}", name_str));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback to active_nvim.txt
|
||||||
|
let profile = std::env::var("USERPROFILE").unwrap_or_else(|_| "C:\\Users\\reazul.ashraf".into());
|
||||||
|
let path = format!("{}\\.gemini\\active_nvim.txt", profile);
|
||||||
|
|
||||||
|
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||||
|
let p = content.trim().to_string();
|
||||||
|
if !p.is_empty() {
|
||||||
|
return Ok(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err("Could not find active Windows Neovim named pipe".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
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 socket_path = get_socket_path().await?;
|
||||||
|
let mut client = ClientOptions::new().open(&socket_path).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
rmpv::encode::write_value(&mut buf, &req).map_err(|e| e.to_string())?;
|
||||||
|
client.write_all(&buf).await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
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()) && arr[1] == msgid {
|
||||||
|
return Ok(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
},
|
||||||
|
Err(_) => {
|
||||||
|
let n = client.read(&mut chunk).await.map_err(|e| e.to_string())?;
|
||||||
|
if n == 0 {
|
||||||
|
return Err("Unexpected EOF from Neovim".to_string());
|
||||||
|
}
|
||||||
|
resp_buf.extend_from_slice(&chunk[..n]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_nvim_command(cmd: &str) -> Result<(), String> {
|
||||||
|
use rmpv::Value as RmpValue;
|
||||||
|
let req = RmpValue::Array(vec![
|
||||||
|
RmpValue::Integer(0.into()),
|
||||||
|
RmpValue::Integer(1.into()), // msgid
|
||||||
|
RmpValue::String("nvim_command".into()),
|
||||||
|
RmpValue::Array(vec![RmpValue::String(cmd.into())]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let resp = call_nvim(req).await?;
|
||||||
|
if let RmpValue::Array(arr) = resp {
|
||||||
|
if !arr[2].is_nil() {
|
||||||
|
return Err(format!("Neovim error: {:?}", arr[2]));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err("Invalid response".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_nvim_active_buffer() -> Result<String, String> {
|
||||||
|
use rmpv::Value as RmpValue;
|
||||||
|
let req = RmpValue::Array(vec![
|
||||||
|
RmpValue::Integer(0.into()),
|
||||||
|
RmpValue::Integer(2.into()), // msgid
|
||||||
|
RmpValue::String("nvim_buf_get_lines".into()),
|
||||||
|
RmpValue::Array(vec![
|
||||||
|
RmpValue::Integer(0.into()),
|
||||||
|
RmpValue::Integer(0.into()),
|
||||||
|
RmpValue::Integer((-1).into()),
|
||||||
|
RmpValue::Boolean(true),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let resp = call_nvim(req).await?;
|
||||||
|
if let RmpValue::Array(arr) = resp {
|
||||||
|
if !arr[2].is_nil() {
|
||||||
|
return Err(format!("Neovim error: {:?}", arr[2]));
|
||||||
|
}
|
||||||
|
if let RmpValue::Array(lines) = &arr[3] {
|
||||||
|
let mut text = String::new();
|
||||||
|
for line in lines {
|
||||||
|
if let RmpValue::String(s) = line {
|
||||||
|
if let Some(s) = s.as_str() {
|
||||||
|
text.push_str(s);
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err("Invalid response".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_nvim_cursor() -> Result<String, String> {
|
||||||
|
use rmpv::Value as RmpValue;
|
||||||
|
let req = RmpValue::Array(vec![
|
||||||
|
RmpValue::Integer(0.into()),
|
||||||
|
RmpValue::Integer(3.into()), // msgid
|
||||||
|
RmpValue::String("nvim_win_get_cursor".into()),
|
||||||
|
RmpValue::Array(vec![
|
||||||
|
RmpValue::Integer(0.into()),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let resp = call_nvim(req).await?;
|
||||||
|
if let RmpValue::Array(arr) = resp {
|
||||||
|
if !arr[2].is_nil() {
|
||||||
|
return Err(format!("Neovim error: {:?}", arr[2]));
|
||||||
|
}
|
||||||
|
if let RmpValue::Array(pos) = &arr[3] {
|
||||||
|
if pos.len() == 2 {
|
||||||
|
if let (RmpValue::Integer(row), RmpValue::Integer(col)) = (&pos[0], &pos[1]) {
|
||||||
|
return Ok(format!("Line: {}, Column: {}", row, col));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err("Invalid response".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_nvim_visual_selection() -> Result<String, String> {
|
||||||
|
let lua_script = r#"
|
||||||
|
local _, csrow, cscol, _ = unpack(vim.fn.getpos("'<"))
|
||||||
|
local _, cerow, cecol, _ = unpack(vim.fn.getpos("'>"))
|
||||||
|
local lines = vim.fn.getline(csrow, cerow)
|
||||||
|
if type(lines) == "table" then
|
||||||
|
return table.concat(lines, "\n")
|
||||||
|
else
|
||||||
|
return lines
|
||||||
|
end
|
||||||
|
"#;
|
||||||
|
|
||||||
|
use rmpv::Value as RmpValue;
|
||||||
|
let req = RmpValue::Array(vec![
|
||||||
|
RmpValue::Integer(0.into()),
|
||||||
|
RmpValue::Integer(4.into()), // msgid
|
||||||
|
RmpValue::String("nvim_exec_lua".into()),
|
||||||
|
RmpValue::Array(vec![
|
||||||
|
RmpValue::String(lua_script.into()),
|
||||||
|
RmpValue::Array(vec![]),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let resp = call_nvim(req).await?;
|
||||||
|
if let RmpValue::Array(arr) = resp {
|
||||||
|
if !arr[2].is_nil() {
|
||||||
|
return Err(format!("Neovim error: {:?}", arr[2]));
|
||||||
|
}
|
||||||
|
if let RmpValue::String(s) = &arr[3] {
|
||||||
|
if let Some(text) = s.as_str() {
|
||||||
|
return Ok(text.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err("Invalid response".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_nvim_diagnostics(line: i64, message: &str) -> Result<(), String> {
|
||||||
|
let escaped_message = message.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||||
|
let lua_script = format!(r#"
|
||||||
|
local ns = vim.api.nvim_create_namespace("gemini_diagnostics")
|
||||||
|
local diagnostics = {{{{
|
||||||
|
lnum = {} - 1,
|
||||||
|
col = 0,
|
||||||
|
severity = vim.diagnostic.severity.WARN,
|
||||||
|
message = "{}",
|
||||||
|
}}}}
|
||||||
|
vim.diagnostic.set(ns, 0, diagnostics, {{}})
|
||||||
|
"#, line, escaped_message);
|
||||||
|
|
||||||
|
use rmpv::Value as RmpValue;
|
||||||
|
let req = RmpValue::Array(vec![
|
||||||
|
RmpValue::Integer(0.into()),
|
||||||
|
RmpValue::Integer(5.into()), // msgid
|
||||||
|
RmpValue::String("nvim_exec_lua".into()),
|
||||||
|
RmpValue::Array(vec![
|
||||||
|
RmpValue::String(lua_script.into()),
|
||||||
|
RmpValue::Array(vec![]),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let resp = call_nvim(req).await?;
|
||||||
|
if let RmpValue::Array(arr) = resp {
|
||||||
|
if !arr[2].is_nil() {
|
||||||
|
return Err(format!("Neovim error: {:?}", arr[2]));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err("Invalid response".to_string())
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct JsonRpcRequest {
|
||||||
|
pub jsonrpc: String,
|
||||||
|
pub id: Option<Value>,
|
||||||
|
pub method: String,
|
||||||
|
pub params: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Debug, Clone)]
|
||||||
|
pub struct JsonRpcResponse {
|
||||||
|
pub jsonrpc: String,
|
||||||
|
pub id: Value,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub result: Option<Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read_message() -> Option<JsonRpcRequest> {
|
||||||
|
let mut stdin = BufReader::new(tokio::io::stdin());
|
||||||
|
let mut length = 0;
|
||||||
|
loop {
|
||||||
|
let mut line = String::new();
|
||||||
|
if stdin.read_line(&mut line).await.unwrap_or(0) == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
let mut buffer = vec![0; length];
|
||||||
|
stdin.read_exact(&mut buffer).await.unwrap_or(0);
|
||||||
|
serde_json::from_slice(&buffer).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_response(response: JsonRpcResponse) {
|
||||||
|
let msg = serde_json::to_string(&response).unwrap();
|
||||||
|
let payload = format!("Content-Length: {}\r\n\r\n{}", msg.len(), msg);
|
||||||
|
let mut stdout = tokio::io::stdout();
|
||||||
|
let _ = stdout.write_all(payload.as_bytes()).await;
|
||||||
|
let _ = stdout.flush().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_error(id: Value, code: i32, message: &str) {
|
||||||
|
send_response(JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
id,
|
||||||
|
result: None,
|
||||||
|
error: Some(serde_json::json!({"code": code, "message": message})),
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user