From 4cd29f91e593a35c57e4917dc4de000ef41b12c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 30 May 2026 23:51:45 -0400 Subject: [PATCH] Add Ely-Eye MCP server Local read-only Model Context Protocol server (Go) exposing the .ely_eye memory store to coding agents such as Codex and Claude Code: nine tools over status, cartridges, Evidence Atoms, compiled evidence packs, proof suites, and HashHop proofs. One-line cross-platform install (no Docker), with a prebuilt-binary release workflow. --- .github/workflows/mcp-release.yml | 76 +++++++ README.md | 33 ++- ely-eye-mcp/.gitattributes | 18 ++ ely-eye-mcp/README.md | 77 +++++++ ely-eye-mcp/cmd/ely-eye-mcp/main.go | 16 ++ ely-eye-mcp/docs/RESEARCH.md | 55 +++++ ely-eye-mcp/go.mod | 25 +++ ely-eye-mcp/go.sum | 67 ++++++ ely-eye-mcp/install.ps1 | 126 +++++++++++ ely-eye-mcp/install.sh | 143 ++++++++++++ ely-eye-mcp/internal/cli/cli.go | 128 +++++++++++ ely-eye-mcp/internal/config/config.go | 87 ++++++++ ely-eye-mcp/internal/contextpack/compiler.go | 175 +++++++++++++++ ely-eye-mcp/internal/domain/types.go | 148 +++++++++++++ ely-eye-mcp/internal/mcpserver/server.go | 83 +++++++ ely-eye-mcp/internal/mcpserver/tools.go | 212 ++++++++++++++++++ ely-eye-mcp/internal/setup/setup.go | 220 +++++++++++++++++++ ely-eye-mcp/internal/store/atoms.go | 200 +++++++++++++++++ ely-eye-mcp/internal/store/cartridge.go | 135 ++++++++++++ ely-eye-mcp/internal/store/json_value.go | 35 +++ ely-eye-mcp/internal/store/proof_evidence.go | 167 ++++++++++++++ ely-eye-mcp/internal/store/proofs.go | 104 +++++++++ ely-eye-mcp/internal/store/sqlite.go | 136 ++++++++++++ 23 files changed, 2463 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/mcp-release.yml create mode 100644 ely-eye-mcp/.gitattributes create mode 100644 ely-eye-mcp/README.md create mode 100644 ely-eye-mcp/cmd/ely-eye-mcp/main.go create mode 100644 ely-eye-mcp/docs/RESEARCH.md create mode 100644 ely-eye-mcp/go.mod create mode 100644 ely-eye-mcp/go.sum create mode 100644 ely-eye-mcp/install.ps1 create mode 100644 ely-eye-mcp/install.sh create mode 100644 ely-eye-mcp/internal/cli/cli.go create mode 100644 ely-eye-mcp/internal/config/config.go create mode 100644 ely-eye-mcp/internal/contextpack/compiler.go create mode 100644 ely-eye-mcp/internal/domain/types.go create mode 100644 ely-eye-mcp/internal/mcpserver/server.go create mode 100644 ely-eye-mcp/internal/mcpserver/tools.go create mode 100644 ely-eye-mcp/internal/setup/setup.go create mode 100644 ely-eye-mcp/internal/store/atoms.go create mode 100644 ely-eye-mcp/internal/store/cartridge.go create mode 100644 ely-eye-mcp/internal/store/json_value.go create mode 100644 ely-eye-mcp/internal/store/proof_evidence.go create mode 100644 ely-eye-mcp/internal/store/proofs.go create mode 100644 ely-eye-mcp/internal/store/sqlite.go diff --git a/.github/workflows/mcp-release.yml b/.github/workflows/mcp-release.yml new file mode 100644 index 0000000..f23fd40 --- /dev/null +++ b/.github/workflows/mcp-release.yml @@ -0,0 +1,76 @@ +name: ely-eye-mcp release + +on: + push: + tags: + - "mcp-v*" + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - { goos: linux, goarch: amd64 } + - { goos: linux, goarch: arm64 } + - { goos: darwin, goarch: amd64 } + - { goos: darwin, goarch: arm64 } + - { goos: windows, goarch: amd64 } + - { goos: windows, goarch: arm64 } + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + cache-dependency-path: ely-eye-mcp/go.sum + - name: Build and archive + working-directory: ely-eye-mcp + env: + CGO_ENABLED: "0" + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#mcp-}" + binary="ely-eye-mcp" + if [ "$GOOS" = "windows" ]; then binary="ely-eye-mcp.exe"; fi + ldflags="-s -w -X github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/cli.version=${version}" + mkdir -p dist + go build -trimpath -ldflags "$ldflags" -o "dist/${binary}" ./cmd/ely-eye-mcp + base="ely-eye-mcp_${GOOS}_${GOARCH}" + cd dist + if [ "$GOOS" = "windows" ]; then + zip "${base}.zip" "$binary" + else + tar -czf "${base}.tar.gz" "$binary" + fi + rm -f "$binary" + - uses: actions/upload-artifact@v4 + with: + name: dist-${{ matrix.goos }}-${{ matrix.goarch }} + path: ely-eye-mcp/dist/* + if-no-files-found: error + + release: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + - name: Generate checksums + run: | + set -euo pipefail + cd dist + # Match only the per-target archives (ely-eye-mcp__.*); the + # single-underscore SHA256SUMS file is excluded so it never hashes itself. + sha256sum ely-eye-mcp_*_* > ely-eye-mcp_SHA256SUMS.txt + - uses: softprops/action-gh-release@v2 + with: + files: dist/* + fail_on_unmatched_files: true + generate_release_notes: true diff --git a/README.md b/README.md index acbff26..f38c917 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,32 @@ evidence under `.ely_eye/data/eval_proofs/`. Among them: .\.venv\Scripts\ely-eye.exe visual-contradiction-proof ``` +## MCP server for coding agents + +`ely-eye-mcp/` is a local Model Context Protocol server that exposes the +`.ely_eye` memory store to coding agents such as Codex and Claude Code. One +command builds a native Go binary (or downloads a prebuilt one), installs it +under `.ely_eye/bin/`, and registers it with both clients. No Docker. + +Windows: + +```powershell +powershell -ExecutionPolicy Bypass -File .\ely-eye-mcp\install.ps1 -Client both +``` + +Linux and macOS: + +```bash +bash ./ely-eye-mcp/install.sh --client both +``` + +The agent then has nine read-only tools over the live memory — status, +cartridge listing and detail, Evidence Atom search and fetch, evidence-pack +compilation, proof suites, and HashHop proofs. The server opens the database +read-only and never writes to it. The same installer works against any +`.ely_eye` store, not only this repository. See `ely-eye-mcp/README.md` for the +full tool list and the release pipeline. + ## Data layout Runtime state lives under `.ely_eye/` (git-ignored): @@ -120,7 +146,8 @@ capsule, any attached adapters, and the eval proofs. ## Project layout ```text -backend/ Python: ingestion, retrieval, runtime, training, proof suite, FastAPI -frontend/ the local dashboard (Vite + React) -scripts/ PowerShell: install, model download, serving, runtimes, fine-tuning +backend/ Python: ingestion, retrieval, runtime, training, proof suite, FastAPI +frontend/ the local dashboard (Vite + React) +scripts/ PowerShell: install, model download, serving, runtimes, fine-tuning +ely-eye-mcp/ Go: local MCP server exposing the memory store to coding agents ``` diff --git a/ely-eye-mcp/.gitattributes b/ely-eye-mcp/.gitattributes new file mode 100644 index 0000000..06ee902 --- /dev/null +++ b/ely-eye-mcp/.gitattributes @@ -0,0 +1,18 @@ +# Deterministic line endings for the MCP project on Windows, Linux, and macOS. +# A bash installer checked out with CRLF has a broken shebang on Unix, so shell +# scripts are pinned to LF regardless of the user's core.autocrlf setting. +* text=auto + +*.sh text eol=lf +*.ps1 text eol=crlf +*.go text eol=lf +*.mod text eol=lf +*.sum text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.json text eol=lf +*.md text eol=lf + +*.exe binary +*.tar.gz binary +*.zip binary diff --git a/ely-eye-mcp/README.md b/ely-eye-mcp/README.md new file mode 100644 index 0000000..06c62a0 --- /dev/null +++ b/ely-eye-mcp/README.md @@ -0,0 +1,77 @@ +# Ely-Eye MCP + +Ely-Eye MCP exposes a local Context Cartridge memory store as a stdio Model Context Protocol server for coding agents such as Codex and Claude Code. + +The server is local-first and read-only against `/data/ely_eye.sqlite`. It reads Evidence Atoms, cartridge manifests, Memory DNA, proof-suite artifacts, HashHop proofs, and compiled evidence packs. The production database is never modified. + +It can be deployed for any `.ely_eye` store. Identity is anchored on the database file, not on the Ely-Eye repository layout, so the server walks up from the working directory to find `.ely_eye/data/ely_eye.sqlite`, or takes an explicit `--ely-eye-home`. + +## One-command setup + +The installer downloads a checksum-verified prebuilt binary for the platform when a tagged release is available, otherwise it builds from source with Go from a repository checkout. Either way it installs the binary into `/bin` and registers the server with Codex and Claude Code. No Docker. + +Windows: + +```powershell +powershell -ExecutionPolicy Bypass -File .\ely-eye-mcp\install.ps1 -Client both +``` + +Linux and macOS: + +```bash +bash ./ely-eye-mcp/install.sh --client both +``` + +Force a source build (requires Go 1.25 or newer): + +```bash +bash ./ely-eye-mcp/install.sh --method source +``` + +Codex registration uses `codex mcp add`. Claude Code project registration writes `.mcp.json` at the project root; `--scope user` registers through the Claude CLI instead. + +## Manual run + +From inside a project that owns an `.ely_eye` store: + +```bash +go run ./cmd/ely-eye-mcp server +``` + +Or point at an explicit store: + +```bash +go run ./cmd/ely-eye-mcp server --ely-eye-home /path/to/.ely_eye +``` + +## Tools + +`ely_eye_status` returns atom, source, cartridge, token, and cache-layer counts. + +`list_cartridges` lists local Context Cartridges with manifest JSON and Memory DNA. + +`get_cartridge` reads one cartridge plus artifact metadata, memory capsule index, and asset report. + +`search_atoms` queries the local SQLite FTS index and returns ranked Evidence Atoms. + +`fetch_atom` reads one Evidence Atom by exact id. + +`compile_context` compiles a question into a local evidence pack with the same profile names used by the app (`live_demo`, `extreme_context`, `library_100m`, `research_theater`). + +`list_proof_suites` lists PRD proof suites under `/data/eval_proofs`. + +`list_hashhop_proofs` aggregates HashHop and Visual HashHop long-context addressing proofs by kind, hop count, and token budget, with pass counts and example proofs. Proofs are deduplicated by content-derived id. + +`get_proof_suite` reads one PRD proof suite by id, including all checks and a summary of its HashHop proofs. + +## Resources + +`ely-eye://project/prd`, `ely-eye://project/readme`, and `ely-eye://project/mcp-research` expose `PRD.md`, the project README, and these research notes when those files are present next to the store. + +## Releases + +Pushing a `mcp-v*` tag runs `.github/workflows/mcp-release.yml`, which cross-compiles the pure-Go server (CGO disabled) for Linux, macOS, and Windows on amd64 and arm64, then publishes the archives with a `SHA256SUMS` manifest to a GitHub Release. The installers consume those assets. + +## Security posture + +The server opens SQLite read-only with `mode=ro` and `PRAGMA query_only(1)`, and uses `busy_timeout` so reads tolerate concurrent writes by the Ely-Eye app. Tools expose structured outputs and return tool-level errors for invalid inputs so agents self-correct through normal tool calls. Proof ids are validated as single path elements before any filesystem read. The server uses stdio transport for local clients and takes credentials only from the local environment. diff --git a/ely-eye-mcp/cmd/ely-eye-mcp/main.go b/ely-eye-mcp/cmd/ely-eye-mcp/main.go new file mode 100644 index 0000000..2430796 --- /dev/null +++ b/ely-eye-mcp/cmd/ely-eye-mcp/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/cli" +) + +func main() { + if err := cli.Run(context.Background(), os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/ely-eye-mcp/docs/RESEARCH.md b/ely-eye-mcp/docs/RESEARCH.md new file mode 100644 index 0000000..dbe0730 --- /dev/null +++ b/ely-eye-mcp/docs/RESEARCH.md @@ -0,0 +1,55 @@ +# Ely-Eye MCP Research Notes + +Date: 2026-05-30 + +## Current protocol baseline + +The MCP specification latest version observed during implementation is `2025-11-25`. It defines a JSON-RPC 2.0 lifecycle with initialization, capability negotiation, normal operation, and transport-level shutdown. Server features are resources, prompts, and tools; client features include roots, sampling, and elicitation. + +Local Ely-Eye deployment uses stdio transport because Codex and Claude Code both support local stdio MCP servers. HTTP remains the better fit for remote shared services with OAuth; Ely-Eye memory is local project state, so stdio keeps the trust boundary on the user's machine. + +The official Go SDK is Tier 1 in the MCP SDK list and supports the latest 2025-11-25 spec in v1.4.0 and newer. This project uses `github.com/modelcontextprotocol/go-sdk` v1.6.1. + +## Product mapping + +Ely-Eye already materializes Context Cartridges, Evidence Atoms, memory capsule indexes, proof suites, and cache events. The MCP layer maps those into agent-native capabilities: + +| Ely-Eye object | MCP surface | +|---|---| +| Evidence Atom | `search_atoms`, `fetch_atom` | +| Context Cartridge | `list_cartridges`, `get_cartridge` | +| Context Compiler | `compile_context` | +| PRD and docs | MCP resources | +| Proof Suite | `list_proof_suites`, `get_proof_suite` | +| HashHop / Visual HashHop | `list_hashhop_proofs`, `get_proof_suite` | +| Cache Fabric | `ely_eye_status` | + +## Long-context research anchors + +Magic's LTM-2-mini announcement establishes the design target: 100M-token context and HashHop as a stricter long-context addressing test than needle-in-a-haystack. Ely-Eye keeps 100M as a logical memory capsule and exposes the capsule through MCP as cartridge scope. Single-request full-attention execution belongs to the physical-model profile. + +Qwen3.5-9B supplies the base local VLM target: 262K native context and a 1.01M YaRN profile. Qwen3-VL-Embedding-8B supplies multimodal retrieval coverage across text, images, screenshots, video, and multimodal combinations. + +Native Sparse Attention, VL-Cache, SparseVLM, DuoAttention, and the TTT-E2E/Titans line shape the memory system: sparse long-context compute, modality-aware KV compression, visual token sparsification, retrieval/streaming head separation, and test-time memory formation. The MCP server exposes these as evidence and proof artifacts so coding agents can inspect what exists locally before proposing changes. + +## Security anchors + +Recent MCP security papers and threat taxonomies identify tool poisoning, prompt injection, over-broad tool permissions, and hidden metadata attacks as central risks. This implementation limits the first MCP milestone to read-only local tools, concise tool descriptions, structured output schemas, and project-scoped Claude configuration. Write operations can be added as separate audited tools with explicit names and narrow inputs. + +## Sources + +- Model Context Protocol specification 2025-11-25: https://modelcontextprotocol.io/specification/2025-11-25 +- MCP Go SDK: https://github.com/modelcontextprotocol/go-sdk +- Claude Code MCP docs: https://code.claude.com/docs/en/mcp +- OpenAI Docs MCP and Codex config docs: https://developers.openai.com/learn/docs-mcp +- Magic 100M Token Context Windows: https://magic.dev/blog/100m-token-context-windows +- Qwen3.5-9B model card: https://huggingface.co/Qwen/Qwen3.5-9B +- Qwen3-VL-Embedding-8B model card: https://huggingface.co/Qwen/Qwen3-VL-Embedding-8B +- Native Sparse Attention: https://arxiv.org/abs/2502.11089 +- End-to-End Test-Time Training for Long Context: https://arxiv.org/abs/2512.23675 +- VL-Cache: https://arxiv.org/abs/2410.23317 +- SparseVLM: https://arxiv.org/abs/2410.04417 +- DuoAttention: https://arxiv.org/abs/2410.10819 +- Titans: https://arxiv.org/abs/2501.00663 +- Model Context Protocol security landscape: https://arxiv.org/abs/2503.23278 +- MCP threat modeling and tool poisoning: https://arxiv.org/abs/2603.22489 diff --git a/ely-eye-mcp/go.mod b/ely-eye-mcp/go.mod new file mode 100644 index 0000000..e2e3048 --- /dev/null +++ b/ely-eye-mcp/go.mod @@ -0,0 +1,25 @@ +module github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp + +go 1.25.0 + +require ( + github.com/modelcontextprotocol/go-sdk v1.6.1 + modernc.org/sqlite v1.51.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/ely-eye-mcp/go.sum b/ely-eye-mcp/go.sum new file mode 100644 index 0000000..1612346 --- /dev/null +++ b/ely-eye-mcp/go.sum @@ -0,0 +1,67 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U= +modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/ely-eye-mcp/install.ps1 b/ely-eye-mcp/install.ps1 new file mode 100644 index 0000000..0c5c0bf --- /dev/null +++ b/ely-eye-mcp/install.ps1 @@ -0,0 +1,126 @@ +#requires -version 5.1 +[CmdletBinding()] +param( + [ValidateSet("codex", "claude", "both")] + [string]$Client = "both", + + [ValidateSet("project", "user")] + [string]$Scope = "project", + + [string]$ServerName = "ely-eye", + + [ValidateSet("auto", "download", "source")] + [string]$Method = "auto", + + [string]$Version = "", + [string]$ProjectRoot = "", + [string]$ElyEyeHome = "", + [string]$Repo = "ZacharyZhang-NY/ely-eye" +) + +# Ely-Eye MCP installer for Windows. By default it downloads a prebuilt, +# checksum-verified binary for this platform and needs no toolchain. Use +# -Method source from a repository checkout to build with Go instead. + +$ErrorActionPreference = "Stop" +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +if (-not $ProjectRoot) { + if ($PSScriptRoot -and (Test-Path (Join-Path $PSScriptRoot "go.mod"))) { + $ProjectRoot = Split-Path -Parent $PSScriptRoot + } + else { + $ProjectRoot = (Get-Location).Path + } +} +$ProjectRoot = (Resolve-Path $ProjectRoot).Path +if (-not $ElyEyeHome) { $ElyEyeHome = Join-Path $ProjectRoot ".ely_eye" } +$BinDir = Join-Path $ElyEyeHome "bin" +New-Item -ItemType Directory -Force -Path $BinDir | Out-Null +$Binary = Join-Path $BinDir "ely-eye-mcp.exe" + +function Get-Platform { + # PROCESSOR_ARCHITEW6432 holds the true machine architecture when PowerShell + # runs as an x64-emulated process on ARM64 Windows; fall back to the process + # architecture otherwise. + $arch = $env:PROCESSOR_ARCHITEW6432 + if (-not $arch) { $arch = $env:PROCESSOR_ARCHITECTURE } + switch ($arch) { + "AMD64" { return "windows_amd64" } + "ARM64" { return "windows_arm64" } + default { throw "unsupported architecture: $arch" } + } +} + +function Resolve-Version { + if ($Version) { return $Version } + try { + $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases" -Headers @{ "User-Agent" = "ely-eye-mcp-installer" } + } + catch { return "" } + $match = $releases | Where-Object { $_.tag_name -like "mcp-*" } | Select-Object -First 1 + if ($match) { return $match.tag_name } + return "" +} + +function Build-FromSource { + if (-not (Get-Command go -ErrorAction SilentlyContinue)) { throw "Go 1.25 or newer is required to build from source." } + if (-not ($PSScriptRoot -and (Test-Path (Join-Path $PSScriptRoot "go.mod")))) { throw "source builds must run from a repository checkout." } + Write-Host "Building ely-eye-mcp from source with Go." + $env:CGO_ENABLED = "0" + Push-Location $PSScriptRoot + try { + go build -trimpath -ldflags="-s -w" -o $Binary .\cmd\ely-eye-mcp + if ($LASTEXITCODE -ne 0) { throw "go build failed with exit code $LASTEXITCODE." } + } + finally { Pop-Location } +} + +function Download-Binary([string]$Tag) { + $platform = Get-Platform + $asset = "ely-eye-mcp_$platform.zip" + $url = "https://github.com/$Repo/releases/download/$Tag/$asset" + $sumsUrl = "https://github.com/$Repo/releases/download/$Tag/ely-eye-mcp_SHA256SUMS.txt" + $tmp = Join-Path ([IO.Path]::GetTempPath()) ("ely-eye-mcp-" + [Guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Force -Path $tmp | Out-Null + try { + Write-Host "Downloading $asset from release $Tag." + $archive = Join-Path $tmp $asset + Invoke-WebRequest -Uri $url -OutFile $archive + $sumsFile = Join-Path $tmp "SHA256SUMS" + Invoke-WebRequest -Uri $sumsUrl -OutFile $sumsFile + $line = Select-String -Path $sumsFile -Pattern ("\*?" + [Regex]::Escape($asset) + "$") | Select-Object -First 1 + if (-not $line) { throw "checksum for $asset not found in release." } + $expected = ($line.Line -split "\s+")[0] + $actual = (Get-FileHash -Algorithm SHA256 -Path $archive).Hash + if ($expected.ToLower() -ne $actual.ToLower()) { throw "checksum mismatch for $asset." } + Expand-Archive -Force -Path $archive -DestinationPath $tmp + Move-Item -Force -Path (Join-Path $tmp "ely-eye-mcp.exe") -Destination $Binary + } + finally { Remove-Item -Recurse -Force $tmp } +} + +switch ($Method) { + "source" { Build-FromSource } + "download" { + $tag = Resolve-Version + if (-not $tag) { throw "no prebuilt release found for $Repo." } + Download-Binary $tag + } + "auto" { + $tag = Resolve-Version + if ($tag) { Download-Binary $tag } + else { + Write-Host "No prebuilt release available; building from source." + Build-FromSource + } + } +} + +& $Binary setup ` + --client $Client ` + --scope $Scope ` + --server-name $ServerName ` + --project-root $ProjectRoot ` + --ely-eye-home $ElyEyeHome ` + --binary $Binary diff --git a/ely-eye-mcp/install.sh b/ely-eye-mcp/install.sh new file mode 100644 index 0000000..0a460c9 --- /dev/null +++ b/ely-eye-mcp/install.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Ely-Eye MCP installer for Linux and macOS. +# +# curl -fsSL https://raw.githubusercontent.com/ZacharyZhang-NY/ely-eye/main/ely-eye-mcp/install.sh | bash +# +# By default it downloads a prebuilt, checksum-verified binary for this platform +# and needs no toolchain. Run with --method source from a repository checkout to +# build with Go instead. The binary is installed into /bin and +# registered with Codex and Claude Code. + +REPO="ZacharyZhang-NY/ely-eye" +CLIENT="both" +SCOPE="project" +SERVER_NAME="ely-eye" +METHOD="auto" +VERSION="" +PROJECT_ROOT="" +ELY_EYE_HOME="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --client) CLIENT="$2"; shift 2 ;; + --scope) SCOPE="$2"; shift 2 ;; + --server-name) SERVER_NAME="$2"; shift 2 ;; + --method) METHOD="$2"; shift 2 ;; + --version) VERSION="$2"; shift 2 ;; + --project-root) PROJECT_ROOT="$2"; shift 2 ;; + --ely-eye-home) ELY_EYE_HOME="$2"; shift 2 ;; + --repo) REPO="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +case "$METHOD" in auto | download | source) ;; *) echo "method must be auto, download, or source" >&2; exit 1 ;; esac + +SOURCE="${BASH_SOURCE[0]:-}" +SCRIPT_DIR="" +if [[ -n "$SOURCE" && -f "$SOURCE" ]]; then + SCRIPT_DIR="$(cd -- "$(dirname -- "$SOURCE")" && pwd)" +fi + +if [[ -z "$PROJECT_ROOT" ]]; then + if [[ -n "$SCRIPT_DIR" && -f "$SCRIPT_DIR/go.mod" ]]; then + PROJECT_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)" + else + PROJECT_ROOT="$PWD" + fi +fi +PROJECT_ROOT="$(cd -- "$PROJECT_ROOT" && pwd)" +[[ -z "$ELY_EYE_HOME" ]] && ELY_EYE_HOME="$PROJECT_ROOT/.ely_eye" +mkdir -p "$ELY_EYE_HOME/bin" +BINARY="$ELY_EYE_HOME/bin/ely-eye-mcp" + +detect_platform() { + local os arch + case "$(uname -s)" in + Linux) os="linux" ;; + Darwin) os="darwin" ;; + *) echo "unsupported operating system: $(uname -s)" >&2; exit 1 ;; + esac + case "$(uname -m)" in + x86_64 | amd64) arch="amd64" ;; + arm64 | aarch64) arch="arm64" ;; + *) echo "unsupported architecture: $(uname -m)" >&2; exit 1 ;; + esac + echo "${os}_${arch}" +} + +sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +resolve_version() { + if [[ -n "$VERSION" ]]; then echo "$VERSION"; return 0; fi + local body + body="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases" 2>/dev/null)" || return 0 + printf '%s\n' "$body" \ + | grep -oE '"tag_name": *"mcp-[^"]*"' \ + | head -n 1 \ + | sed -E 's/.*"(mcp-[^"]*)".*/\1/' || true +} + +build_from_source() { + command -v go >/dev/null 2>&1 || { echo "Go 1.25 or newer is required to build from source." >&2; exit 1; } + [[ -n "$SCRIPT_DIR" && -f "$SCRIPT_DIR/go.mod" ]] || { echo "source builds must run from a repository checkout." >&2; exit 1; } + echo "Building ely-eye-mcp from source with Go." + ( cd "$SCRIPT_DIR" && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o "$BINARY" ./cmd/ely-eye-mcp ) +} + +download_binary() { + local tag="$1" platform asset url sums tmp + platform="$(detect_platform)" + asset="ely-eye-mcp_${platform}.tar.gz" + url="https://github.com/${REPO}/releases/download/${tag}/${asset}" + sums="https://github.com/${REPO}/releases/download/${tag}/ely-eye-mcp_SHA256SUMS.txt" + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + echo "Downloading $asset from release $tag." + curl -fsSL "$url" -o "$tmp/$asset" + curl -fsSL "$sums" -o "$tmp/SHA256SUMS" + local expected actual + expected="$(grep -E " \*?${asset}\$" "$tmp/SHA256SUMS" | awk '{print $1}' || true)" + [[ -n "$expected" ]] || { echo "checksum for $asset not found in release." >&2; exit 1; } + actual="$(sha256 "$tmp/$asset")" + [[ "$expected" == "$actual" ]] || { echo "checksum mismatch for $asset." >&2; exit 1; } + tar -xzf "$tmp/$asset" -C "$tmp" + mv "$tmp/ely-eye-mcp" "$BINARY" + chmod +x "$BINARY" +} + +acquire() { + case "$METHOD" in + source) build_from_source; return ;; + download) + local tag; tag="$(resolve_version)" + [[ -n "$tag" ]] || { echo "no prebuilt release found for ${REPO}." >&2; exit 1; } + download_binary "$tag"; return ;; + auto) + local tag; tag="$(resolve_version)" + if [[ -n "$tag" ]]; then + download_binary "$tag" + else + echo "No prebuilt release available; building from source." + build_from_source + fi ;; + esac +} + +acquire + +"$BINARY" setup \ + --client "$CLIENT" \ + --scope "$SCOPE" \ + --server-name "$SERVER_NAME" \ + --project-root "$PROJECT_ROOT" \ + --ely-eye-home "$ELY_EYE_HOME" \ + --binary "$BINARY" diff --git a/ely-eye-mcp/internal/cli/cli.go b/ely-eye-mcp/internal/cli/cli.go new file mode 100644 index 0000000..714158e --- /dev/null +++ b/ely-eye-mcp/internal/cli/cli.go @@ -0,0 +1,128 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + + "github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/config" + "github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/mcpserver" + "github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/setup" + "github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/store" +) + +// version is stamped at release build time via -ldflags -X. The default is the +// development version used by source builds. +var version = "0.1.0" + +func Run(ctx context.Context, args []string) error { + if len(args) == 0 { + return errors.New(usage()) + } + switch args[0] { + case "server": + return runServer(ctx, args[1:]) + case "setup": + return runSetup(ctx, args[1:]) + case "status": + return runStatus(ctx, args[1:]) + case "version": + fmt.Println(version) + return nil + case "help", "-h", "--help": + fmt.Print(usage()) + return nil + default: + return errors.New("unknown command: " + args[0]) + } +} + +func runServer(ctx context.Context, args []string) error { + flags := flag.NewFlagSet("server", flag.ContinueOnError) + projectRoot := flags.String("project-root", "", "Ely-Eye project root") + elyEyeHome := flags.String("ely-eye-home", "", "Ely-Eye data home") + flags.SetOutput(os.Stderr) + if err := flags.Parse(args); err != nil { + return err + } + cfg, err := config.Resolve(*projectRoot, *elyEyeHome, "") + if err != nil { + return err + } + return mcpserver.Run(ctx, cfg, version) +} + +func runSetup(ctx context.Context, args []string) error { + flags := flag.NewFlagSet("setup", flag.ContinueOnError) + projectRoot := flags.String("project-root", "", "Ely-Eye project root") + elyEyeHome := flags.String("ely-eye-home", "", "Ely-Eye data home") + binary := flags.String("binary", "", "MCP server binary path") + client := flags.String("client", "both", "codex, claude, or both") + scope := flags.String("scope", "project", "Claude scope: project or user") + serverName := flags.String("server-name", "ely-eye", "MCP server name") + flags.SetOutput(os.Stderr) + if err := flags.Parse(args); err != nil { + return err + } + cfg, err := config.Resolve(*projectRoot, *elyEyeHome, *binary) + if err != nil { + return err + } + result, err := setup.Run(ctx, cfg, setup.Options{ + Client: *client, + Scope: *scope, + ServerName: *serverName, + }) + if err != nil { + return err + } + return printJSON(result) +} + +func runStatus(ctx context.Context, args []string) error { + flags := flag.NewFlagSet("status", flag.ContinueOnError) + projectRoot := flags.String("project-root", "", "Ely-Eye project root") + elyEyeHome := flags.String("ely-eye-home", "", "Ely-Eye data home") + flags.SetOutput(os.Stderr) + if err := flags.Parse(args); err != nil { + return err + } + cfg, err := config.Resolve(*projectRoot, *elyEyeHome, "") + if err != nil { + return err + } + db, err := store.Open(ctx, cfg) + if err != nil { + return err + } + defer db.Close() + status, err := db.Status(ctx) + if err != nil { + return err + } + return printJSON(status) +} + +func printJSON(value any) error { + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + fmt.Println(string(encoded)) + return nil +} + +func usage() string { + return `ely-eye-mcp + +Commands: + server Run the MCP server over stdio + setup Register the MCP server with Codex and Claude Code + status Print read-only Ely-Eye memory status + version Print the binary version + +` +} diff --git a/ely-eye-mcp/internal/config/config.go b/ely-eye-mcp/internal/config/config.go new file mode 100644 index 0000000..5bf61de --- /dev/null +++ b/ely-eye-mcp/internal/config/config.go @@ -0,0 +1,87 @@ +package config + +import ( + "errors" + "os" + "path/filepath" +) + +type Config struct { + ProjectRoot string `json:"project_root"` + ElyEyeHome string `json:"ely_eye_home"` + Database string `json:"database"` + Binary string `json:"binary"` +} + +// Resolve locates the Ely-Eye data home and its SQLite database. The server is +// a read-only mirror of any local .ely_eye store, so identity is anchored on the +// database itself rather than on Ely-Eye repository layout. This lets the MCP +// server be deployed for any project that owns an .ely_eye/data/ely_eye.sqlite. +func Resolve(projectRoot string, elyEyeHome string, binary string) (Config, error) { + home, err := resolveHome(projectRoot, elyEyeHome) + if err != nil { + return Config{}, err + } + db := filepath.Join(home, "data", "ely_eye.sqlite") + if _, err := os.Stat(db); err != nil { + return Config{}, errors.New("Ely-Eye database is required at " + db) + } + root := projectRoot + if root == "" { + root = filepath.Dir(home) + } + root, err = filepath.Abs(root) + if err != nil { + return Config{}, err + } + if binary == "" { + binary, _ = os.Executable() + } + if binary != "" { + binary, err = filepath.Abs(binary) + if err != nil { + return Config{}, err + } + } + return Config{ + ProjectRoot: root, + ElyEyeHome: home, + Database: db, + Binary: binary, + }, nil +} + +func resolveHome(projectRoot string, elyEyeHome string) (string, error) { + if elyEyeHome == "" { + elyEyeHome = os.Getenv("ELY_EYE_HOME") + } + if elyEyeHome != "" { + return filepath.Abs(elyEyeHome) + } + if projectRoot != "" { + abs, err := filepath.Abs(projectRoot) + if err != nil { + return "", err + } + return filepath.Join(abs, ".ely_eye"), nil + } + return discoverHome() +} + +func discoverHome() (string, error) { + current, err := os.Getwd() + if err != nil { + return "", err + } + for { + home := filepath.Join(current, ".ely_eye") + if _, err := os.Stat(filepath.Join(home, "data", "ely_eye.sqlite")); err == nil { + return home, nil + } + next := filepath.Dir(current) + if next == current { + return "", errors.New("cannot find an Ely-Eye data home; pass --ely-eye-home or set ELY_EYE_HOME") + } + current = next + } +} diff --git a/ely-eye-mcp/internal/contextpack/compiler.go b/ely-eye-mcp/internal/contextpack/compiler.go new file mode 100644 index 0000000..b03f10b --- /dev/null +++ b/ely-eye-mcp/internal/contextpack/compiler.go @@ -0,0 +1,175 @@ +package contextpack + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "unicode/utf8" + + "github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/domain" +) + +var profileBudgets = map[string]int64{ + "live_demo": 128000, + "extreme_context": 1010000, + "library_100m": 262144, + "research_theater": 128000, +} + +func Build(question string, profile string, hits []domain.AtomHit, requestedBudget int64) domain.EvidencePack { + if profile == "" { + profile = "library_100m" + } + budget := requestedBudget + if budget <= 0 { + budget = profileBudgets[profile] + } + if budget <= 0 { + budget = profileBudgets["library_100m"] + } + var sections []string + var tokens int64 + for _, hit := range hits { + text := focusText(hit.Atom.Text, question, 1600) + atomTokens := tokenEquivalent(text) + if atomTokens == 0 { + atomTokens = 1 + } + if tokens+atomTokens > budget { + break + } + sections = append(sections, evidenceSection(hit, text)) + tokens += atomTokens + } + trace := traceID(question, profile, tokens, hits) + return domain.EvidencePack{ + Question: question, + Profile: profile, + TokenBudget: budget, + TokenEquivalent: tokens, + CacheTraceID: trace, + Hits: SummarizeHits(hits, 900), + PackedText: strings.Join(sections, "\n\n"), + VerifierContract: []string{ + "Every factual claim must cite atom ids.", + "Citations must include page, image region, frame, or code path when available.", + "Contradictions must be reported as contradiction_notes.", + }, + } +} + +func SummarizeHits(hits []domain.AtomHit, maxRunes int) []domain.AtomSearchHit { + summaries := make([]domain.AtomSearchHit, 0, len(hits)) + for _, hit := range hits { + summaries = append(summaries, domain.AtomSearchHit{ + Atom: summarizeAtom(hit.Atom, maxRunes), + SparseScore: hit.SparseScore, + FinalScore: hit.FinalScore, + }) + } + return summaries +} + +func summarizeAtom(atom domain.Atom, maxRunes int) domain.AtomSummary { + return domain.AtomSummary{ + AtomID: atom.AtomID, + SourceID: atom.SourceID, + Modality: atom.Modality, + Source: atom.Source, + Time: atom.Time, + TextPreview: trimRunes(atom.Text, maxRunes), + ImageRef: atom.ImageRef, + TokenEquivalent: atom.TokenEquivalent, + } +} + +func evidenceSection(hit domain.AtomHit, text string) string { + return fmt.Sprintf( + "[EVIDENCE]\natom_id=%s\nmodality=%s\nsource=%s\nimage_ref=%s\nscore=%.4f\n%s\n[/EVIDENCE]", + hit.Atom.AtomID, + hit.Atom.Modality, + hit.Atom.Source, + hit.Atom.ImageRef, + hit.FinalScore, + text, + ) +} + +func focusText(text string, query string, window int) string { + if utf8.RuneCountInString(text) <= window { + return text + } + lowerText := strings.ToLower(text) + best := 0 + for _, term := range strings.Fields(strings.ToLower(query)) { + if len(term) < 3 { + continue + } + if index := strings.Index(lowerText, term); index >= 0 { + best = index + break + } + } + start := best - window/2 + if start < 0 { + start = 0 + } + runes := []rune(text) + runeStart := byteToRuneOffset(text, start) + if runeStart+window > len(runes) { + runeStart = len(runes) - window + } + if runeStart < 0 { + runeStart = 0 + } + return string(runes[runeStart : runeStart+window]) +} + +func trimRunes(text string, limit int) string { + if limit <= 0 || utf8.RuneCountInString(text) <= limit { + return text + } + runes := []rune(text) + return string(runes[:limit]) +} + +func tokenEquivalent(text string) int64 { + var ascii int64 + var nonASCII int64 + for _, r := range text { + if r < 128 { + ascii++ + } else { + nonASCII++ + } + } + total := ascii/4 + nonASCII/2 + if total == 0 && text != "" { + return 1 + } + return total +} + +func traceID(question string, profile string, tokens int64, hits []domain.AtomHit) string { + hash := sha256.New() + fmt.Fprintf(hash, "%s\n%s\n%d\n", question, profile, tokens) + for _, hit := range hits { + fmt.Fprintln(hash, hit.Atom.AtomID) + } + return hex.EncodeToString(hash.Sum(nil))[:16] +} + +func byteToRuneOffset(text string, byteOffset int) int { + if byteOffset <= 0 { + return 0 + } + count := 0 + for index := range text { + if index >= byteOffset { + return count + } + count++ + } + return count +} diff --git a/ely-eye-mcp/internal/domain/types.go b/ely-eye-mcp/internal/domain/types.go new file mode 100644 index 0000000..67f1b79 --- /dev/null +++ b/ely-eye-mcp/internal/domain/types.go @@ -0,0 +1,148 @@ +package domain + +type CacheLayer struct { + Layer string `json:"layer"` + Entries int `json:"entries"` + BytesEstimate int64 `json:"bytes_estimate"` + HitCount int `json:"hit_count"` + MissCount int `json:"miss_count"` + LastEventAt string `json:"last_event_at,omitempty"` +} + +type Status struct { + App string `json:"app"` + ProjectRoot string `json:"project_root"` + DataHome string `json:"data_home"` + Database string `json:"database"` + AtomCount int `json:"atom_count"` + SourceCount int `json:"source_count"` + CartridgeCount int `json:"cartridge_count"` + ActiveTokenEquivalent int64 `json:"active_token_equivalent"` + LibraryTokenEquivalent int64 `json:"library_token_equivalent"` + CacheLayers []CacheLayer `json:"cache_layers"` +} + +type Cartridge struct { + CartridgeID string `json:"cartridge_id"` + Name string `json:"name"` + RootPath string `json:"root_path"` + DNA string `json:"dna,omitempty"` + TokenEquivalent int64 `json:"token_equivalent"` + CreatedAt string `json:"created_at"` + Manifest any `json:"manifest,omitempty"` +} + +type CartridgeDetail struct { + Cartridge + Artifacts map[string]string `json:"artifacts"` + MemoryCapsuleIndex any `json:"memory_capsule_index,omitempty"` + AssetReport any `json:"asset_report,omitempty"` +} + +type Atom struct { + AtomID string `json:"atom_id"` + SourceID string `json:"source_id"` + Modality string `json:"modality"` + Source string `json:"source"` + Time string `json:"time"` + Text string `json:"text"` + ImageRef string `json:"image_ref,omitempty"` + Layout any `json:"layout,omitempty"` + Relations any `json:"relations,omitempty"` + Trust any `json:"trust,omitempty"` + TokenEquivalent int64 `json:"token_equivalent"` + Metadata any `json:"metadata,omitempty"` +} + +type AtomHit struct { + Atom Atom `json:"atom"` + SparseScore float64 `json:"sparse_score"` + FinalScore float64 `json:"final_score"` +} + +type AtomSummary struct { + AtomID string `json:"atom_id"` + SourceID string `json:"source_id"` + Modality string `json:"modality"` + Source string `json:"source"` + Time string `json:"time"` + TextPreview string `json:"text_preview"` + ImageRef string `json:"image_ref,omitempty"` + TokenEquivalent int64 `json:"token_equivalent"` +} + +type AtomSearchHit struct { + Atom AtomSummary `json:"atom"` + SparseScore float64 `json:"sparse_score"` + FinalScore float64 `json:"final_score"` +} + +type EvidencePack struct { + Question string `json:"question"` + Profile string `json:"profile"` + TokenBudget int64 `json:"token_budget"` + TokenEquivalent int64 `json:"token_equivalent"` + CacheTraceID string `json:"cache_trace_id"` + Hits []AtomSearchHit `json:"hits"` + PackedText string `json:"packed_text"` + VerifierContract []string `json:"verifier_contract"` +} + +type ProofSuite struct { + ProofID string `json:"proof_id"` + CartridgeID string `json:"cartridge_id"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + Path string `json:"path"` + Payload any `json:"payload,omitempty"` +} + +type HashHopProof struct { + ProofID string `json:"proof_id"` + Kind string `json:"kind"` + Hops int `json:"hops"` + TokenEquivalent int64 `json:"token_equivalent"` + QueryID string `json:"query_id"` + ExpectedTargetID string `json:"expected_target_id"` + ModelTargetID string `json:"model_target_id,omitempty"` + Passed *bool `json:"passed"` + Artifacts []string `json:"artifacts,omitempty"` + CreatedAt string `json:"created_at"` + Path string `json:"path"` +} + +type HashHopBucket struct { + Kind string `json:"kind"` + Hops int `json:"hops"` + TokenEquivalent int64 `json:"token_equivalent"` + Total int `json:"total"` + Passed int `json:"passed"` +} + +type HashHopReport struct { + Total int `json:"total"` + Passed int `json:"passed"` + Buckets []HashHopBucket `json:"buckets"` + Proofs []HashHopProof `json:"proofs,omitempty"` +} + +type ProofCheck struct { + Name string `json:"name"` + Requirement string `json:"requirement"` + Status string `json:"status"` + Evidence []string `json:"evidence,omitempty"` + Detail string `json:"detail"` +} + +type ProofSuiteDetail struct { + ProofID string `json:"proof_id"` + CartridgeID string `json:"cartridge_id"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + Checks []ProofCheck `json:"checks"` + Artifacts map[string]string `json:"artifacts"` + DNABefore string `json:"dna_before,omitempty"` + DNAAfter string `json:"dna_after,omitempty"` + Path string `json:"path"` + HashHop HashHopReport `json:"hashhop"` +} diff --git a/ely-eye-mcp/internal/mcpserver/server.go b/ely-eye-mcp/internal/mcpserver/server.go new file mode 100644 index 0000000..52743c9 --- /dev/null +++ b/ely-eye-mcp/internal/mcpserver/server.go @@ -0,0 +1,83 @@ +package mcpserver + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + + "github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/config" + "github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/store" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type Server struct { + cfg config.Config + store *store.Store + version string +} + +func Run(ctx context.Context, cfg config.Config, version string) error { + db, err := store.Open(ctx, cfg) + if err != nil { + return err + } + defer db.Close() + server := &Server{cfg: cfg, store: db, version: version} + mcpServer := mcp.NewServer(&mcp.Implementation{ + Name: "ely-eye", + Title: "Ely-Eye Context Cartridge MCP", + Version: version, + }, &mcp.ServerOptions{ + Instructions: "Use Ely-Eye tools to retrieve local cartridge context, evidence atoms, proof suites, and compiled evidence packs. Tools read local Ely-Eye state and do not mutate the production database.", + }) + server.registerTools(mcpServer) + server.registerResources(mcpServer) + if err := mcpServer.Run(ctx, &mcp.StdioTransport{}); err != nil { + if errors.Is(err, io.EOF) || strings.HasSuffix(err.Error(), "EOF") { + return nil + } + return err + } + return nil +} + +func (s *Server) registerResources(server *mcp.Server) { + addFileResource(server, "ely-eye://project/prd", "Ely-Eye PRD", filepath.Join(s.cfg.ProjectRoot, "PRD.md")) + addFileResource(server, "ely-eye://project/readme", "Ely-Eye README", filepath.Join(s.cfg.ProjectRoot, "README.md")) + addFileResource(server, "ely-eye://project/mcp-research", "Ely-Eye MCP research notes", filepath.Join(s.cfg.ProjectRoot, "ely-eye-mcp", "docs", "RESEARCH.md")) +} + +// addFileResource registers a project document only when it exists on disk. +// External deployments rarely ship the Ely-Eye repository documents, so a +// missing file is a normal condition and the resource is simply omitted. +func addFileResource(server *mcp.Server, uri string, name string, path string) { + info, err := os.Stat(path) + if err != nil { + return + } + size := info.Size() + server.AddResource(&mcp.Resource{ + URI: uri, + Name: name, + Title: name, + Description: "Local Ely-Eye project resource: " + path, + MIMEType: "text/markdown", + Size: size, + }, func(ctx context.Context, request *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + _ = ctx + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{ + URI: request.Params.URI, + MIMEType: "text/markdown", + Text: string(data), + }}, + }, nil + }) +} diff --git a/ely-eye-mcp/internal/mcpserver/tools.go b/ely-eye-mcp/internal/mcpserver/tools.go new file mode 100644 index 0000000..067e0b4 --- /dev/null +++ b/ely-eye-mcp/internal/mcpserver/tools.go @@ -0,0 +1,212 @@ +package mcpserver + +import ( + "context" + "errors" + + "github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/contextpack" + "github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/domain" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type EmptyInput struct{} + +type ListCartridgesInput struct { + Limit int `json:"limit,omitempty" jsonschema:"maximum number of cartridges to return, capped at 200"` +} + +type GetCartridgeInput struct { + CartridgeID string `json:"cartridge_id,omitempty" jsonschema:"exact Ely-Eye cartridge id"` + Name string `json:"name,omitempty" jsonschema:"exact Ely-Eye cartridge name"` +} + +type SearchAtomsInput struct { + Query string `json:"query" jsonschema:"search query for the Ely-Eye FTS atom index"` + Modality string `json:"modality,omitempty" jsonschema:"optional exact modality filter such as text, code, image, pdf_page, pdf_region, video_frame, ui_screenshot"` + Limit int `json:"limit,omitempty" jsonschema:"maximum number of atoms to return, capped at 100"` +} + +type FetchAtomInput struct { + AtomID string `json:"atom_id" jsonschema:"exact Evidence Atom id"` +} + +type CompileContextInput struct { + Question string `json:"question" jsonschema:"question to compile into an Ely-Eye evidence pack"` + Profile string `json:"profile,omitempty" jsonschema:"live_demo, extreme_context, library_100m, or research_theater"` + Limit int `json:"limit,omitempty" jsonschema:"maximum retrieval hits to pack"` + TokenBudget int64 `json:"token_budget,omitempty" jsonschema:"explicit token budget for the evidence pack"` +} + +type ListProofSuitesInput struct { + Limit int `json:"limit,omitempty" jsonschema:"maximum proof suites to return, capped at 100"` + IncludePayload bool `json:"include_payload,omitempty" jsonschema:"include full proof_suite.json payloads"` +} + +type ListHashHopProofsInput struct { + Kind string `json:"kind,omitempty" jsonschema:"optional filter: text_hashhop or visual_hashhop"` + Limit int `json:"limit,omitempty" jsonschema:"maximum example proofs to include, capped at 200"` +} + +type GetProofSuiteInput struct { + ProofID string `json:"proof_id" jsonschema:"exact proof suite id, for example prd_proof_suite_6bc3fa744c60a0e6"` +} + +type StatusOutput struct { + Status domain.Status `json:"status"` +} + +type ListCartridgesOutput struct { + Cartridges []domain.Cartridge `json:"cartridges"` +} + +type GetCartridgeOutput struct { + Cartridge domain.CartridgeDetail `json:"cartridge"` +} + +type SearchAtomsOutput struct { + Hits []domain.AtomSearchHit `json:"hits"` +} + +type FetchAtomOutput struct { + Atom domain.Atom `json:"atom"` +} + +type CompileContextOutput struct { + Context domain.EvidencePack `json:"context"` +} + +type ListProofSuitesOutput struct { + ProofSuites []domain.ProofSuite `json:"proof_suites"` +} + +type ListHashHopProofsOutput struct { + Report domain.HashHopReport `json:"report"` +} + +type GetProofSuiteOutput struct { + ProofSuite domain.ProofSuiteDetail `json:"proof_suite"` +} + +func (s *Server) registerTools(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "ely_eye_status", + Title: "Ely-Eye status", + Description: "Return read-only counts and cache layer status from the local Ely-Eye database.", + }, s.statusTool) + mcp.AddTool(server, &mcp.Tool{ + Name: "list_cartridges", + Title: "List Context Cartridges", + Description: "List local Ely-Eye Context Cartridges with DNA, token-equivalent capacity, and manifest JSON.", + }, s.listCartridgesTool) + mcp.AddTool(server, &mcp.Tool{ + Name: "get_cartridge", + Title: "Get Context Cartridge", + Description: "Read one local Ely-Eye Context Cartridge, including artifacts, memory capsule index, and asset report when present.", + }, s.getCartridgeTool) + mcp.AddTool(server, &mcp.Tool{ + Name: "search_atoms", + Title: "Search Evidence Atoms", + Description: "Search the local Ely-Eye FTS index and return ranked Evidence Atoms.", + }, s.searchAtomsTool) + mcp.AddTool(server, &mcp.Tool{ + Name: "fetch_atom", + Title: "Fetch Evidence Atom", + Description: "Read one Evidence Atom by exact atom id.", + }, s.fetchAtomTool) + mcp.AddTool(server, &mcp.Tool{ + Name: "compile_context", + Title: "Compile Evidence Context", + Description: "Compile a question into an Ely-Eye evidence pack using local retrieval and the same profile names as the app.", + }, s.compileContextTool) + mcp.AddTool(server, &mcp.Tool{ + Name: "list_proof_suites", + Title: "List Proof Suites", + Description: "List local PRD proof suites from .ely_eye/data/eval_proofs.", + }, s.listProofSuitesTool) + mcp.AddTool(server, &mcp.Tool{ + Name: "list_hashhop_proofs", + Title: "List HashHop Proofs", + Description: "Aggregate local HashHop and Visual HashHop long-context addressing proofs by kind, hop count, and token budget, with pass counts and example proofs.", + }, s.listHashHopProofsTool) + mcp.AddTool(server, &mcp.Tool{ + Name: "get_proof_suite", + Title: "Get Proof Suite", + Description: "Read one local PRD proof suite by id, including all checks and a summary of its HashHop proofs.", + }, s.getProofSuiteTool) +} + +func (s *Server) statusTool(ctx context.Context, _ *mcp.CallToolRequest, _ EmptyInput) (*mcp.CallToolResult, StatusOutput, error) { + status, err := s.store.Status(ctx) + if err != nil { + return nil, StatusOutput{}, err + } + return nil, StatusOutput{Status: status}, nil +} + +func (s *Server) listCartridgesTool(ctx context.Context, _ *mcp.CallToolRequest, input ListCartridgesInput) (*mcp.CallToolResult, ListCartridgesOutput, error) { + cartridges, err := s.store.ListCartridges(ctx, input.Limit) + if err != nil { + return nil, ListCartridgesOutput{}, err + } + return nil, ListCartridgesOutput{Cartridges: cartridges}, nil +} + +func (s *Server) getCartridgeTool(ctx context.Context, _ *mcp.CallToolRequest, input GetCartridgeInput) (*mcp.CallToolResult, GetCartridgeOutput, error) { + cartridge, err := s.store.GetCartridge(ctx, input.CartridgeID, input.Name) + if err != nil { + return nil, GetCartridgeOutput{}, err + } + return nil, GetCartridgeOutput{Cartridge: cartridge}, nil +} + +func (s *Server) searchAtomsTool(ctx context.Context, _ *mcp.CallToolRequest, input SearchAtomsInput) (*mcp.CallToolResult, SearchAtomsOutput, error) { + hits, err := s.store.SearchAtoms(ctx, input.Query, input.Modality, input.Limit) + if err != nil { + return nil, SearchAtomsOutput{}, err + } + return nil, SearchAtomsOutput{Hits: contextpack.SummarizeHits(hits, 1200)}, nil +} + +func (s *Server) fetchAtomTool(ctx context.Context, _ *mcp.CallToolRequest, input FetchAtomInput) (*mcp.CallToolResult, FetchAtomOutput, error) { + atom, err := s.store.FetchAtom(ctx, input.AtomID) + if err != nil { + return nil, FetchAtomOutput{}, err + } + return nil, FetchAtomOutput{Atom: atom}, nil +} + +func (s *Server) compileContextTool(ctx context.Context, _ *mcp.CallToolRequest, input CompileContextInput) (*mcp.CallToolResult, CompileContextOutput, error) { + if input.Question == "" { + return nil, CompileContextOutput{}, errors.New("question is required") + } + hits, err := s.store.SearchAtoms(ctx, input.Question, "", input.Limit) + if err != nil { + return nil, CompileContextOutput{}, err + } + pack := contextpack.Build(input.Question, input.Profile, hits, input.TokenBudget) + return nil, CompileContextOutput{Context: pack}, nil +} + +func (s *Server) listProofSuitesTool(ctx context.Context, _ *mcp.CallToolRequest, input ListProofSuitesInput) (*mcp.CallToolResult, ListProofSuitesOutput, error) { + proofs, err := s.store.ListProofSuites(ctx, input.Limit, input.IncludePayload) + if err != nil { + return nil, ListProofSuitesOutput{}, err + } + return nil, ListProofSuitesOutput{ProofSuites: proofs}, nil +} + +func (s *Server) listHashHopProofsTool(ctx context.Context, _ *mcp.CallToolRequest, input ListHashHopProofsInput) (*mcp.CallToolResult, ListHashHopProofsOutput, error) { + report, err := s.store.ListHashHopProofs(ctx, input.Kind, input.Limit) + if err != nil { + return nil, ListHashHopProofsOutput{}, err + } + return nil, ListHashHopProofsOutput{Report: report}, nil +} + +func (s *Server) getProofSuiteTool(ctx context.Context, _ *mcp.CallToolRequest, input GetProofSuiteInput) (*mcp.CallToolResult, GetProofSuiteOutput, error) { + suite, err := s.store.GetProofSuite(ctx, input.ProofID) + if err != nil { + return nil, GetProofSuiteOutput{}, err + } + return nil, GetProofSuiteOutput{ProofSuite: suite}, nil +} diff --git a/ely-eye-mcp/internal/setup/setup.go b/ely-eye-mcp/internal/setup/setup.go new file mode 100644 index 0000000..fa06ba0 --- /dev/null +++ b/ely-eye-mcp/internal/setup/setup.go @@ -0,0 +1,220 @@ +package setup + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/config" +) + +type Options struct { + Client string + Scope string + ServerName string +} + +type Action struct { + Client string `json:"client"` + Scope string `json:"scope"` + Target string `json:"target"` + Detail string `json:"detail"` + Command string `json:"command,omitempty"` +} + +type Result struct { + ServerName string `json:"server_name"` + Binary string `json:"binary"` + ProjectRoot string `json:"project_root"` + Actions []Action `json:"actions"` +} + +func Run(ctx context.Context, cfg config.Config, options Options) (Result, error) { + client := normalizeClient(options.Client) + if client != "codex" && client != "claude" && client != "both" { + return Result{}, errors.New("client must be codex, claude, or both") + } + scope := normalizeScope(options.Scope) + if scope != "project" && scope != "user" { + return Result{}, errors.New("claude scope must be project or user") + } + serverName := options.ServerName + if serverName == "" { + serverName = "ely-eye" + } + if cfg.Binary == "" { + return Result{}, errors.New("binary path is required for setup") + } + result := Result{ServerName: serverName, Binary: cfg.Binary, ProjectRoot: cfg.ProjectRoot} + var actions []Action + if client == "codex" || client == "both" { + action, err := setupCodex(ctx, cfg, serverName) + if err != nil { + return Result{}, err + } + actions = append(actions, action) + } + if client == "claude" || client == "both" { + action, err := setupClaude(ctx, cfg, serverName, scope) + if err != nil { + return Result{}, err + } + actions = append(actions, action) + } + result.Actions = actions + return result, nil +} + +func normalizeClient(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "all", "both": + return "both" + case "codex", "claude": + return strings.ToLower(value) + default: + return value + } +} + +func normalizeScope(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "project": + return "project" + case "user": + return "user" + default: + return value + } +} + +func serverCommand(cfg config.Config) []string { + return []string{ + cfg.Binary, + "server", + "--project-root", + cfg.ProjectRoot, + "--ely-eye-home", + cfg.ElyEyeHome, + } +} + +func serverEnv(cfg config.Config) map[string]string { + return map[string]string{ + "ELY_EYE_HOME": cfg.ElyEyeHome, + } +} + +func commandLine(parts []string) string { + quoted := make([]string, 0, len(parts)) + for _, part := range parts { + if strings.ContainsAny(part, " \t\"'") { + quoted = append(quoted, `"`+strings.ReplaceAll(part, `"`, `\"`)+`"`) + } else { + quoted = append(quoted, part) + } + } + return strings.Join(quoted, " ") +} + +func setupCodex(ctx context.Context, cfg config.Config, serverName string) (Action, error) { + codex, err := exec.LookPath("codex") + if err != nil { + return Action{}, errors.New("codex CLI is required to configure Codex MCP") + } + remove := exec.CommandContext(ctx, codex, "mcp", "remove", serverName) + _ = remove.Run() + args := []string{"mcp", "add", serverName, "--env", "ELY_EYE_HOME=" + cfg.ElyEyeHome, "--"} + args = append(args, serverCommand(cfg)...) + add := exec.CommandContext(ctx, codex, args...) + output, err := add.CombinedOutput() + if err != nil { + return Action{}, errors.New("codex mcp add failed: " + strings.TrimSpace(string(output))) + } + return Action{ + Client: "codex", + Scope: "user", + Target: "~/.codex/config.toml", + Detail: "registered stdio MCP server through Codex CLI", + Command: commandLine(append([]string{"codex"}, args...)), + }, nil +} + +func setupClaude(ctx context.Context, cfg config.Config, serverName string, scope string) (Action, error) { + if scope == "user" { + return setupClaudeUser(ctx, cfg, serverName) + } + if scope != "project" { + return Action{}, errors.New("claude scope must be project or user") + } + target := filepath.Join(cfg.ProjectRoot, ".mcp.json") + if err := writeProjectMCP(target, serverName, cfg); err != nil { + return Action{}, err + } + return Action{ + Client: "claude", + Scope: "project", + Target: target, + Detail: "wrote project-scoped .mcp.json", + }, nil +} + +func setupClaudeUser(ctx context.Context, cfg config.Config, serverName string) (Action, error) { + claude, err := exec.LookPath("claude") + if err != nil { + return Action{}, errors.New("claude CLI is required for user-scoped Claude Code MCP setup") + } + payload, err := json.Marshal(mcpServerConfig(cfg)) + if err != nil { + return Action{}, err + } + args := []string{"mcp", "add-json", serverName, string(payload), "--scope", "user"} + cmd := exec.CommandContext(ctx, claude, args...) + output, err := cmd.CombinedOutput() + if err != nil { + return Action{}, errors.New("claude mcp add-json failed: " + strings.TrimSpace(string(output))) + } + return Action{ + Client: "claude", + Scope: "user", + Target: "~/.claude.json", + Detail: "registered stdio MCP server through Claude CLI", + Command: commandLine(append([]string{"claude"}, args...)), + }, nil +} + +func writeProjectMCP(path string, serverName string, cfg config.Config) error { + payload := map[string]any{} + data, err := os.ReadFile(path) + if err == nil { + if err := json.Unmarshal(data, &payload); err != nil { + return err + } + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + servers, ok := payload["mcpServers"].(map[string]any) + if !ok { + servers = map[string]any{} + } + servers[serverName] = mcpServerConfig(cfg) + payload["mcpServers"] = servers + data, err = json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0644) +} + +func mcpServerConfig(cfg config.Config) map[string]any { + command := serverCommand(cfg) + return map[string]any{ + "type": "stdio", + "command": command[0], + "args": command[1:], + "env": serverEnv(cfg), + } +} diff --git a/ely-eye-mcp/internal/store/atoms.go b/ely-eye-mcp/internal/store/atoms.go new file mode 100644 index 0000000..579bde1 --- /dev/null +++ b/ely-eye-mcp/internal/store/atoms.go @@ -0,0 +1,200 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "math" + "regexp" + "strings" + + "github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/domain" +) + +var tokenPattern = regexp.MustCompile(`[\p{L}\p{N}_]+`) + +func (s *Store) FetchAtom(ctx context.Context, atomID string) (domain.Atom, error) { + if strings.TrimSpace(atomID) == "" { + return domain.Atom{}, errors.New("atom_id is required") + } + row := s.db.QueryRowContext(ctx, ` + SELECT atom_id, source_id, modality, source, time, text, COALESCE(image_ref, ''), + COALESCE(layout_json, ''), relations_json, trust_json, token_equivalent, metadata_json + FROM atoms + WHERE atom_id = ?`, atomID) + atom, err := scanAtom(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.Atom{}, errors.New("atom not found") + } + return domain.Atom{}, err + } + return atom, nil +} + +func (s *Store) SearchAtoms(ctx context.Context, query string, modality string, limit int) ([]domain.AtomHit, error) { + matchQuery, err := ftsQuery(query) + if err != nil { + return nil, err + } + if limit <= 0 || limit > 100 { + limit = 24 + } + args := []any{matchQuery} + filter := "" + if modality != "" { + filter = "AND a.modality = ?" + args = append(args, modality) + } + args = append(args, limit) + rows, err := s.db.QueryContext(ctx, ` + SELECT a.atom_id, a.source_id, a.modality, a.source, a.time, a.text, + COALESCE(a.image_ref, ''), COALESCE(a.layout_json, ''), + a.relations_json, a.trust_json, a.token_equivalent, a.metadata_json, + bm25(atom_fts) AS score + FROM atom_fts + JOIN atoms a ON a.atom_id = atom_fts.atom_id + WHERE atom_fts MATCH ? `+filter+` + ORDER BY score + LIMIT ?`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var hits []domain.AtomHit + var bestScore float64 + for rows.Next() { + atom, score, err := scanAtomHit(rows) + if err != nil { + return nil, err + } + sparseScore := -score + if math.IsNaN(sparseScore) || math.IsInf(sparseScore, 0) { + sparseScore = 0 + } + if sparseScore > bestScore { + bestScore = sparseScore + } + hits = append(hits, domain.AtomHit{Atom: atom, SparseScore: sparseScore, FinalScore: sparseScore}) + } + if err := rows.Err(); err != nil { + return nil, err + } + if bestScore > 0 { + for index := range hits { + hits[index].SparseScore = hits[index].SparseScore / bestScore + hits[index].FinalScore = hits[index].SparseScore + } + } + return hits, nil +} + +type atomScanner interface { + Scan(dest ...any) error +} + +func scanAtom(scanner atomScanner) (domain.Atom, error) { + var atom domain.Atom + var layout string + var relations string + var trust string + var metadata string + if err := scanner.Scan( + &atom.AtomID, + &atom.SourceID, + &atom.Modality, + &atom.Source, + &atom.Time, + &atom.Text, + &atom.ImageRef, + &layout, + &relations, + &trust, + &atom.TokenEquivalent, + &metadata, + ); err != nil { + return domain.Atom{}, err + } + var err error + atom.Layout, err = optionalJSONValue(layout) + if err != nil { + return domain.Atom{}, err + } + atom.Relations, err = requiredJSONValue(relations) + if err != nil { + return domain.Atom{}, err + } + atom.Trust, err = requiredJSONValue(trust) + if err != nil { + return domain.Atom{}, err + } + atom.Metadata, err = requiredJSONValue(metadata) + if err != nil { + return domain.Atom{}, err + } + return atom, nil +} + +func scanAtomHit(scanner atomScanner) (domain.Atom, float64, error) { + var atom domain.Atom + var layout string + var relations string + var trust string + var metadata string + var score float64 + if err := scanner.Scan( + &atom.AtomID, + &atom.SourceID, + &atom.Modality, + &atom.Source, + &atom.Time, + &atom.Text, + &atom.ImageRef, + &layout, + &relations, + &trust, + &atom.TokenEquivalent, + &metadata, + &score, + ); err != nil { + return domain.Atom{}, 0, err + } + var err error + atom.Layout, err = optionalJSONValue(layout) + if err != nil { + return domain.Atom{}, 0, err + } + atom.Relations, err = requiredJSONValue(relations) + if err != nil { + return domain.Atom{}, 0, err + } + atom.Trust, err = requiredJSONValue(trust) + if err != nil { + return domain.Atom{}, 0, err + } + atom.Metadata, err = requiredJSONValue(metadata) + if err != nil { + return domain.Atom{}, 0, err + } + return atom, score, nil +} + +func ftsQuery(query string) (string, error) { + tokens := tokenPattern.FindAllString(strings.ToLower(query), -1) + if len(tokens) == 0 { + return "", errors.New("query must contain searchable text") + } + unique := make([]string, 0, len(tokens)) + seen := map[string]bool{} + for _, token := range tokens { + if seen[token] { + continue + } + seen[token] = true + unique = append(unique, `"`+strings.ReplaceAll(token, `"`, `""`)+`"`) + if len(unique) == 16 { + break + } + } + return strings.Join(unique, " OR "), nil +} diff --git a/ely-eye-mcp/internal/store/cartridge.go b/ely-eye-mcp/internal/store/cartridge.go new file mode 100644 index 0000000..3c491ef --- /dev/null +++ b/ely-eye-mcp/internal/store/cartridge.go @@ -0,0 +1,135 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "os" + "path/filepath" + + "github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/domain" +) + +func (s *Store) ListCartridges(ctx context.Context, limit int) ([]domain.Cartridge, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + rows, err := s.db.QueryContext(ctx, ` + SELECT cartridge_id, name, root_path, manifest_json, COALESCE(dna, ''), + token_equivalent, created_at + FROM cartridges + ORDER BY created_at DESC + LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + cartridges := []domain.Cartridge{} + for rows.Next() { + cartridge, err := scanCartridge(rows) + if err != nil { + return nil, err + } + cartridges = append(cartridges, cartridge) + } + return cartridges, rows.Err() +} + +func (s *Store) GetCartridge(ctx context.Context, cartridgeID string, name string) (domain.CartridgeDetail, error) { + if cartridgeID == "" && name == "" { + return domain.CartridgeDetail{}, errors.New("cartridge_id or name is required") + } + query := ` + SELECT cartridge_id, name, root_path, manifest_json, COALESCE(dna, ''), + token_equivalent, created_at + FROM cartridges + WHERE cartridge_id = ? OR name = ? + ORDER BY created_at DESC + LIMIT 1` + row := s.db.QueryRowContext(ctx, query, cartridgeID, name) + cartridge, err := scanCartridge(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.CartridgeDetail{}, errors.New("cartridge not found") + } + return domain.CartridgeDetail{}, err + } + artifacts, err := artifactsFromManifest(cartridge.Manifest) + if err != nil { + return domain.CartridgeDetail{}, err + } + detail := domain.CartridgeDetail{ + Cartridge: cartridge, + Artifacts: artifacts, + } + memoryIndex, err := readOptionalJSON(filepath.Join(cartridge.RootPath, "memory_capsule_index.json")) + if err != nil { + return domain.CartridgeDetail{}, err + } + detail.MemoryCapsuleIndex = memoryIndex + assetReport, err := readOptionalJSON(filepath.Join(cartridge.RootPath, "cartridge_assets.json")) + if err != nil { + return domain.CartridgeDetail{}, err + } + detail.AssetReport = assetReport + return detail, nil +} + +type cartridgeScanner interface { + Scan(dest ...any) error +} + +func scanCartridge(scanner cartridgeScanner) (domain.Cartridge, error) { + var cartridge domain.Cartridge + var manifest string + if err := scanner.Scan( + &cartridge.CartridgeID, + &cartridge.Name, + &cartridge.RootPath, + &manifest, + &cartridge.DNA, + &cartridge.TokenEquivalent, + &cartridge.CreatedAt, + ); err != nil { + return domain.Cartridge{}, err + } + value, err := requiredJSONValue(manifest) + if err != nil { + return domain.Cartridge{}, err + } + cartridge.Manifest = value + return cartridge, nil +} + +func artifactsFromManifest(manifest any) (map[string]string, error) { + var payload struct { + Artifacts map[string]string `json:"artifacts"` + } + data, err := json.Marshal(manifest) + if err != nil { + return nil, err + } + if err := json.Unmarshal(data, &payload); err != nil { + return nil, err + } + if payload.Artifacts == nil { + payload.Artifacts = map[string]string{} + } + return payload.Artifacts, nil +} + +func readOptionalJSON(path string) (any, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + value, err := readJSONValue(data) + if err != nil { + return nil, errors.New(err.Error() + " at " + path) + } + return value, nil +} diff --git a/ely-eye-mcp/internal/store/json_value.go b/ely-eye-mcp/internal/store/json_value.go new file mode 100644 index 0000000..51029e8 --- /dev/null +++ b/ely-eye-mcp/internal/store/json_value.go @@ -0,0 +1,35 @@ +package store + +import ( + "encoding/json" + "errors" +) + +func optionalJSONValue(value string) (any, error) { + if value == "" || value == "null" { + return nil, nil + } + return decodeJSONValue(value) +} + +func requiredJSONValue(value string) (any, error) { + if value == "" { + return map[string]any{}, nil + } + return decodeJSONValue(value) +} + +func readJSONValue(data []byte) (any, error) { + if !json.Valid(data) { + return nil, errors.New("invalid JSON") + } + var value any + if err := json.Unmarshal(data, &value); err != nil { + return nil, err + } + return value, nil +} + +func decodeJSONValue(value string) (any, error) { + return readJSONValue([]byte(value)) +} diff --git a/ely-eye-mcp/internal/store/proof_evidence.go b/ely-eye-mcp/internal/store/proof_evidence.go new file mode 100644 index 0000000..0703b7a --- /dev/null +++ b/ely-eye-mcp/internal/store/proof_evidence.go @@ -0,0 +1,167 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/domain" +) + +func (s *Store) evalProofsRoot() string { + return filepath.Join(s.cfg.ElyEyeHome, "data", "eval_proofs") +} + +// ListHashHopProofs aggregates every HashHop and Visual HashHop proof under the +// eval set into pass-rate buckets keyed by kind, hop count, and token budget, +// plus a capped list of recent example proofs. +func (s *Store) ListHashHopProofs(ctx context.Context, kind string, limit int) (domain.HashHopReport, error) { + if kind != "" && kind != "text_hashhop" && kind != "visual_hashhop" { + return domain.HashHopReport{}, errors.New("kind must be text_hashhop or visual_hashhop") + } + if limit <= 0 || limit > 200 { + limit = 25 + } + return aggregateHashHop(ctx, s.evalProofsRoot(), kind, limit) +} + +// GetProofSuite reads one PRD proof suite by id, including all checks, and +// summarizes the HashHop proofs stored alongside it. +func (s *Store) GetProofSuite(ctx context.Context, proofID string) (domain.ProofSuiteDetail, error) { + if err := validateProofID(proofID); err != nil { + return domain.ProofSuiteDetail{}, err + } + dir := filepath.Join(s.evalProofsRoot(), proofID) + path := filepath.Join(dir, "proof_suite.json") + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return domain.ProofSuiteDetail{}, errors.New("proof suite not found") + } + return domain.ProofSuiteDetail{}, err + } + var detail domain.ProofSuiteDetail + if err := json.Unmarshal(data, &detail); err != nil { + return domain.ProofSuiteDetail{}, err + } + detail.Path = path + report, err := aggregateHashHop(ctx, filepath.Join(dir, "hashhop"), "", 0) + if err != nil { + return domain.ProofSuiteDetail{}, err + } + detail.HashHop = report + return detail, nil +} + +// aggregateHashHop walks a directory tree for proof.json files and builds a +// HashHopReport. proofCap <= 0 returns buckets only; a positive cap also +// includes that many most-recent example proofs. +func aggregateHashHop(ctx context.Context, root string, kind string, proofCap int) (domain.HashHopReport, error) { + buckets := map[string]*domain.HashHopBucket{} + seen := map[string]bool{} + var proofs []domain.HashHopProof + report := domain.HashHopReport{} + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if err := ctx.Err(); err != nil { + return err + } + if entry.IsDir() || entry.Name() != "proof.json" { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + var proof domain.HashHopProof + if err := json.Unmarshal(data, &proof); err != nil { + return err + } + if kind != "" && proof.Kind != kind { + return nil + } + // A suite stores each HashHop proof under its own hashhop/ directory and + // also promotes a byte-identical copy to a top-level proof directory. + // Count each proof once by its content-derived id. + if seen[proof.ProofID] { + return nil + } + seen[proof.ProofID] = true + proof.Path = path + report.Total++ + passed := proof.Passed != nil && *proof.Passed + if passed { + report.Passed++ + } + key := proof.Kind + "|" + strconv.Itoa(proof.Hops) + "|" + strconv.FormatInt(proof.TokenEquivalent, 10) + bucket, ok := buckets[key] + if !ok { + bucket = &domain.HashHopBucket{Kind: proof.Kind, Hops: proof.Hops, TokenEquivalent: proof.TokenEquivalent} + buckets[key] = bucket + } + bucket.Total++ + if passed { + bucket.Passed++ + } + if proofCap > 0 { + proofs = append(proofs, proof) + } + return nil + }) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return domain.HashHopReport{Buckets: []domain.HashHopBucket{}}, nil + } + return domain.HashHopReport{}, err + } + report.Buckets = sortedBuckets(buckets) + if proofCap > 0 { + report.Proofs = capRecentProofs(proofs, proofCap) + } + return report, nil +} + +func sortedBuckets(buckets map[string]*domain.HashHopBucket) []domain.HashHopBucket { + out := make([]domain.HashHopBucket, 0, len(buckets)) + for _, bucket := range buckets { + out = append(out, *bucket) + } + sort.Slice(out, func(i int, j int) bool { + if out[i].Kind != out[j].Kind { + return out[i].Kind < out[j].Kind + } + if out[i].TokenEquivalent != out[j].TokenEquivalent { + return out[i].TokenEquivalent < out[j].TokenEquivalent + } + return out[i].Hops < out[j].Hops + }) + return out +} + +func capRecentProofs(proofs []domain.HashHopProof, limit int) []domain.HashHopProof { + sort.Slice(proofs, func(i int, j int) bool { + return proofs[i].CreatedAt > proofs[j].CreatedAt + }) + if len(proofs) > limit { + proofs = proofs[:limit] + } + return proofs +} + +func validateProofID(id string) error { + if id == "" { + return errors.New("proof_id is required") + } + if id == "." || id == ".." || strings.ContainsAny(id, `/\`) || filepath.VolumeName(id) != "" || filepath.Base(id) != id { + return errors.New("proof_id must be a single path element") + } + return nil +} diff --git a/ely-eye-mcp/internal/store/proofs.go b/ely-eye-mcp/internal/store/proofs.go new file mode 100644 index 0000000..c869b96 --- /dev/null +++ b/ely-eye-mcp/internal/store/proofs.go @@ -0,0 +1,104 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "sort" + "time" + + "github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/domain" +) + +type proofDir struct { + name string + modTime time.Time +} + +func (s *Store) ListProofSuites(ctx context.Context, limit int, includePayload bool) ([]domain.ProofSuite, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if limit <= 0 || limit > 100 { + limit = 20 + } + root := filepath.Join(s.cfg.ElyEyeHome, "data", "eval_proofs") + entries, err := os.ReadDir(root) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return []domain.ProofSuite{}, nil + } + return nil, err + } + dirs := sortedProofDirs(entries) + proofs := make([]domain.ProofSuite, 0, limit) + for _, dir := range dirs { + if err := ctx.Err(); err != nil { + return nil, err + } + path := filepath.Join(root, dir.name, "proof_suite.json") + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return nil, err + } + var header struct { + ProofID string `json:"proof_id"` + CartridgeID string `json:"cartridge_id"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + } + if err := json.Unmarshal(data, &header); err != nil { + return nil, err + } + proof := domain.ProofSuite{ + ProofID: header.ProofID, + CartridgeID: header.CartridgeID, + Status: header.Status, + CreatedAt: header.CreatedAt, + Path: path, + } + if includePayload { + value, err := readJSONValue(data) + if err != nil { + return nil, err + } + proof.Payload = value + } + proofs = append(proofs, proof) + if len(proofs) == limit { + break + } + } + return proofs, nil +} + +// sortedProofDirs resolves each directory's mod time once and orders newest +// first. The store mutates underneath the read-only server, so a directory may +// be removed between ReadDir and Info; an Info error yields a zero time that +// sorts last rather than dereferencing a nil FileInfo. Names break ties for a +// stable order. +func sortedProofDirs(entries []os.DirEntry) []proofDir { + dirs := make([]proofDir, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + modTime := time.Time{} + if info, err := entry.Info(); err == nil { + modTime = info.ModTime() + } + dirs = append(dirs, proofDir{name: entry.Name(), modTime: modTime}) + } + sort.Slice(dirs, func(i int, j int) bool { + if !dirs[i].modTime.Equal(dirs[j].modTime) { + return dirs[i].modTime.After(dirs[j].modTime) + } + return dirs[i].name < dirs[j].name + }) + return dirs +} diff --git a/ely-eye-mcp/internal/store/sqlite.go b/ely-eye-mcp/internal/store/sqlite.go new file mode 100644 index 0000000..bd685fc --- /dev/null +++ b/ely-eye-mcp/internal/store/sqlite.go @@ -0,0 +1,136 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "net/url" + "path/filepath" + + "github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/config" + "github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/domain" + _ "modernc.org/sqlite" +) + +type Store struct { + cfg config.Config + db *sql.DB +} + +func Open(ctx context.Context, cfg config.Config) (*Store, error) { + dsn := sqliteReadOnlyDSN(cfg.Database) + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + if err := db.PingContext(ctx); err != nil { + db.Close() + return nil, err + } + return &Store{cfg: cfg, db: db}, nil +} + +func (s *Store) Close() error { + return s.db.Close() +} + +func (s *Store) Status(ctx context.Context) (domain.Status, error) { + atomCount, err := s.scalarInt(ctx, "SELECT COUNT(*) FROM atoms") + if err != nil { + return domain.Status{}, err + } + sourceCount, err := s.scalarInt(ctx, "SELECT COUNT(*) FROM sources") + if err != nil { + return domain.Status{}, err + } + cartridgeCount, err := s.scalarInt(ctx, "SELECT COUNT(*) FROM cartridges") + if err != nil { + return domain.Status{}, err + } + activeTokens, err := s.scalarInt64(ctx, "SELECT COALESCE(SUM(token_equivalent), 0) FROM atoms") + if err != nil { + return domain.Status{}, err + } + cartridgeTokens, err := s.scalarInt64(ctx, "SELECT COALESCE(SUM(token_equivalent), 0) FROM cartridges") + if err != nil { + return domain.Status{}, err + } + layers, err := s.CacheLayers(ctx) + if err != nil { + return domain.Status{}, err + } + libraryTokens := activeTokens + if cartridgeTokens > libraryTokens { + libraryTokens = cartridgeTokens + } + return domain.Status{ + App: "Ely-Eye MCP", + ProjectRoot: s.cfg.ProjectRoot, + DataHome: s.cfg.ElyEyeHome, + Database: s.cfg.Database, + AtomCount: atomCount, + SourceCount: sourceCount, + CartridgeCount: cartridgeCount, + ActiveTokenEquivalent: activeTokens, + LibraryTokenEquivalent: libraryTokens, + CacheLayers: layers, + }, nil +} + +func (s *Store) CacheLayers(ctx context.Context) ([]domain.CacheLayer, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT + layer, + COUNT(DISTINCT cache_key) AS entries, + COALESCE(SUM(bytes_estimate), 0) AS bytes_estimate, + SUM(CASE WHEN event = 'hit' THEN 1 ELSE 0 END) AS hit_count, + SUM(CASE WHEN event = 'miss' THEN 1 ELSE 0 END) AS miss_count, + COALESCE(MAX(ts), '') AS last_event_at + FROM cache_events + GROUP BY layer + ORDER BY layer`) + if err != nil { + return nil, err + } + defer rows.Close() + layers := []domain.CacheLayer{} + for rows.Next() { + var layer domain.CacheLayer + if err := rows.Scan(&layer.Layer, &layer.Entries, &layer.BytesEstimate, &layer.HitCount, &layer.MissCount, &layer.LastEventAt); err != nil { + return nil, err + } + layers = append(layers, layer) + } + return layers, rows.Err() +} + +func (s *Store) scalarInt(ctx context.Context, query string) (int, error) { + var value int + if err := s.db.QueryRowContext(ctx, query).Scan(&value); err != nil { + return 0, err + } + return value, nil +} + +func (s *Store) scalarInt64(ctx context.Context, query string) (int64, error) { + var value int64 + if err := s.db.QueryRowContext(ctx, query).Scan(&value); err != nil { + return 0, err + } + return value, nil +} + +func sqliteReadOnlyDSN(path string) string { + absolute, _ := filepath.Abs(path) + uri := "file:" + filepath.ToSlash(absolute) + query := url.Values{} + query.Set("mode", "ro") + query.Add("_pragma", "query_only(1)") + // The production database runs in WAL mode and is written by the Ely-Eye + // app concurrently. busy_timeout lets a reader wait through a checkpoint or + // a momentary write lock instead of failing with SQLITE_BUSY. immutable is + // deliberately not set because the database does change underneath us. + query.Add("_pragma", "busy_timeout(5000)") + return fmt.Sprintf("%s?%s", uri, query.Encode()) +}