diff --git a/.github/workflows/e2e-cli-tier2.yml b/.github/workflows/e2e-cli-tier2.yml new file mode 100644 index 0000000..1ae7dd1 --- /dev/null +++ b/.github/workflows/e2e-cli-tier2.yml @@ -0,0 +1,158 @@ +name: E2E CLI Tests (Tier 2) + +# Tier 2 runtimes are best-effort (see README "Supported surface"). They are not +# a PR gate; this schedule exists so they cannot rot silently between releases. +on: + schedule: + # Mondays 06:00 UTC + - cron: "0 6 * * 1" + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + e2e-cli-tier2: + name: E2E CLI Tier 2 - ${{ matrix.cli }} (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + environment: e2e-cli + strategy: + fail-fast: false + matrix: + cli: [gemini, copilot] + os: [ubuntu-24.04, macos-latest] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install system dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y protobuf-compiler libclang-dev + + - name: Install system dependencies (macOS) + if: runner.os == 'macOS' + run: | + brew install protobuf llvm + echo "LIBCLANG_PATH=$(brew --prefix llvm)/lib" >> $GITHUB_ENV + + - name: Install Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.97" + + - name: Cache cargo registry + uses: Swatinem/rust-cache@v2 + with: + shared-key: "e2e-cli-${{ matrix.os }}" + + - name: Build daemon and ingest binaries + run: cargo build -p memory-daemon -p memory-ingest + + - name: Install bats-core (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get install -y bats + + - name: Install bats-core (macOS) + if: runner.os == 'macOS' + run: | + brew install bats-core + + - name: Install bats helper libraries + run: | + mkdir -p tests/cli/lib + git clone --depth 1 https://github.com/bats-core/bats-support.git tests/cli/lib/bats-support + git clone --depth 1 https://github.com/bats-core/bats-assert.git tests/cli/lib/bats-assert + + - name: Verify jq is available + run: jq --version + + - name: Run bats tests + id: bats_run + continue-on-error: true + env: + BATS_LIB_PATH: tests/cli/lib + MEMORY_DAEMON_BIN: target/debug/memory-daemon + MEMORY_INGEST_BIN: target/debug/memory-ingest + run: | + mkdir -p tests/cli/.runs + if [ -d "tests/cli/${{ matrix.cli }}" ]; then + bats --report-formatter junit --output tests/cli/.runs tests/cli/${{ matrix.cli }}/ 2>&1 | tee e2e-cli-results.txt + else + echo "No tests found for ${{ matrix.cli }} — skipping" + echo "::notice::No bats tests found for ${{ matrix.cli }}, skipping" + exit 0 + fi + + - name: Upload JUnit XML report + if: always() + uses: actions/upload-artifact@v4 + with: + name: junit-tier2-${{ matrix.cli }}-${{ matrix.os }} + path: tests/cli/.runs/report.xml + if-no-files-found: ignore + retention-days: 14 + + - name: Upload failure artifacts + if: failure() || steps.bats_run.outcome == 'failure' + uses: actions/upload-artifact@v4 + with: + name: failure-artifacts-tier2-${{ matrix.cli }}-${{ matrix.os }} + path: | + tests/cli/.runs/ + e2e-cli-results.txt + if-no-files-found: ignore + retention-days: 7 + + - name: Report summary + if: always() + run: | + echo "## E2E CLI Results (Tier 2): ${{ matrix.cli }} (${{ matrix.os }})" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ -f e2e-cli-results.txt ]; then + echo '```' >> $GITHUB_STEP_SUMMARY + tail -20 e2e-cli-results.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + else + echo "No test results file found." >> $GITHUB_STEP_SUMMARY + fi + + - name: Check bats test result + if: always() && steps.bats_run.outcome == 'failure' + run: | + echo "Bats tests failed for ${{ matrix.cli }}" + exit 1 + + matrix-report: + name: CLI Matrix Report (Tier 2) + needs: [e2e-cli-tier2] + if: always() + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download all JUnit artifacts + uses: actions/download-artifact@v4 + with: + path: junit-reports + pattern: junit-tier2-* + merge-multiple: false + + - name: Generate matrix report + run: | + chmod +x scripts/cli-matrix-report.sh + scripts/cli-matrix-report.sh junit-reports "gemini copilot" >> $GITHUB_STEP_SUMMARY + + - name: Upload matrix report + if: always() + uses: actions/upload-artifact@v4 + with: + name: cli-matrix-report-tier2 + path: junit-reports/ + if-no-files-found: ignore + retention-days: 14 diff --git a/.github/workflows/e2e-cli.yml b/.github/workflows/e2e-cli.yml index 2b98be4..f145ecb 100644 --- a/.github/workflows/e2e-cli.yml +++ b/.github/workflows/e2e-cli.yml @@ -1,10 +1,13 @@ -name: E2E CLI Tests +name: E2E CLI Tests (Tier 1) +# Tier 1 runtimes are the supported surface (see README "Supported surface"). +# They gate every PR. Tier 2 runtimes run on a schedule in e2e-cli-tier2.yml. on: push: branches: [main] pull_request: branches: [main] + workflow_dispatch: env: CARGO_TERM_COLOR: always @@ -12,13 +15,13 @@ env: jobs: e2e-cli: - name: E2E CLI - ${{ matrix.cli }} (${{ matrix.os }}) + name: E2E CLI Tier 1 - ${{ matrix.cli }} (${{ matrix.os }}) runs-on: ${{ matrix.os }} environment: e2e-cli strategy: fail-fast: false matrix: - cli: [claude-code, gemini, opencode, copilot, codex] + cli: [claude-code, codex] os: [ubuntu-24.04, macos-latest] steps: @@ -109,7 +112,7 @@ jobs: - name: Report summary if: always() run: | - echo "## E2E CLI Results: ${{ matrix.cli }} (${{ matrix.os }})" >> $GITHUB_STEP_SUMMARY + echo "## E2E CLI Results (Tier 1): ${{ matrix.cli }} (${{ matrix.os }})" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY if [ -f e2e-cli-results.txt ]; then echo '```' >> $GITHUB_STEP_SUMMARY @@ -126,7 +129,7 @@ jobs: exit 1 matrix-report: - name: CLI Matrix Report + name: CLI Matrix Report (Tier 1) needs: [e2e-cli] if: always() runs-on: ubuntu-24.04 diff --git a/.planning/MILESTONES.md b/.planning/MILESTONES.md index e054e76..25a5edb 100644 --- a/.planning/MILESTONES.md +++ b/.planning/MILESTONES.md @@ -17,7 +17,8 @@ **Known Gaps:** -- OC-01–06: OpenCode converter is a stub (deferred — OpenCode runtime format still evolving) +- OC-01–06: OpenCode converter is a stub (deferred — OpenCode runtime format still evolving). + **Closed in v3.1 Phase 57 by removing the stub**; OpenCode is no longer a supported runtime. **Stats:** diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index f196100..f192113 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -261,7 +261,12 @@ Agent Memory implements a layered cognitive architecture: ### Known Gaps (v2.7) -- OC-01–06: OpenCode converter is a stub (methods return empty). Deferred to v3.0. +- OC-01–06: RESOLVED-BY-REMOVAL in v3.1 Phase 57. The OpenCode converter was a + stub whose methods returned empty, so `memory-installer --agent opencode` + reported success and wrote no files. Rather than carry the gap further, the + converter, the `Runtime::OpenCode` variant, its bats suite, and the archived + plugin directory were deleted, and OpenCode is documented as not supported. + The runtime-agnostic `memory-ingest --agent opencode` path is unaffected. ### Deferred / Future diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index dae9a06..76d0824 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -12,7 +12,7 @@ - ✅ **v2.6 Cognitive Retrieval** — Phases 39-44 (shipped 2026-03-16) - ✅ **v2.7 Multi-Runtime Portability** — Phases 45-50 (shipped 2026-03-22) - **v3.0 Competitive Parity & Benchmarks** — Phases 51-53 + Phase 51.5 (in progress; Phase 51.5 merged 2026-04-28) -- **v3.1 Make It True** — Phases 54-58 (in progress; Phases 54, 54.5, 55 merged 2026-08-30, Phase 56 executing) +- **v3.1 Make It True** — Phases 54-58 (in progress; Phases 54, 54.5, 55, 56 merged 2026-08-30, Phase 57 executing) ## Phases @@ -279,15 +279,19 @@ Close the claim/reality gap, then open the shop window. No new capabilities. - [x] 55-01: Split setup vs query (64.6s was toc_build) - [x] 55-02: Honest percentiles (n≥10 / n≥30) -### Phase 56: Honest Benchmarks (3/3 plans) — IN EXECUTION 2026-08-30 +### Phase 56: Honest Benchmarks (3/3 plans) — COMPLETE 2026-08-30 (PR #34) - [x] 56-01: Custom harness (real recall@k, content compression, fail-loud, isolation, ≥25 fixtures) - [x] 56-02: LOCOMO adapter v2 (real schema, mock vs llm-judge) - [x] 56-03: Smoke artifacts + HOLD comparison marketing -### Phase 57: Shop Window & Positioning (0/3) +### Phase 57: Shop Window & Positioning (3/3 plans) — IN EXECUTION 2026-08-30 + +- [x] 57-01: Repo hygiene (root README, LICENSE, repository URL) +- [x] 57-02: Positioning writeup vs Mem0 / Zep / MemMachine / Letta +- [x] 57-03: Scope trim — Tier 1/Tier 2 surface; OpenCode stub deleted ### Phase 58: Launch (side quest) -*Updated: 2026-08-30 — Phase 54.5 merged (#35); Phase 56 Honest Benchmarks in execution* +*Updated: 2026-08-30 — Phase 56 merged (#34); Phase 57 Shop Window in execution* diff --git a/.planning/STATE.md b/.planning/STATE.md index d7c6b2d..d9248a9 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,11 +3,11 @@ gsd_state_version: 1.0 milestone_name: Make It True status: in_progress stopped_at: null -last_updated: "2026-08-30T20:45:00.000Z" -last_activity: 2026-08-30 — Phase 54.5 merged (#35); Phase 56 Honest Benchmarks rebasing +last_updated: "2026-08-30T22:30:00.000Z" +last_activity: 2026-08-30 — Phase 56 merged (#34); Phase 57 Shop Window & Positioning in execution progress: total_phases: 6 - completed_phases: 3 + completed_phases: 4 total_plans: 14 completed_plans: 11 percent: 79 @@ -20,16 +20,17 @@ progress: See: .planning/PROJECT.md (updated 2026-03-22) **Core value:** Agent can answer "what were we talking about last week?" without scanning everything -**Current focus:** v3.1 Phase 56 — Honest Benchmarks (real recall@k, locomo10.json schema, HOLD comparison marketing) +**Current focus:** v3.1 Phase 57 — Shop Window & Positioning (root README, LICENSE, positioning writeup, supported-surface tiering) ## Current Position -Phase: 56 of 58 (Honest Benchmarks) -Plan: 01-03 implemented on `feature/phase-56-honest-benchmarks` (PR #34) -Status: Phase 54 + 54.5 + 55 merged; Phase 56 rebase onto #35 -Last activity: 2026-08-30 — #35 merged (clippy pin); rebasing #34 +Phase: 57 of 58 (Shop Window & Positioning) +Plan: 01-03 implemented on `claude/phase-54-toolchain-drift-3k4fer` +Status: Phases 54, 54.5, 55, 56 merged; Phase 57 in review +Last activity: 2026-08-30 — #34 merged; Phase 57 README/LICENSE/positioning/scope-trim -Progress: [████████░░] ~79% (11/14 plans; Phase 56 of 54-58) +Progress: [████████░░] 11/14 plans merged; Phase 57's 3 plans are implemented and in review +(Phase 58 is a side quest, not a GSD phase) ## Out-of-band Work @@ -37,12 +38,13 @@ Progress: [████████░░] ~79% (11/14 plans; Phase 56 of 54-58) | PR | What | Status | |---|---|---| -| #34 | Phase 56 Honest Benchmarks | Open; rebasing onto #35 | +| _(none open)_ | | | ### Recently Merged | PR | What | Merged | |---|---|---| +| #34 | Phase 56 Honest Benchmarks | 2026-08-30 | | #35 | Phase 54.5 truth leaks + rustc 1.97 pin | 2026-08-30 | | #33 | Phase 55 Performance Truth | 2026-08-30 | | #32 | Phase 54 Integration Truth | 2026-08-30 | @@ -59,3 +61,6 @@ Progress: [████████░░] ~79% (11/14 plans; Phase 56 of 54-58) - Phase 55: split setup vs query in `perf_bench`; p90/p99 withheld below 10/30 samples - Warm = one setup + N query samples; cold = new store per iteration - Phase 56: substring metric is `context_hit_rate`; HOLD LOCOMO comparison marketing until `locomo_llm_judge` artifact exists +- Phase 57 tiering: Tier 1 = Claude Code + Codex CLI (PR gate); Tier 2 = Gemini + Copilot (weekly schedule) +- Phase 57: OpenCode removed rather than archived — a converter whose methods return empty is a false success, not a gap +- Phase 57: no comparative benchmark claim ships while the only committed results are mock-backend / mock-judge diff --git a/.planning/phases/57-shop-window/57-CONTEXT.md b/.planning/phases/57-shop-window/57-CONTEXT.md new file mode 100644 index 0000000..1d555d9 --- /dev/null +++ b/.planning/phases/57-shop-window/57-CONTEXT.md @@ -0,0 +1,32 @@ +# Phase 57: Shop Window & Positioning + +**Gathered:** 2026-08-30 +**Status:** In execution +**Source:** docs/plans/phase-57-shop-window-plan.md + +Make the public face of the repo match the reality Phases 54–56 established: +a root README with an honest status table, a LICENSE, a positioning writeup +that leads with the structural differences and declines to make a benchmark +comparison it cannot back, and a supported-surface trim that deletes the +OpenCode stub instead of shipping empty methods. + +## What was already true before this phase + +- No root `README.md` — the GitHub landing page was empty +- No `LICENSE` file, despite `license = "MIT"` in `Cargo.toml` +- `workspace.package.repository` pointed at `spillwave/agent-memory`, not the + actual remote `SpillwaveSolutions/agent-memory` +- `docs/README.md` advertised "Passive capture from Claude Code, OpenCode, + Gemini CLI hooks" and a "Plugin (TypeScript)" OpenCode adapter; the OpenCode + converter's methods all returned empty and `plugins/memory-opencode-plugin/` + contained only an archived README +- Five bats CLI suites gated every PR, one of them for a runtime with no + working converter + +## Constraints carried in + +- **Benchmark gate (Phase 56):** the only committed results are mock-backend + and mock-judge, so no comparative accuracy claim may appear anywhere public +- **Execution-evidence rule (v3.1 process change):** the quickstart is a + run-dependent requirement, so it must be verified by actually executing it + and committing the transcript — not by the README's existence diff --git a/.planning/phases/57-shop-window/57-VERIFICATION.md b/.planning/phases/57-shop-window/57-VERIFICATION.md new file mode 100644 index 0000000..0516f8e --- /dev/null +++ b/.planning/phases/57-shop-window/57-VERIFICATION.md @@ -0,0 +1,61 @@ +--- +phase: 57-shop-window +verified: 2026-08-30 +status: passed +--- + +# Phase 57: Shop Window & Positioning Verification + +**Phase Goal:** a stranger landing on the repo understands what it is, trusts +it, and can run it — and the project's public claims match Phases 54–56 reality. + +## Execution evidence + +| # | Claim | Status | Evidence | +|---|-------|--------|----------| +| 1 | Root `README.md` exists and renders the landing page | FILE | `README.md`, 200+ lines: pitch, ASCII architecture, quickstart, status table, tiers, docs index | +| 2 | `LICENSE` present and matches `Cargo.toml` | FILE | `LICENSE` (MIT); `workspace.package.license = "MIT"` | +| 3 | `repository` points at the real remote | RUN | `Cargo.toml:30` = `SpillwaveSolutions/agent-memory`; `git remote -v` agrees | +| 4 | Quickstart executed verbatim on a machine with no toolchain and no store | **RUN** | `docs/verification/57-quickstart-transcript.md` — full transcript, store wiped first | +| 5 | Search returns the ingested event after the documented wait | **RUN** | `memory search "which JWT signing algorithm did we pick"` → 1 hit, `source_layer: bm25`, `text_preview` populated | +| 6 | Positioning doc exists, leads with the three structural differences | FILE | `docs/positioning/agent-memory-vs-competition.md` — head-to-head table, "where they are ahead of us", platform risk, claims ledger with sources + dates | +| 7 | No comparative benchmark claim ships (Phase 56 gate honored) | DOCS | Positioning doc states the only committed results are mock-backend / mock-judge and declines the comparison; README Benchmarks section says the same | +| 8 | OpenCode stub removed, not archived | **RUN** | `memory-installer install --agent opencode --project` → `invalid value 'opencode'`, exit 2. Previously exited 0 and wrote nothing | +| 9 | No stub converter can be reintroduced silently | TEST | `every_offered_runtime_converts_something` in `crates/memory-installer/tests/e2e_converters.rs` iterates `Runtime::value_variants()` | +| 10 | Tier 1 gates PRs; Tier 2 runs on a schedule | CI | `e2e-cli.yml` matrix = `[claude-code, codex]` on push/PR; new `e2e-cli-tier2.yml` matrix = `[gemini, copilot]` on `schedule` + `workflow_dispatch` | + +## Defects found by executing the quickstart (and fixed in this phase) + +The README was written first, then run. Three defects surfaced — all in the +"documented happy path silently does nothing" family: + +| # | Defect | Fix | +|---|--------|-----| +| A | A fresh store has no `db/search` or `db/vector`, so the outbox indexing job never registered and **every query returned `results: []` with `confidence 0.0` and no error** | `start_daemon` creates both index directories before job registration (`crates/memory-daemon/src/commands.rs`) | +| B | The remedy the daemon itself printed (`admin rebuild-indexes`) failed on the RocksDB lock while the daemon ran, and reported "No documents found" when stopped — it indexes TOC nodes and grips, not raw events | Moot after A: the documented path no longer routes through it | +| C | `admin rebuild-toc` printed "TOC rebuild not yet fully implemented" and **exited 0**; `--dry-run` claimed "To actually rebuild, run without --dry-run" | Now `anyhow::bail!`s with guidance and exits 1 — same treatment Phase 54 gave `--background` | + +Two truths about retrieval were also found and written into the README rather +than left for a user to discover: + +- Indexing is a ~1-minute scheduled outbox drain, not synchronous with ingest. + The README now has an explicit wait step. +- BM25 does not stem: `jwt` does not match `JWTs`. The README's second example + was changed to a query BM25 can answer, and the status table says so. + +## Human verification (blockers) + +- [x] Quickstart executed start-to-finish from the README verbatim, on a fresh + store, with the transcript committed +- [x] `memory search` returns the ingested event (not an empty result set) +- [x] `--agent opencode` is rejected rather than silently succeeding +- [x] No comparative accuracy claim anywhere public + +## Not done (stated, not waved through) + +| Item | Why | Owner | +|---|---|---| +| GitHub repo description, topics, Discussions | Repository settings — cannot be changed from a PR | Maintainer, before launch | +| asciinema / GIF demo of drill-down navigation | Not recorded. The executed transcript is committed instead | Phase 58 (Launch) | +| Vector search exercised in the quickstart | This container's proxy blocks the Hugging Face model download; the daemon warned and ran BM25-only, as documented | Covered by the workspace test suite | +| macOS quickstart | Not run here. macOS prerequisites in the README come from the CI workflow, which does run `macos-latest` | Phase 58 (Launch) | diff --git a/Cargo.toml b/Cargo.toml index 45bd645..ae21fa1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ members = [ version = "2.7.0" edition = "2021" license = "MIT" -repository = "https://github.com/spillwave/agent-memory" +repository = "https://github.com/SpillwaveSolutions/agent-memory" [workspace.dependencies] # Internal crates diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a57a8ae --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Spillwave Solutions + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b90efdd --- /dev/null +++ b/README.md @@ -0,0 +1,226 @@ +# Agent Memory + +**Local-first conversational memory for AI coding agents.** Your agent answers +"what were we talking about last week?" by *navigating* a time-hierarchical +index — not by replaying your whole history into its context window. + +[![CI](https://github.com/SpillwaveSolutions/agent-memory/actions/workflows/ci.yml/badge.svg)](https://github.com/SpillwaveSolutions/agent-memory/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) + +Everything runs on your machine: an append-only RocksDB event log, a local +BM25 index (Tantivy), a local vector index (HNSW + Candle embeddings), and a +gRPC daemon your agent talks to. Capture is passive — CLI hooks pipe events in, +so the agent spends **zero tokens** recording what it did. + +--- + +## Why this instead of a memory API + +Three things are structurally different, not just tuned differently: + +- **Passive, zero-token capture.** Events arrive from CLI hooks. The agent is + not asked to "decide what to remember", so remembering costs no tokens and + cannot be skipped when the context is full. +- **Local-first.** The event log, the indexes, and the embeddings live in + `~/.local/share/agent-memory`. Nothing leaves the machine unless you turn on + an LLM summarizer or LLM reranking and give it an API key. +- **Cross-CLI.** Memory is a layer beside the CLI, not inside one, so the same + store is reachable from more than one agent runtime. + +The long-form comparison against Mem0, Zep, MemMachine, and Letta — including +the "won't the vendors just build this in?" question — is in +[docs/positioning/agent-memory-vs-competition.md](docs/positioning/agent-memory-vs-competition.md). + +--- + +## How retrieval works + +Instead of scanning everything, the agent drills down a Table of Contents built +over time, reading a summary at each level and deciding whether to go deeper: + +``` +Year ──▶ Month ──▶ Week ──▶ Day ──▶ Segment ──▶ Grip ──▶ raw events + │ + summary + keywords ─────┘ excerpt + provenance +``` + +Three independent retrieval layers feed a single fused ranking: + +``` + ┌──────────────────────────────┐ + hooks ──▶ ingest ──▶│ RocksDB append-only log │ + (passive) └───────────────┬──────────────┘ + │ outbox + ┌─────────────────────┼─────────────────────┐ + ▼ ▼ ▼ + BM25 (Tantivy) HNSW vectors TOC + topics + └─────────────────────┼─────────────────────┘ + ▼ + MemoryOrchestrator + (fuse ─▶ rerank ─▶ explain) + ▼ + gRPC ──▶ memory search +``` + +`MemoryOrchestrator` is reachable from the shipped binaries: the daemon's +`RouteQuery` RPC calls it, and `memory search` is a client of that RPC. + +--- + +## Quickstart (5 minutes) + +Prerequisites: **macOS or Linux**, `protoc`, and Rust (the repo pins the +toolchain in `rust-toolchain.toml`, so `rustup` picks the right one). + +```bash +# Ubuntu/Debian: sudo apt-get install -y protobuf-compiler libclang-dev +# macOS: brew install protobuf llvm +``` + +### 1. Build + +```bash +git clone https://github.com/SpillwaveSolutions/agent-memory.git +cd agent-memory +cargo build --release -p memory-daemon -p memory-ingest -p memory-cli +export PATH="$PWD/target/release:$PATH" +``` + +### 2. Start the daemon + +The daemon runs in the foreground. There is no built-in background mode — use +`systemd`, `launchd`, or your terminal multiplexer. + +```bash +memory-daemon start --foreground & +memory-daemon status +``` + +First start creates the store and its index directories under +`~/.local/share/agent-memory/` and downloads the embedding model for vector +search. With no network it logs a warning and runs BM25-only — the daemon still +starts. + +### 3. Record something + +In real use, CLI hooks do this for you (see step 6). To prove the path works: + +```bash +memory add --content "We chose RS256 over HS256 for the auth service JWTs" --agent claude +memory add --content "Rate limiting lives in the gateway, not the auth service" --agent claude +``` + +### 4. Wait for the indexer + +Indexing is **not synchronous with ingest**. Events land in the event log +immediately and an outbox drain indexes them on a one-minute schedule, so a +query issued straight after `memory add` legitimately returns nothing. + +```bash +sleep 70 +``` + +### 5. Ask for it back + +```bash +memory search "which JWT signing algorithm did we pick" --top 5 +memory search "rate limiting gateway" --format json | jq '.results[0].text_preview' +``` + +BM25 matches tokens as written — it does not stem, so `jwt` will not find +`JWTs`. Paraphrase matching is the vector layer's job, and that needs the +embedding model from step 2. + +`memory recall` is the same search with LLM reranking; it needs an API key and +falls back to the heuristic ranker (and *says so* in its explainability +payload) when the model is unavailable. + +### 6. Wire it to your agent + +```bash +cargo build --release -p memory-installer +target/release/memory-installer install --agent claude --project +``` + +Installs hooks, commands, and skills into `./.claude/`. Use `--global` for +`~/.claude/`, and `--dry-run` to see the file list first. Run +`memory-installer install --help` for the runtimes on offer. + +--- + +## Status: what is solid, what is not + +This table is the point of the v3.1 milestone. If a row says experimental, it +is experimental. + +| Area | Status | Notes | +|---|---|---| +| Append-only event log (RocksDB) | **Solid** | Immutable, durable, the source of truth | +| Passive hook capture → `memory-ingest` | **Solid** | Covered by the bats CLI suites on Linux + macOS | +| TOC build and drill-down navigation | **Solid** | Year → Month → Week → Day → Segment → Grip | +| Grips / provenance | **Solid** | Excerpts link back to the events they came from | +| BM25 keyword search (Tantivy) | **Solid** | Exact tokens, no stemming (`jwt` does not match `JWTs`). Indexes built before v3.1 do not store text — rebuild to get event previews | +| Vector search (HNSW + Candle) | **Solid** | First daemon start downloads the embedding model; with no network the daemon warns and runs BM25-only | +| Topic graph | **Works** | Clustering quality is not benchmarked | +| Hybrid fusion + `RouteQuery` orchestration | **Works** | Wired end-to-end in Phase 54; explainability reports what actually ran | +| LLM summarization / LLM rerank | **Experimental** | Needs an API key; fails open to the heuristic ranker and reports `rerank=heuristic` when it does | +| Cross-project federated query | **Experimental** | Implemented; not performance-characterised | +| Cross-encoder rerank | **Not implemented** | The extension point exists and returns an explicit error — it is not silently degraded | +| Ingest → searchable latency | **~1 minute** | The outbox drains on a schedule; ingest is deliberately not blocked on indexing | +| Offline TOC rebuild (`admin rebuild-toc`) | **Not implemented** | Exits non-zero with guidance. TOC nodes come from the daemon's scheduled rollup jobs | +| Background daemonization | **Not implemented** | `--background` exits non-zero with guidance rather than pretending | + +### Benchmarks + +`docs/benchmarks.md` explains what the harness measures and, specifically, why +the old "65 second TOC" number was a harness defect (it timed ingest-time +rollup and labelled it navigation). + +Committed results live in `benchmarks/results/`. Today both are **mock-backend** +runs — a mock retrieval backend and a mock judge — so they demonstrate the +harness, not competitive quality. **There is deliberately no comparison +marketing in this repo**, and there will not be until a real-backend, +real-judge run is committed next to the claim. + +--- + +## Supported surface + +Maintaining six runtime converters and five CLI test suites as first-class +promises is not sustainable for this project's size, so the promise is tiered +rather than uniform. + +| Tier | Runtimes | What it means | +|---|---|---| +| **Tier 1 — supported** | Claude Code, Codex CLI | Converters are exercised on every PR (bats suites on Linux + macOS). Bugs here are release blockers | +| **Tier 2 — best effort** | Gemini CLI, Copilot CLI | Converters are implemented and tested; their bats suites run on a weekly schedule, not the PR gate. Fixes are welcome, response is not guaranteed | +| **Not supported** | OpenCode | The converter was an empty stub that reported success and wrote nothing. It was removed in Phase 57 rather than shipped. `--agent opencode` is now rejected | + +Any runtime can still feed the store directly by piping events to +`memory-ingest` with `--agent ` — that path is runtime-agnostic and is +unaffected by tiering. + +--- + +## Documentation + +| Doc | What's in it | +|---|---| +| [docs/README.md](docs/README.md) | Concepts: progressive disclosure, TOC navigation, grips | +| [docs/setup/quickstart.md](docs/setup/quickstart.md) | Longer install path, including prebuilt binaries | +| [docs/setup/agent-setup.md](docs/setup/agent-setup.md) | Per-runtime hook wiring | +| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Crate layout and data flow | +| [docs/API.md](docs/API.md) | gRPC surface | +| [docs/benchmarks.md](docs/benchmarks.md) | What the perf harness measures, and what it does not | +| [docs/verification/57-quickstart-transcript.md](docs/verification/57-quickstart-transcript.md) | The transcript of this quickstart being run on a clean machine, defects and all | +| [docs/positioning/agent-memory-vs-competition.md](docs/positioning/agent-memory-vs-competition.md) | Head-to-head vs Mem0 / Zep / MemMachine / Letta | +| [docs/UPGRADING.md](docs/UPGRADING.md) | Version-to-version migration notes | + +## Contributing + +`task pr-precheck` before every PR — it runs the same format, clippy, test, and +doc gates CI does. See [CLAUDE.md](CLAUDE.md) for repository conventions. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/crates/memory-daemon/src/commands.rs b/crates/memory-daemon/src/commands.rs index 6a728d5..6c2fb0f 100644 --- a/crates/memory-daemon/src/commands.rs +++ b/crates/memory-daemon/src/commands.rs @@ -115,8 +115,9 @@ fn is_process_running(_pid: u32) -> bool { /// 2. Create an indexing pipeline with the BM25 updater /// 3. Register the pipeline with the scheduler /// -/// If the search index doesn't exist, returns an error. Users should -/// run `rebuild-indexes` first to initialize the search index. +/// The search index directory is created by [`start_daemon`] before this runs, +/// so a missing directory here means the path is not writable rather than a +/// first-run store; that is an error, not a skip. async fn register_indexing_job( scheduler: &SchedulerService, storage: Arc, @@ -617,6 +618,21 @@ pub async fn start_daemon( let storage = Storage::open(&db_path).context("Failed to open storage")?; let storage = Arc::new(storage); + // A fresh store has no index directories, and both the outbox indexing job + // and the prune jobs only register when their directory already exists. + // Without this, a first-run daemon accepts events and then answers every + // query with an empty result set and no error -- see Phase 57 quickstart + // verification. Create the directories so indexing starts on the first + // outbox drain. + for sub in ["search", "vector"] { + let index_dir = db_path.join(sub); + if !index_dir.exists() { + fs::create_dir_all(&index_dir) + .with_context(|| format!("Failed to create index directory {index_dir:?}"))?; + info!("Created index directory {:?}", index_dir); + } + } + // Create scheduler info!("Initializing scheduler..."); let scheduler = SchedulerService::new(SchedulerConfig::default()) @@ -1354,14 +1370,18 @@ pub fn handle_admin(db_path: Option, command: AdminCommands) -> Result<( events.last().map(|(k, _)| k.timestamp_ms).unwrap_or(0) ); println!(); - println!("To actually rebuild, run without --dry-run"); + println!("Note: offline TOC rebuild is not implemented; running"); + println!("without --dry-run reports this and exits non-zero."); } else { - // TODO: Full TOC rebuild would require integrating with memory-toc - // For now, just report what would be done - println!(); - println!("TOC rebuild not yet fully implemented."); - println!("This would require re-running segmentation and summarization."); - println!("Events are intact and can be manually processed."); + // Offline rebuild would have to re-run segmentation and + // summarization outside the daemon. It is not implemented, so + // say so and fail rather than printing a TODO and exiting 0. + anyhow::bail!( + "offline TOC rebuild is not implemented; TOC nodes are produced by the \ + daemon's scheduled rollup jobs (toc_rollup_day / _week / _month) -- run \ + `memory-daemon start --foreground` and check `memory-daemon scheduler status`. \ + Your events are intact in the event log." + ); } } diff --git a/crates/memory-installer/src/converter.rs b/crates/memory-installer/src/converter.rs index cdc2334..32ac887 100644 --- a/crates/memory-installer/src/converter.rs +++ b/crates/memory-installer/src/converter.rs @@ -7,10 +7,10 @@ use crate::types::{ /// Trait for converting canonical Claude-format plugins to a specific runtime's format. /// -/// Each runtime (Claude, OpenCode, Gemini, Codex, Copilot, Skills) implements this trait. +/// Each runtime (Claude, Gemini, Codex, Copilot, Skills) implements this trait. /// Converters are stateless -- all configuration is passed via [`InstallConfig`]. pub trait RuntimeConverter { - /// Human-readable name for this runtime (e.g., "claude", "opencode"). + /// Human-readable name for this runtime (e.g., "claude", "codex"). fn name(&self) -> &str; /// Target directory for this runtime given the install scope. @@ -44,12 +44,6 @@ mod tests { assert_eq!(converter.name(), "claude"); } - #[test] - fn select_converter_returns_correct_name_for_opencode() { - let converter = select_converter(Runtime::OpenCode); - assert_eq!(converter.name(), "opencode"); - } - #[test] fn select_converter_returns_correct_name_for_gemini() { let converter = select_converter(Runtime::Gemini); @@ -88,8 +82,9 @@ mod tests { source_path: PathBuf::from("test.md"), }; - // All implemented converters produce at least one ConvertedFile. - // OpenCode is still a stub (Phase 47 scope) -- excluded here. + // Every runtime the installer offers produces at least one ConvertedFile. + // There are no stub converters: a runtime that cannot be converted is + // not a `Runtime` variant (see Phase 57 supported-surface tiering). for runtime in [ Runtime::Claude, Runtime::Gemini, @@ -115,9 +110,6 @@ mod tests { let claude_dir = select_converter(Runtime::Claude).target_dir(&scope); assert!(claude_dir.to_str().unwrap().contains(".claude")); - let opencode_dir = select_converter(Runtime::OpenCode).target_dir(&scope); - assert!(opencode_dir.to_str().unwrap().contains(".opencode")); - let gemini_dir = select_converter(Runtime::Gemini).target_dir(&scope); assert!(gemini_dir.to_str().unwrap().contains(".gemini")); } diff --git a/crates/memory-installer/src/converters/mod.rs b/crates/memory-installer/src/converters/mod.rs index 74b2e62..47a896f 100644 --- a/crates/memory-installer/src/converters/mod.rs +++ b/crates/memory-installer/src/converters/mod.rs @@ -3,14 +3,12 @@ pub mod codex; pub mod copilot; pub mod gemini; pub mod helpers; -pub mod opencode; pub mod skills; pub use claude::ClaudeConverter; pub use codex::CodexConverter; pub use copilot::CopilotConverter; pub use gemini::GeminiConverter; -pub use opencode::OpenCodeConverter; pub use skills::SkillsConverter; use crate::converter::RuntimeConverter; @@ -20,7 +18,6 @@ use crate::types::Runtime; pub fn select_converter(runtime: Runtime) -> Box { match runtime { Runtime::Claude => Box::new(ClaudeConverter), - Runtime::OpenCode => Box::new(OpenCodeConverter), Runtime::Gemini => Box::new(GeminiConverter), Runtime::Codex => Box::new(CodexConverter), Runtime::Copilot => Box::new(CopilotConverter), diff --git a/crates/memory-installer/src/converters/opencode.rs b/crates/memory-installer/src/converters/opencode.rs deleted file mode 100644 index 3adaace..0000000 --- a/crates/memory-installer/src/converters/opencode.rs +++ /dev/null @@ -1,49 +0,0 @@ -use std::path::PathBuf; - -use crate::converter::RuntimeConverter; -use crate::types::{ - ConvertedFile, HookDefinition, InstallConfig, InstallScope, PluginAgent, PluginBundle, - PluginCommand, PluginSkill, -}; - -pub struct OpenCodeConverter; - -#[allow(unused_variables)] -impl RuntimeConverter for OpenCodeConverter { - fn name(&self) -> &str { - "opencode" - } - - fn target_dir(&self, scope: &InstallScope) -> PathBuf { - match scope { - InstallScope::Project(root) => root.join(".opencode"), - InstallScope::Global => { - let config_dir = directories::BaseDirs::new() - .map(|b| b.config_dir().to_path_buf()) - .unwrap_or_else(|| PathBuf::from(shellexpand::tilde("~/.config").as_ref())); - config_dir.join("opencode") - } - InstallScope::Custom(dir) => dir.clone(), - } - } - - fn convert_command(&self, cmd: &PluginCommand, cfg: &InstallConfig) -> Vec { - Vec::new() - } - - fn convert_agent(&self, agent: &PluginAgent, cfg: &InstallConfig) -> Vec { - Vec::new() - } - - fn convert_skill(&self, skill: &PluginSkill, cfg: &InstallConfig) -> Vec { - Vec::new() - } - - fn convert_hook(&self, hook: &HookDefinition, cfg: &InstallConfig) -> Option { - None - } - - fn generate_guidance(&self, bundle: &PluginBundle, cfg: &InstallConfig) -> Vec { - Vec::new() - } -} diff --git a/crates/memory-installer/src/tool_maps.rs b/crates/memory-installer/src/tool_maps.rs index 54f042a..1eebbec 100644 --- a/crates/memory-installer/src/tool_maps.rs +++ b/crates/memory-installer/src/tool_maps.rs @@ -4,7 +4,7 @@ //! Returns `Option<&'static str>` -- `None` means the tool is excluded for that runtime. //! //! **MCP tools (`mcp__*`):** Callers must check `tool_name.starts_with("mcp__")` before -//! calling `map_tool`. MCP tools pass through unchanged for Claude/OpenCode and are +//! calling `map_tool`. MCP tools pass through unchanged for Claude and are //! excluded (None) for Gemini/Codex/Copilot. This keeps `map_tool` simple with a //! static return type. @@ -46,19 +46,6 @@ pub fn map_tool(runtime: Runtime, claude_name: &str) -> Option<&'static str> { (Runtime::Skills, "AskUserQuestion") => Some("AskUserQuestion"), (Runtime::Skills, "Task") => Some("Task"), - // OpenCode: lowercase equivalents - (Runtime::OpenCode, "Read") => Some("read"), - (Runtime::OpenCode, "Write") => Some("write"), - (Runtime::OpenCode, "Edit") => Some("edit"), - (Runtime::OpenCode, "Bash") => Some("bash"), - (Runtime::OpenCode, "Grep") => Some("grep"), - (Runtime::OpenCode, "Glob") => Some("glob"), - (Runtime::OpenCode, "WebSearch") => Some("websearch"), - (Runtime::OpenCode, "WebFetch") => Some("webfetch"), - (Runtime::OpenCode, "TodoWrite") => Some("todowrite"), - (Runtime::OpenCode, "AskUserQuestion") => Some("question"), - (Runtime::OpenCode, "Task") => Some("task"), - // Gemini: snake_case / Gemini-specific names; Task excluded (Runtime::Gemini, "Read") => Some("read_file"), (Runtime::Gemini, "Write") => Some("write_file"), @@ -131,24 +118,6 @@ mod tests { // --- Individual mapping tests --- - #[test] - fn opencode_read() { - assert_eq!(map_tool(Runtime::OpenCode, "Read"), Some("read")); - } - - #[test] - fn opencode_write() { - assert_eq!(map_tool(Runtime::OpenCode, "Write"), Some("write")); - } - - #[test] - fn opencode_ask_user_question() { - assert_eq!( - map_tool(Runtime::OpenCode, "AskUserQuestion"), - Some("question") - ); - } - #[test] fn gemini_read() { assert_eq!(map_tool(Runtime::Gemini, "Read"), Some("read_file")); @@ -186,21 +155,11 @@ mod tests { #[test] fn unknown_tool_returns_none() { - assert_eq!(map_tool(Runtime::OpenCode, "UnknownTool"), None); + assert_eq!(map_tool(Runtime::Claude, "UnknownTool"), None); } // --- Exhaustive coverage tests --- - #[test] - fn all_11_tools_return_some_for_opencode() { - for tool in KNOWN_TOOLS { - assert!( - map_tool(Runtime::OpenCode, tool).is_some(), - "OpenCode should map tool '{tool}'" - ); - } - } - #[test] fn gemini_maps_10_returns_none_for_task() { let mut some_count = 0; @@ -259,7 +218,6 @@ mod tests { fn unknown_tool_none_for_all_runtimes() { for runtime in [ Runtime::Claude, - Runtime::OpenCode, Runtime::Gemini, Runtime::Codex, Runtime::Copilot, diff --git a/crates/memory-installer/src/types.rs b/crates/memory-installer/src/types.rs index 7b88aaa..71d39f4 100644 --- a/crates/memory-installer/src/types.rs +++ b/crates/memory-installer/src/types.rs @@ -4,7 +4,6 @@ use std::path::PathBuf; #[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] pub enum Runtime { Claude, - OpenCode, Gemini, Codex, Copilot, diff --git a/crates/memory-installer/tests/e2e_converters.rs b/crates/memory-installer/tests/e2e_converters.rs index 6bfbc23..1cd3581 100644 --- a/crates/memory-installer/tests/e2e_converters.rs +++ b/crates/memory-installer/tests/e2e_converters.rs @@ -510,54 +510,52 @@ fn skills_full_bundle() { } // --------------------------------------------------------------------------- -// 6. OpenCode stub (MIG-01) +// 6. No stub converters ship (Phase 57 supported-surface tiering) // --------------------------------------------------------------------------- +/// The OpenCode converter used to be a registered `Runtime` whose methods all +/// returned empty, so `memory-installer --agent opencode` reported success and +/// wrote nothing. Phase 57 removed it rather than shipping empty methods. +/// +/// This test guards the rule that replaced it: every runtime the installer +/// offers on its command line actually converts something. #[test] -fn opencode_stub() { +fn every_offered_runtime_converts_something() { + use clap::ValueEnum; + let bundle = canonical_bundle(); let cfg = InstallConfig { - scope: InstallScope::Project(PathBuf::from("/tmp/opencode-test")), + scope: InstallScope::Project(PathBuf::from("/tmp/no-stub-test")), dry_run: false, source_root: PathBuf::from("/src"), }; - let converter = select_converter(Runtime::OpenCode); + let variants = Runtime::value_variants(); + assert!( + !variants.is_empty(), + "installer must offer at least one runtime" + ); - // Converter name - assert_eq!(converter.name(), "opencode"); + for runtime in variants { + let converter = select_converter(*runtime); - // All convert methods return empty - for cmd in &bundle.commands { - assert!( - converter.convert_command(cmd, &cfg).is_empty(), - "OpenCode convert_command should return empty" - ); - } - for agent in &bundle.agents { - assert!( - converter.convert_agent(agent, &cfg).is_empty(), - "OpenCode convert_agent should return empty" + assert_ne!( + converter.name(), + "opencode", + "the OpenCode stub was removed in Phase 57; re-adding it needs a real converter" ); - } - for skill in &bundle.skills { - assert!( - converter.convert_skill(skill, &cfg).is_empty(), - "OpenCode convert_skill should return empty" - ); - } - for hook in &bundle.hooks { + + let produced: usize = bundle + .commands + .iter() + .map(|cmd| converter.convert_command(cmd, &cfg).len()) + .sum(); assert!( - converter.convert_hook(hook, &cfg).is_none(), - "OpenCode convert_hook should return None" + produced > 0, + "{} converter produced no files for the canonical bundle -- stub converters must not be offered", + converter.name() ); } - - // generate_guidance returns empty - assert!( - converter.generate_guidance(&bundle, &cfg).is_empty(), - "OpenCode generate_guidance should return empty" - ); } // --------------------------------------------------------------------------- diff --git a/docs/README.md b/docs/README.md index cf1482d..e1350f2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,14 +9,14 @@ Agent Memory enables AI agents to answer questions like "what were we talking ab - **TOC-based Navigation**: Time-hierarchical Table of Contents (Year → Month → Week → Day → Segment) for efficient drill-down - **Grips for Provenance**: Excerpts linked to source events for verifiable citations - **Append-only Storage**: Immutable event log with RocksDB for durability -- **Hook-based Ingestion**: Passive capture from Claude Code, OpenCode, Gemini CLI hooks +- **Hook-based Ingestion**: Passive, zero-token capture from CLI hooks (Claude Code and Codex CLI are the supported surface -- see the root README) - **gRPC API**: High-performance interface for agent integration ## Setup Guides - [Quickstart (macOS + Linux)](setup/quickstart.md) - [Full Guide (macOS + Linux)](setup/full-guide.md) -- [Agent Setup (Claude Code, OpenCode, Gemini CLI, Copilot CLI)](setup/agent-setup.md) +- [Agent Setup (Claude Code, Codex CLI, Gemini CLI, Copilot CLI)](setup/agent-setup.md) ## Core Value: Agentic Search Through Progressive Disclosure @@ -341,14 +341,29 @@ agent-memory/ Agent Memory supports multiple AI coding agents simultaneously. Each adapter captures events and provides skills/commands for its respective agent: -| Agent | Adapter | Event Capture | Install | -|-------|---------|---------------|---------| -| Claude Code | Built-in (hooks.yaml) | CCH binary | [Setup Guide](../plugins/memory-query-plugin/README.md) | -| OpenCode | Plugin (TypeScript) | Plugin system | [Setup Guide](../plugins/memory-opencode-plugin/README.md) | -| Gemini CLI | Shell hooks | settings.json | [Setup Guide](../plugins/memory-gemini-adapter/README.md) | -| Copilot CLI | Shell hooks | hooks.json | [Setup Guide](../plugins/memory-copilot-adapter/README.md) | +Runtime files are generated by `memory-installer` from the canonical sources in +`plugins/`; the per-runtime plugin directories are archived stubs, not separate +implementations. -All adapters share the same memory daemon and storage. Events are tagged by agent for cross-agent discovery and filtering. +| Agent | Tier | Install | +|-------|------|---------| +| Claude Code | Tier 1 -- gated on every PR | `memory-installer install --agent claude --project` | +| Codex CLI | Tier 1 -- gated on every PR | `memory-installer install --agent codex --project` | +| Gemini CLI | Tier 2 -- best effort, weekly CI | `memory-installer install --agent gemini --project` | +| Copilot CLI | Tier 2 -- best effort, weekly CI | `memory-installer install --agent copilot --project` | +| OpenCode | Not supported | The converter was an empty stub and was removed in Phase 57 | + +See [Supported surface](../README.md#supported-surface) for what the tiers +promise. All runtimes share the same memory daemon and storage, and events are +tagged by agent for cross-agent discovery and filtering. + +Any runtime -- including OpenCode -- can still feed the store directly: + +```bash +memory-ingest --agent opencode < event.json +``` + +That path is runtime-agnostic and unaffected by tiering. ## Cross-Agent Discovery diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index 1999456..3d8cd43 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -62,7 +62,7 @@ Install the adapters for your agents: | Agent | Adapter Location | Install | |-------|-----------------|---------| | Claude Code | Built-in (hooks.yaml) | [Setup Guide](../plugins/memory-query-plugin/README.md) | -| OpenCode | `plugins/memory-opencode-plugin/` | [Setup Guide](../plugins/memory-opencode-plugin/README.md) | +| OpenCode | Removed in v3.1 -- the converter was a stub | [Supported surface](../README.md#supported-surface) | | Gemini CLI | `plugins/memory-gemini-adapter/` | [Setup Guide](../plugins/memory-gemini-adapter/README.md) | | Copilot CLI | `plugins/memory-copilot-adapter/` | [Setup Guide](../plugins/memory-copilot-adapter/README.md) | diff --git a/docs/adapters/authoring-guide.md b/docs/adapters/authoring-guide.md index c4dcb40..ec05a18 100644 --- a/docs/adapters/authoring-guide.md +++ b/docs/adapters/authoring-guide.md @@ -575,7 +575,7 @@ Study these existing adapters for patterns and best practices: | Adapter | Strengths | Location | |---------|-----------|----------| | Claude Code | Simplest hook integration | `plugins/memory-query-plugin/` | -| OpenCode | TypeScript plugin example | `plugins/memory-opencode-plugin/` | +| Codex CLI | Sandboxed runtime notes | `adapters/codex-cli/` | | Gemini CLI | Shell hook with settings.json | `plugins/memory-gemini-adapter/` | | Copilot CLI | Hook + skill hybrid approach | `plugins/memory-copilot-adapter/` | diff --git a/docs/adapters/cross-agent-guide.md b/docs/adapters/cross-agent-guide.md index f488966..7f2a953 100644 --- a/docs/adapters/cross-agent-guide.md +++ b/docs/adapters/cross-agent-guide.md @@ -26,7 +26,7 @@ Each adapter has its own installation process. See the adapter-specific README f | Agent | Setup Guide | |-------|-------------| | Claude Code | [Claude Code Setup](../../plugins/memory-query-plugin/README.md) | -| OpenCode | [OpenCode Plugin Setup](../../plugins/memory-opencode-plugin/README.md) | +| Codex CLI | [Codex CLI Adapter Notes](../../adapters/codex-cli/README.md) | | Gemini CLI | [Gemini Adapter Setup](../../plugins/memory-gemini-adapter/README.md) | | Copilot CLI | [Copilot Adapter Setup](../../plugins/memory-copilot-adapter/README.md) | diff --git a/docs/plans/phase-57-shop-window-plan.md b/docs/plans/phase-57-shop-window-plan.md new file mode 100644 index 0000000..84350e2 --- /dev/null +++ b/docs/plans/phase-57-shop-window-plan.md @@ -0,0 +1,47 @@ +# Phase 57: Shop Window & Positioning — Plan + +**Milestone:** v3.1 Make It True +**Goal:** a stranger landing on the repo understands what it is, trusts it, and +can run it — and the project's public claims match Phases 54–56 reality. + +## 57-01: Repo hygiene + +| Item | Decision | +|---|---| +| Root `README.md` | New. One-paragraph local-first pitch, ASCII architecture diagram, 5-minute quickstart, honest status table, supported-surface tiers, docs index | +| `LICENSE` | MIT, matching `workspace.package.license` | +| `workspace.package.repository` | `spillwave/agent-memory` → `SpillwaveSolutions/agent-memory` (matches the git remote) | +| GitHub description / topics / Discussions | Cannot be set from a PR — repo settings. Listed as a maintainer action in the verification doc rather than claimed as done | +| Demo recording | Not produced. A real executed quickstart transcript is committed instead; an asciinema/GIF is left open and recorded as not done | + +## 57-02: Positioning writeup + +`docs/positioning/agent-memory-vs-competition.md`, leading with the three +structural differences (passive zero-token capture, local-first, cross-CLI), +head-to-head table vs Mem0 / Zep / MemMachine / Letta, an explicit "where they +are ahead of us" section, the platform-risk answer, and a claims ledger with +sources and check dates. + +**Benchmark gate honored:** the only committed results are mock-backend and +mock-judge, so the doc makes no comparative accuracy claim and says why. + +## 57-03: Scope trim — supported-surface tiering + +- **Tier 1 (PR gate):** Claude Code, Codex CLI +- **Tier 2 (weekly schedule):** Gemini CLI, Copilot CLI +- **Removed:** OpenCode. The converter's methods all returned empty, so + `memory-installer --agent opencode` reported success and wrote nothing. + Deleted: the converter, the `Runtime::OpenCode` variant, its tool mappings, + its bats suite, and the archived `plugins/memory-opencode-plugin/` stub. + A regression test asserts every runtime the installer offers actually + converts something. +- `e2e-cli.yml` becomes the Tier 1 gate; new `e2e-cli-tier2.yml` runs Tier 2 + weekly and on dispatch. Tiering ≠ removal: Tier 2 converters keep their + tests, they just do not block a PR. + +## Exit criteria + +1. GitHub landing page renders the new README +2. The README quickstart has been executed start-to-finish, verbatim, on a + machine that did not previously have the toolchain — transcript committed +3. `task pr-precheck` green diff --git a/docs/positioning/agent-memory-vs-competition.md b/docs/positioning/agent-memory-vs-competition.md new file mode 100644 index 0000000..3bc47aa --- /dev/null +++ b/docs/positioning/agent-memory-vs-competition.md @@ -0,0 +1,208 @@ +# Agent-Memory vs. the memory-layer field + +**Status:** current as of 2026-08-30. Competitor facts below are cited to +public sources and were checked on that date; re-verify before republishing +this as a post — this space moves monthly. + +**Rule for this document:** every claim about *our* system is either verifiable +in this repository today or is labelled as not-yet-true. That rule is why the +benchmark section says what it says. + +--- + +## The one-line difference + +Mem0, Zep, MemMachine, and Letta are memory layers you *call*. Agent-Memory is +a memory layer that *watches* — it sits beside the CLI, captures what actually +happened through hooks, and never asks the model to spend tokens deciding what +to remember. + +Everything else in this document follows from that. + +--- + +## Where we are structurally different + +Three dimensions where the difference is architectural rather than a matter of +tuning. These are the only three we lead with. + +### 1. Passive, zero-token capture + +Every hosted memory layer has the same capture contract: the agent decides +what is worth remembering and calls `add()` / `store()` / a memory tool. That +decision costs tokens on every turn, and it is skipped exactly when the context +window is under pressure — which is exactly when memory matters most. + +Agent-Memory's capture path is CLI hooks (`SessionStart`, `UserPromptSubmit`, +`PostToolUse`, `Stop`) piping events into `memory-ingest`. The agent is not in +the loop and pays nothing. This is also why our event log is +*ground-truth-preserving* by construction: we store what happened, and derive +summaries later, out of band. + +MemMachine independently arrived at the ground-truth-preserving half of this — +it stores raw episodes and minimises routine LLM extraction +([MemMachine paper](https://arxiv.org/html/2604.04853v1)). It still captures +through an API the agent calls. The zero-token half is ours. + +### 2. Local-first by default, not as a deployment option + +The event log (RocksDB), the BM25 index (Tantivy), the vector index (HNSW), +and the embedding model (Candle) all run on the developer's machine. Nothing +leaves it unless you opt into an LLM summarizer or LLM reranking and supply a +key. + +This is not "we also have a self-hosted tier". There is no service to phone +home to. For consultants under client NDAs, regulated teams, and anyone whose +conversation history is the sensitive artifact, that is a categorical +difference rather than a pricing one. + +### 3. Cross-CLI, because memory lives beside the CLI + +A single store is reachable from any runtime that can run a hook or pipe an +event. That is a direct answer to the portability gap in vendor-native memory: +Claude Code's Auto Memory is scoped to one project and does not travel to other +tools or repositories +([overview](https://www.mindstudio.ai/blog/claude-code-memory-levels-explained-6-layers-claude-md-cross-tool-shared-memory)). + +Our supported surface is deliberately narrower than it was — see +[README, "Supported surface"](../../README.md#supported-surface). Two Tier 1 +runtimes we actually gate on beats six runtimes we cannot maintain. + +--- + +## Head-to-head + +| Dimension | Agent-Memory | Mem0 | Zep (Graphiti) | MemMachine | Letta | +|---|---|---|---|---|---| +| **Memory model** | Append-only event log + time-hierarchical TOC (Year→…→Segment) + grips | Three-tier hierarchy (user/session/agent): vector + graph + key-value | Temporal knowledge graph with explicit fact-validity intervals | Working + episodic + profile memory over preserved raw episodes | OS-style virtual memory; context paged in and out | +| **Capture cost** | **Zero tokens** — CLI hooks, agent not involved | Agent calls the API | Agent calls the API | Agent calls the API / MCP | Agent synthesizes memory during the conversation | +| **What is stored** | Raw events, immutable; summaries derived out of band | Extracted facts | Extracted facts + relations, temporally scoped | Raw episodes, minimal routine extraction | Synthesized state | +| **Locality / privacy** | Local-first; no service; keys only for optional LLM steps | Hosted or self-host | Hosted or self-host | Self-hostable server | Self-hostable server | +| **Reach** | Cross-CLI, one store per machine | SDK-reachable from anything | SDK-reachable from anything | Python/TS SDK, REST, MCP | Agent framework | +| **Provenance** | Grips: every excerpt links to the source events | Source attribution | Graph edges carry provenance and validity | Ground-truth episodes retained | Varies | +| **Evolution over time** | Time hierarchy is the primary axis; navigate by *when* | Consolidation over facts | Fact validity intervals — the strongest temporal story in the field | Contextual expansion around matches | Paging, not history | + +Sources for the competitor columns: +[framework survey](https://www.graphlit.com/blog/survey-of-ai-agent-memory-frameworks), +[five-system comparison](https://medium.com/@wasowski.jarek/i-compared-5-ai-agent-memory-systems-across-6-dimensions-none-wins-6a658335ed0a), +[MemMachine](https://memmachine.ai/), +[Zep/Graphiti](https://www.graphlit.com/blog/survey-of-ai-agent-memory-frameworks). + +### Where they are ahead of us + +Stated plainly, because a comparison table that only flatters us is marketing: + +- **Zep's temporal knowledge graph** models fact *validity* — "this was true + between March and June". We model *when it was said*, which is a weaker + claim. If your question is "what is currently true about this customer", + Zep's model is the better fit. +- **Mem0 and MemMachine publish benchmark numbers we do not have.** See below. +- **Letta's paging model** solves a problem we do not attempt: keeping a + long-running agent coherent inside one very long task. +- **Everyone else has an SDK story.** We have a gRPC daemon, a CLI, and hooks. + If you are building a product rather than working in a terminal, they are + easier to adopt today. + +--- + +## Benchmarks: what we can and cannot say + +**We are not making a comparative accuracy claim.** Here is the whole basis +for that decision. + +MemMachine reports **0.9169 on LoCoMo** with `gpt-4.1-mini`, above published +Mem0, Zep, Memobase, LangMem, and OpenAI baselines +([paper](https://arxiv.org/pdf/2604.04853)). + +What this repository has committed, in `benchmarks/results/`: + +| Artifact | What it is | Why it is not a competitive number | +|---|---|---| +| `locomo-smoke.json` | 1 conversation, 4 questions, `metric = context_hit_rate`, `judge = mock`, score 0.5 | A mock judge on a 4-question fixture. It measures whether the harness works, not whether the memory is good. It is not LoCoMo and is not labelled LoCoMo | +| `custom-harness-mock.json` | 25 fixture tests, `backend = mock`, 22 passing | The backend is in-process token-overlap retrieval. Its own `caveats` field says it is not a production quality number | + +A run against a real backend with a real LLM judge has not been performed. +Until one is committed next to the claim, this document, the README, and the +repository make **no accuracy comparison to any of the systems above**. If that +run lands and the score is not competitive, the plan is to publish the +methodology and the number without comparison marketing — the local-first, +zero-token, cross-CLI argument does not depend on winning LoCoMo. + +We do have a real performance story with committed artifacts, and one retracted +claim: the "65 second TOC navigation" figure that circulated internally was a +harness defect — it timed ingest-time summarization rollup and labelled it +navigation. [docs/benchmarks.md](../benchmarks.md) has the full account. + +--- + +## The platform-risk question, head-on + +> "Anthropic and OpenAI are shipping native memory. Why would this survive?" + +It is the right question and it deserves the answer before someone posts it in +a comment thread. + +**What is true:** Claude Code has shipped Auto Memory on by default since +v2.1.59 (February 2026), and chat memory that carries summaries across +sessions reached all tiers in March 2026. Vendor-native memory is real, it is +free, and it is good enough for a large fraction of users. Anyone selling a +memory layer that competes on "the vendor has no memory" has already lost. + +**What is also true:** vendor memory is vendor-shaped by construction. + +1. **It is single-vendor.** What Claude Code learns in your repo is not + available to Codex, Gemini CLI, or Copilot. Every CLI you add starts from + zero. Developers running more than one agent — an increasingly normal + setup — get N disconnected memories. +2. **It is non-portable.** There is no export that another tool can consume. + Switching runtimes means abandoning history, which is a real switching cost + that benefits the vendor, not you. +3. **It is scoped to the vendor's unit of work.** Auto Memory is per-project; + insights captured in repo A stay in repo A. Cross-project questions — "have + I solved this auth problem before, anywhere?" — are outside its model. +4. **It is not yours.** It lives in the vendor's product boundary, on the + vendor's retention policy, on the vendor's roadmap. + +**So the framing is not "instead of".** The native layer deepens one tool; the +portable layer keeps every tool on the same page. Agent-Memory's bet is that +the cross-CLI, local, exportable layer is the part vendors are structurally +unlikely to build, because building it well means making their users easier to +leave. + +**The honest risk:** if a developer only ever uses one CLI and does not care +where their history lives, native memory is sufficient and we are not needed. +That is a real segment, and it is not our segment. + +--- + +## Who this is for + +- Developers running **more than one agent CLI** who are tired of re-explaining + the same decisions to each of them +- Anyone whose conversation history is **sensitive by default** — client work + under NDA, regulated environments, security research +- People who want memory that costs **nothing per turn**, because they have + watched an agent skip its own memory tool when the context filled up + +## Who this is not for + +- Single-CLI users happy with vendor-native memory +- Teams that need a hosted, multi-tenant, SLA-backed service today +- Product builders who need an SDK now — the surface here is a daemon, a CLI, + and hooks + +--- + +## Claims ledger + +Anything in this document that could go stale, with where to re-check it. + +| Claim | Source of truth | Last checked | +|---|---|---| +| MemMachine LoCoMo 0.9169 (`gpt-4.1-mini`) | [arXiv 2604.04853](https://arxiv.org/pdf/2604.04853) | 2026-08-30 | +| Zep models fact-validity intervals | [Graphlit survey](https://www.graphlit.com/blog/survey-of-ai-agent-memory-frameworks) | 2026-08-30 | +| Mem0 three-tier vector + graph + KV | [Graphlit survey](https://www.graphlit.com/blog/survey-of-ai-agent-memory-frameworks) | 2026-08-30 | +| Letta OS-style virtual memory paging | [five-system comparison](https://medium.com/@wasowski.jarek/i-compared-5-ai-agent-memory-systems-across-6-dimensions-none-wins-6a658335ed0a) | 2026-08-30 | +| Claude Code Auto Memory default-on, per-project | [Claude Code memory levels](https://www.mindstudio.ai/blog/claude-code-memory-levels-explained-6-layers-claude-md-cross-tool-shared-memory) | 2026-08-30 | +| Our own committed benchmark artifacts | `benchmarks/results/` in this repo | 2026-08-30 | +| Our own capability status | [README status table](../../README.md#status-what-is-solid-what-is-not) | 2026-08-30 | diff --git a/docs/setup/agent-setup.md b/docs/setup/agent-setup.md index 6b1950f..472025a 100644 --- a/docs/setup/agent-setup.md +++ b/docs/setup/agent-setup.md @@ -1,32 +1,61 @@ # Agent-Specific Setup Guides -Agent setup is intentionally separate from the core install flow. Pick the guide -that matches your tool and follow its steps to configure hooks or plugins. +Agent setup is intentionally separate from the core install flow. In every case +the runtime files are generated by `memory-installer` from the canonical sources +in `plugins/` -- run the install command for your runtime, then read its guide +for the runtime-specific details. -## Claude Code +Runtimes are tiered; see [Supported surface](../../README.md#supported-surface) +for what each tier promises. -Use the Claude Code plugin guide to set up hooks and query commands. +## Claude Code (Tier 1) + +```bash +memory-installer install --agent claude --project +``` - [Claude Code Plugin Guide](../../plugins/memory-query-plugin/README.md) -## OpenCode +## Codex CLI (Tier 1) -The OpenCode adapter provides native commands and skills. +```bash +memory-installer install --agent codex --project +``` -- [OpenCode Plugin Guide](../../plugins/memory-opencode-plugin/README.md) +- [Codex CLI Adapter Notes](../../adapters/codex-cli/README.md) +- [Codex Sandbox Workaround](../../adapters/codex-cli/SANDBOX-WORKAROUND.md) -## Gemini CLI +## Gemini CLI (Tier 2) -The Gemini adapter uses shell hooks and configuration files. +```bash +memory-installer install --agent gemini --project +``` - [Gemini CLI Adapter Guide](../../plugins/memory-gemini-adapter/README.md) -## Copilot CLI +## Copilot CLI (Tier 2) -The Copilot adapter provides hook-based capture for CLI sessions. +```bash +memory-installer install --agent copilot --project +``` - [Copilot CLI Adapter Guide](../../plugins/memory-copilot-adapter/README.md) +## OpenCode (not supported) + +The OpenCode converter was an empty stub -- it reported success and wrote no +files -- and was removed in Phase 57 rather than shipped. `--agent opencode` is +now rejected by the installer. + +OpenCode can still feed the store through the runtime-agnostic ingest path: + +```bash +memory-ingest --agent opencode < event.json +``` + +`memory-daemon clod convert --target opencode` also still emits OpenCode command +files from a CLOD definition, if you want to wire the commands by hand. + ## Notes - Complete core installation first (Quickstart or Full Guide) diff --git a/docs/verification/57-quickstart-transcript.md b/docs/verification/57-quickstart-transcript.md new file mode 100644 index 0000000..1a56488 --- /dev/null +++ b/docs/verification/57-quickstart-transcript.md @@ -0,0 +1,232 @@ +# Phase 57 quickstart execution transcript + +**Executed:** 2026-08-30 +**Machine:** Linux x86_64 container, fresh clone, **no Rust toolchain, no +`protoc`, no `libclang`, and no `~/.local/share/agent-memory` store** +**Method:** the root `README.md` "Quickstart (5 minutes)" section, run verbatim, +in order, with nothing added that the README does not tell you to run. + +This file is the execution evidence for the Phase 57 exit criterion. It is a +record of what happened, including the three things that did not work the first +time and what was changed as a result. + +--- + +## What the first run found + +The README was written first and then executed. Three defects surfaced, all of +them in the "documented happy path silently does nothing" family this milestone +exists to eliminate: + +### 1. `memory search` returned zero results for content just added + +```console +$ memory add --content "We chose RS256 over HS256 for the auth service JWTs" --agent claude +{"status":"ok","query":"add","results":{"created":true,"event_id":"01M1ABYF8RR4F8JCGGE2Z0V864"},...} + +$ memory search "which JWT signing algorithm did we pick" --top 5 +{"status":"ok","query":"...","results":[],"meta":{"retrieval_ms":0,"tokens_estimated":0,"confidence":0.0}} +``` + +No error, no warning, `confidence 0.0`. The daemon had said why at startup, in +an INFO line nobody reads: + +```text +INFO memory_daemon::commands: No BM25 index at ".../db/search"; RouteQuery will skip BM25 +WARN memory_daemon::commands: Indexing job not registered: Search index directory not found +INFO memory_daemon::commands: Run 'rebuild-indexes' to initialize the search index +``` + +A fresh store has no `search/` or `vector/` directory, and both the outbox +indexing job and the prune jobs only register when their directory *already* +exists. So a first-run daemon accepted events forever and answered every query +with nothing. + +**Fix:** `start_daemon` now creates `db/search` and `db/vector` before job +registration (`crates/memory-daemon/src/commands.rs`). + +### 2. The remedy the daemon printed did not work + +```console +$ memory-daemon admin rebuild-indexes +Error: RocksDB error: IO error: While lock file: .../db/LOCK: Resource temporarily unavailable + [12 frames of anyhow backtrace] +``` + +`rebuild-indexes` needs the RocksDB lock, which the running daemon holds. And +after stopping the daemon it reported `No documents found in storage to index.` +— because it indexes TOC nodes and grips, of which a fresh store has zero, +not raw events. + +**Fix:** made moot by fix 1 — the documented path no longer routes through +`rebuild-indexes` at all. + +### 3. `admin rebuild-toc` printed a TODO and exited 0 + +```console +$ memory-daemon admin rebuild-toc +Found 2 events to process + +TOC rebuild not yet fully implemented. +This would require re-running segmentation and summarization. +Events are intact and can be manually processed. +$ echo $? +0 +``` + +`--dry-run` even said "To actually rebuild, run without --dry-run", which was +false. Same class of defect as the `--background` flag Phase 54 fixed. + +**Fix:** it now fails loudly with guidance. + +```console +$ memory-daemon admin rebuild-toc +Found 2 events to process +Error: offline TOC rebuild is not implemented; TOC nodes are produced by the daemon's +scheduled rollup jobs (toc_rollup_day / _week / _month) -- run `memory-daemon start +--foreground` and check `memory-daemon scheduler status`. Your events are intact in +the event log. +$ echo $? +1 +``` + +--- + +## The verifying run (store wiped, README followed verbatim) + +`rm -rf ~/.local/share/agent-memory ~/.cache/agent-memory` first, so this is a +true first run. + +### Step 1 — Build + +```console +$ cargo build --release -p memory-daemon -p memory-ingest -p memory-cli + Finished `release` profile [optimized] target(s) in 3m 11s +``` + +The prerequisites line in the README is load-bearing: without +`protobuf-compiler` the build fails at `prost-build` with `Could not find +protoc`, and `rocksdb` needs `libclang-dev`. Both were installed by following +the README's prerequisite block, on a machine that had neither. + +### Step 2 — Start the daemon + +```console +$ memory-daemon start --foreground & +$ memory-daemon status +Memory daemon is running (PID 13963) +PID file: "/root/.cache/agent-memory/daemon.pid" +``` + +Daemon log, showing the fix from defect 1 taking effect on a fresh store: + +```text +INFO memory_daemon::commands: Created index directory "/root/.local/share/agent-memory/db/search" +INFO memory_daemon::commands: Created index directory "/root/.local/share/agent-memory/db/vector" +INFO memory_daemon::commands: BM25 searcher attached docs=0 +WARN memory_daemon::commands: Failed to load embedder for vector search error=Failed to + download model: ... Connection Failed: tls connection init failed +INFO memory_scheduler::jobs::indexing: Registered outbox indexing job +``` + +**Environment caveat:** this container's egress proxy blocks the Hugging Face +model download, so the embedder never loaded and **vector search was not +exercised**. The daemon warned and continued BM25-only, which is the documented +behaviour. Vector retrieval is verified by the workspace test suite, not by +this transcript. + +### Step 3 — Record something + +```console +$ memory add --content "We chose RS256 over HS256 for the auth service JWTs" --agent claude +{"status":"ok","query":"add","results":{"created":true,"event_id":"01M1AC7CSHMS9XN65TPACNBHDF"},"meta":{"retrieval_ms":0,"tokens_estimated":88,"confidence":1.0}} + +$ memory add --content "Rate limiting lives in the gateway, not the auth service" --agent claude +{"status":"ok","query":"add","results":{"created":true,"event_id":"01M1AC7CSQQ2Y2FCT5QVNDR378"},"meta":{"retrieval_ms":0,"tokens_estimated":92,"confidence":1.0}} +``` + +### Step 4 — Wait for the indexer + +```console +$ sleep 70 +``` + +Indexing is a scheduled outbox drain (`0 * * * * *`, up to 10s jitter), not a +synchronous write. Observed drain: + +```text +INFO memory_scheduler::scheduler: Job started job=outbox_indexing +INFO memory_scheduler::scheduler: Job completed job=outbox_indexing duration_ms=8911 +``` + +The README now states this instead of implying `add` then `search` is instant. + +### Step 5 — Ask for it back + +```console +$ memory search "which JWT signing algorithm did we pick" --top 5 +{"status":"ok","query":"which JWT signing algorithm did we pick","results":[{"agent":"claude", +"doc_id":"01M1AC7CSHMS9XN65TPACNBHDF","doc_type":"event","metadata":{"agent":"claude", +"memory_kind":"observation","timestamp_ms":"1788128506673"},"score":0.01270491722971201, +"source_layer":"bm25","text_preview":"We chose RS256 over HS256 for the auth service JWTs"}], +"meta":{"retrieval_ms":1,"tokens_estimated":88,"confidence":0.01270491722971201}} + +$ memory search "rate limiting gateway" --format json | jq '.results[0].text_preview' +"Rate limiting lives in the gateway, not the auth service" +``` + +`text_preview` being populated on an `doc_type: event` result is the Phase 54.5 +`TEXT | STORED` fix visible end-to-end: before it, event hits came back with +empty previews. + +**Retrieval caveat found and documented:** BM25 does not stem. + +```console +$ memory search "jwt" --format json +{"status":"ok","query":"jwt","results":[],...} + +$ memory search "JWTs" --format json | jq -r '.results[0].text_preview' +We chose RS256 over HS256 for the auth service JWTs +``` + +The README's second example was changed from `jwt` to a query that BM25 can +actually answer, and the status table now says exact-token, no stemming. + +### Step 6 — Wire it to your agent + +```console +$ memory-installer install --agent claude --project --dry-run +[DRY-RUN] CREATE .../.claude/plugins/memory-plugin/commands/memory-search.md 1631 bytes +[DRY-RUN] CREATE .../.claude/plugins/memory-plugin/commands/memory-recent.md 1626 bytes +[DRY-RUN] CREATE .../.claude/plugins/memory-plugin/commands/memory-context.md 2082 bytes +[DRY-RUN] CREATE .../.claude/plugins/memory-plugin/commands/memory-setup.md 15679 bytes +[DRY-RUN] CREATE .../.claude/plugins/memory-plugin/commands/memory-status.md 10940 bytes +[DRY-RUN] CREATE .../.claude/plugins/memory-plugin/commands/memory-config.md 12702 bytes +``` + +And the Phase 57 scope trim, verified at the CLI: + +```console +$ memory-installer install --agent opencode --project +error: invalid value 'opencode' for '--agent ' + [possible values: claude, gemini, codex, copilot, skills] +$ echo $? +2 +``` + +Previously this exited 0 and wrote no files. + +--- + +## What this transcript does *not* establish + +Stated explicitly, so nobody reads more into it than it supports: + +- **Vector search was not exercised** — the model download is blocked in this + environment. BM25 only. +- **No hooks were installed into a live agent.** Step 6 was run as `--dry-run`; + the hook-driven capture loop is covered by the bats suites in CI, not here. +- **This is not a performance measurement.** `retrieval_ms: 1` on a two-event + store is not a benchmark. See `docs/benchmarks.md`. +- **macOS was not tested.** The build prerequisites for macOS in the README are + taken from the CI workflow, which does run on `macos-latest`. diff --git a/plugins/memory-copilot-adapter/README.md b/plugins/memory-copilot-adapter/README.md index c762e68..4b3107f 100644 --- a/plugins/memory-copilot-adapter/README.md +++ b/plugins/memory-copilot-adapter/README.md @@ -8,7 +8,7 @@ plugin files from the canonical source. Install plugins for this runtime using the installer: ```bash -memory-installer --agent copilot --project +memory-installer install --agent copilot --project ``` See `crates/memory-installer/` for details. diff --git a/plugins/memory-gemini-adapter/README.md b/plugins/memory-gemini-adapter/README.md index 40fd4d8..d5e2401 100644 --- a/plugins/memory-gemini-adapter/README.md +++ b/plugins/memory-gemini-adapter/README.md @@ -8,7 +8,7 @@ plugin files from the canonical source. Install plugins for this runtime using the installer: ```bash -memory-installer --agent gemini --project +memory-installer install --agent gemini --project ``` See `crates/memory-installer/` for details. diff --git a/plugins/memory-opencode-plugin/README.md b/plugins/memory-opencode-plugin/README.md deleted file mode 100644 index ade3133..0000000 --- a/plugins/memory-opencode-plugin/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# Memory OpenCode Plugin (Archived) - -This adapter has been replaced by `memory-installer`, which generates runtime-specific -plugin files from the canonical source. - -## Migration - -Install plugins for this runtime using the installer: - -```bash -memory-installer --agent opencode --project -``` - -See `crates/memory-installer/` for details. - -## Note - -This directory is retained for one release cycle and will be removed in a future version. diff --git a/scripts/cli-matrix-report.sh b/scripts/cli-matrix-report.sh index 43e394f..299486c 100755 --- a/scripts/cli-matrix-report.sh +++ b/scripts/cli-matrix-report.sh @@ -2,13 +2,16 @@ set -euo pipefail # Cross-CLI Matrix Report Generator -# Parses JUnit XML reports from all 5 CLIs and produces a markdown summary table. -# Usage: cli-matrix-report.sh [junit-dir] +# Parses JUnit XML reports and produces a markdown summary table. +# Usage: cli-matrix-report.sh [junit-dir] [cli-list] # Local mode: reads $JUNIT_DIR/report-.xml # CI mode: reads $JUNIT_DIR/junit--*/report.xml +# +# The CLI list defaults to the Tier 1 supported surface (see README). Pass the +# list explicitly for a Tier 2 run, e.g. `cli-matrix-report.sh dir "gemini copilot"`. JUNIT_DIR="${1:-.}" -CLIS="claude-code gemini opencode copilot codex" +CLIS="${2:-claude-code codex}" python3 - "$JUNIT_DIR" "$CLIS" <<'PYEOF' import sys diff --git a/tests/cli/lib/cli_wrappers.bash b/tests/cli/lib/cli_wrappers.bash index a59c59b..58d113b 100644 --- a/tests/cli/lib/cli_wrappers.bash +++ b/tests/cli/lib/cli_wrappers.bash @@ -80,23 +80,6 @@ run_claude_with_hooks() { run_claude "$@" } -# --- OpenCode wrappers --- - -run_opencode() { - # Usage: run_opencode [extra args...] - # Wraps opencode CLI in headless mode with timeout and JSON output. - local test_stderr="${TEST_WORKSPACE:-/tmp}/opencode_stderr.log" - export TEST_STDERR="${test_stderr}" - - local cmd=("opencode" "run" "--format" "json" "$@") - - if [[ -n "${TIMEOUT_CMD}" ]]; then - "${TIMEOUT_CMD}" "${CLI_TIMEOUT}s" "${cmd[@]}" 2>"${test_stderr}" - else - "${cmd[@]}" 2>"${test_stderr}" - fi -} - # --- Codex wrappers --- run_codex() { diff --git a/tests/cli/opencode/hooks.bats b/tests/cli/opencode/hooks.bats deleted file mode 100644 index 641ecad..0000000 --- a/tests/cli/opencode/hooks.bats +++ /dev/null @@ -1,311 +0,0 @@ -#!/usr/bin/env bats -# OpenCode CLI hook capture tests -- all event types via direct CchEvent ingest + gRPC verification -# -# OpenCode uses a TypeScript plugin (memory-capture.ts), which cannot be invoked -# from shell. ALL tests use DIRECT CchEvent ingest via the ingest_event helper. -# -# Each test follows a two-layer proof pattern: -# Layer 1: ingest_event exits 0 and produces {"continue":true} -# Layer 2: gRPC query confirms the event was stored in the daemon -# -# sleep 2 between Layer 1 and Layer 2 for background ingest timing. -# -# OpenCode has only 5 event types (NO PreToolUse): -# SessionStart, UserPromptSubmit, PostToolUse, AssistantResponse, Stop -# -# Tests only need cargo-built binaries + daemon -- no OpenCode CLI required. - -load '../lib/common' -load '../lib/cli_wrappers' - -# Set at file scope so all tests can access it -FIXTURE_DIR="${PROJECT_ROOT}/tests/cli/fixtures/opencode" - -setup_file() { - build_daemon_if_needed - setup_workspace - start_daemon -} - -teardown_file() { - stop_daemon - teardown_workspace -} - -# Helper: rewrite session_id in fixture JSON, always compact single-line output. -# memory-ingest reads stdin line-by-line, so multi-line JSON silently fails. -rewrite_session_id() { - local fixture_file="$1" - local new_sid="$2" - - if command -v jq &>/dev/null; then - jq -c --arg sid "$new_sid" '.session_id = $sid' "$fixture_file" - else - # sed fallback: already single-line if fixture is compact; pipe through tr to strip newlines - sed "s/\"session_id\":[[:space:]]*\"[^\"]*\"/\"session_id\": \"${new_sid}\"/" "$fixture_file" | tr -d '\n' - fi -} - -# Helper: query all events in the daemon with a wide time window. -query_all_events() { - run grpc_query events --from 0 --to 9999999999999 --limit 1000 - echo "$output" -} - -# --- Test 1: SessionStart event is captured via direct ingest --- - -@test "hook: SessionStart event is captured via direct ingest (opencode)" { - local sid="test-opencode-sessionstart-$$" - local json - json="$(rewrite_session_id "${FIXTURE_DIR}/session-start.json" "$sid")" - - # Layer 1: Direct ingest via ingest_event helper - run ingest_event "$json" - - [[ "$status" -eq 0 ]] || { - echo "Expected exit 0 from ingest_event, got $status" - false - } - [[ "$output" == *'"continue":true'* ]] || [[ "$output" == *'"continue": true'* ]] || { - echo "Expected continue:true in output" - echo "Actual output: $output" - false - } - - # Wait for background ingest to complete - sleep 2 - - # Layer 2: Query gRPC and verify event was stored - local result - result="$(query_all_events)" - - [[ "$result" != *"No events found"* ]] || { - echo "Expected at least one event after SessionStart ingest" - echo "Query output: $result" - false - } -} - -# --- Test 2: UserPromptSubmit event captures message --- - -@test "hook: UserPromptSubmit event captures message (opencode)" { - local sid="test-opencode-userprompt-$$" - local json - json="$(rewrite_session_id "${FIXTURE_DIR}/user-prompt.json" "$sid")" - - # Layer 1 - run ingest_event "$json" - - [[ "$status" -eq 0 ]] || { - echo "Expected exit 0 from ingest_event, got $status" - false - } - [[ "$output" == *'"continue":true'* ]] || [[ "$output" == *'"continue": true'* ]] || { - echo "Expected continue:true in output" - echo "Actual output: $output" - false - } - - sleep 2 - - # Layer 2: Verify prompt content appears in query - local result - result="$(query_all_events)" - - [[ "$result" == *"project structure"* ]] || { - echo "Expected 'project structure' in gRPC query result" - echo "Query output: $result" - false - } -} - -# --- Test 3: AssistantResponse event captures response --- - -@test "hook: AssistantResponse event captures response (opencode)" { - local sid="test-opencode-assistantresponse-$$" - local json - json="$(rewrite_session_id "${FIXTURE_DIR}/assistant-response.json" "$sid")" - - # Layer 1 - run ingest_event "$json" - - [[ "$status" -eq 0 ]] || { - echo "Expected exit 0 from ingest_event, got $status" - false - } - [[ "$output" == *'"continue":true'* ]] || [[ "$output" == *'"continue": true'* ]] || { - echo "Expected continue:true in output" - echo "Actual output: $output" - false - } - - sleep 2 - - # Layer 2: Verify response content appears in query - local result - result="$(query_all_events)" - - [[ "$result" == *"src/ and tests/"* ]] || { - echo "Expected 'src/ and tests/' in gRPC query result" - echo "Query output: $result" - false - } -} - -# --- Test 4: PostToolUse event captures tool name --- - -@test "hook: PostToolUse event captures tool name (opencode)" { - local sid="test-opencode-posttooluse-$$" - local json - json="$(rewrite_session_id "${FIXTURE_DIR}/post-tool-use.json" "$sid")" - - # Layer 1 - run ingest_event "$json" - - [[ "$status" -eq 0 ]] || { - echo "Expected exit 0 from ingest_event, got $status" - false - } - [[ "$output" == *'"continue":true'* ]] || [[ "$output" == *'"continue": true'* ]] || { - echo "Expected continue:true in output" - echo "Actual output: $output" - false - } - - sleep 2 - - # Layer 2: Verify tool event was stored - local result - result="$(query_all_events)" - - [[ "$result" == *"tool:"* ]] || { - echo "Expected 'tool:' type in gRPC query result" - echo "Query output: $result" - false - } -} - -# --- Test 5: Stop event is captured --- - -@test "hook: Stop event is captured (opencode)" { - local sid="test-opencode-stop-$$" - local json - json="$(rewrite_session_id "${FIXTURE_DIR}/stop.json" "$sid")" - - # Layer 1 - run ingest_event "$json" - - [[ "$status" -eq 0 ]] || { - echo "Expected exit 0 from ingest_event, got $status" - false - } - [[ "$output" == *'"continue":true'* ]] || [[ "$output" == *'"continue": true'* ]] || { - echo "Expected continue:true in output" - echo "Actual output: $output" - false - } - - sleep 2 - - # Layer 2: Verify event was stored - local result - result="$(query_all_events)" - - [[ "$result" != *"No events found"* ]] || { - echo "Expected events after Stop ingest" - echo "Query output: $result" - false - } -} - -# --- Test 6: Multiple events in sequence maintain session coherence --- - -@test "hook: multiple events in sequence maintain session coherence (opencode)" { - local sid="test-opencode-sequence-$$" - - local json_start json_prompt json_tool json_response json_stop - json_start="$(rewrite_session_id "${FIXTURE_DIR}/session-start.json" "$sid")" - json_prompt="$(rewrite_session_id "${FIXTURE_DIR}/user-prompt.json" "$sid")" - json_tool="$(rewrite_session_id "${FIXTURE_DIR}/post-tool-use.json" "$sid")" - json_response="$(rewrite_session_id "${FIXTURE_DIR}/assistant-response.json" "$sid")" - json_stop="$(rewrite_session_id "${FIXTURE_DIR}/stop.json" "$sid")" - - # Layer 1: Ingest all 5 events via direct ingest - run ingest_event "$json_start" - [[ "$status" -eq 0 ]] || { echo "SessionStart ingest failed: $output"; false; } - [[ "$output" == *'"continue":true'* ]] || [[ "$output" == *'"continue": true'* ]] - - run ingest_event "$json_prompt" - [[ "$status" -eq 0 ]] || { echo "UserPromptSubmit ingest failed: $output"; false; } - [[ "$output" == *'"continue":true'* ]] || [[ "$output" == *'"continue": true'* ]] - - run ingest_event "$json_tool" - [[ "$status" -eq 0 ]] || { echo "PostToolUse ingest failed: $output"; false; } - [[ "$output" == *'"continue":true'* ]] || [[ "$output" == *'"continue": true'* ]] - - run ingest_event "$json_response" - [[ "$status" -eq 0 ]] || { echo "AssistantResponse ingest failed: $output"; false; } - [[ "$output" == *'"continue":true'* ]] || [[ "$output" == *'"continue": true'* ]] - - run ingest_event "$json_stop" - [[ "$status" -eq 0 ]] || { echo "Stop ingest failed: $output"; false; } - [[ "$output" == *'"continue":true'* ]] || [[ "$output" == *'"continue": true'* ]] - - sleep 3 - - # Layer 2: Verify prompt and response content appear - local result - result="$(query_all_events)" - - [[ "$result" == *"project structure"* ]] || { - echo "Expected 'project structure' from UserPromptSubmit in multi-event sequence" - echo "Query output: $result" - false - } - - [[ "$result" == *"src/ and tests/"* ]] || { - echo "Expected 'src/ and tests/' from AssistantResponse in multi-event sequence" - echo "Query output: $result" - false - } -} - -# --- Test 7: Agent field "opencode" is preserved through ingest --- - -@test "hook: agent field opencode is preserved through ingest (opencode)" { - local sid="test-opencode-agentfield-$$" - local json - json="$(rewrite_session_id "${FIXTURE_DIR}/session-start.json" "$sid")" - - # Verify fixture contains agent=opencode before ingest - [[ "$json" == *'"agent":"opencode"'* ]] || [[ "$json" == *'"agent": "opencode"'* ]] || { - echo "Fixture JSON missing agent=opencode field" - echo "JSON: $json" - false - } - - # Layer 1: Ingest with agent=opencode -- memory-ingest parses and forwards the agent field - run ingest_event "$json" - - [[ "$status" -eq 0 ]] || { - echo "Expected exit 0 from ingest_event, got $status" - false - } - [[ "$output" == *'"continue":true'* ]] || [[ "$output" == *'"continue": true'* ]] || { - echo "Expected continue:true in output" - echo "Actual output: $output" - false - } - - sleep 2 - - # Layer 2: Query gRPC to verify event was stored (agent field accepted by ingest pipeline) - local result - result="$(query_all_events)" - - [[ "$result" != *"No events found"* ]] || { - echo "Expected event stored after agent=opencode ingest" - echo "Query output: $result" - false - } -} diff --git a/tests/cli/opencode/negative.bats b/tests/cli/opencode/negative.bats deleted file mode 100644 index 9d9ed3a..0000000 --- a/tests/cli/opencode/negative.bats +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env bats -# OpenCode CLI negative tests -- daemon down, malformed input, fail-open behavior (OPEN-04). -# -# Tests memory-ingest fail-open ONLY (no hook script layer -- OpenCode uses TypeScript plugin). -# The assertion is always exit 0 with {"continue":true} for all failure modes. - -load '../lib/common' -load '../lib/cli_wrappers' - -# NOTE: Daemon is NOT started -- tests manage connectivity explicitly -setup_file() { - build_daemon_if_needed - setup_workspace - # Daemon is NOT started here -- tests that need it start/stop explicitly -} - -teardown_file() { - # Stop daemon if any test started one - stop_daemon 2>/dev/null || true - teardown_workspace -} - -# --- Fixture path --- - -FIXTURE_DIR="${BATS_TEST_DIRNAME}/../fixtures/opencode" - -# ========================================================================= -# memory-ingest fail-open tests (assert {"continue":true}) -# ========================================================================= - -# Test 1: memory-ingest with daemon down still returns continue:true -@test "negative: memory-ingest with daemon down still returns continue:true (opencode)" { - # Do NOT start daemon. Use an unused port to ensure no daemon is listening. - local unused_port=$(( (RANDOM % 10000) + 40000 )) - - run bash -c "echo '{\"hook_event_name\":\"SessionStart\",\"session_id\":\"neg-o1\",\"agent\":\"opencode\"}' | MEMORY_DAEMON_ADDR=\"http://127.0.0.1:${unused_port}\" '${MEMORY_INGEST_BIN}'" - [ "$status" -eq 0 ] - - # Output must contain {"continue":true} - [[ "$output" == *'{"continue":true}'* ]] || { - echo "Expected {\"continue\":true} but got: $output" - false - } -} - -# Test 2: memory-ingest with malformed JSON returns continue:true -@test "negative: memory-ingest with malformed JSON returns continue:true (opencode)" { - run bash -c "cat '${FIXTURE_DIR}/malformed.json' | '${MEMORY_INGEST_BIN}'" - [ "$status" -eq 0 ] - - [[ "$output" == *'{"continue":true}'* ]] || { - echo "Expected {\"continue\":true} for malformed JSON but got: $output" - false - } -} - -# Test 3: memory-ingest with empty stdin returns continue:true -@test "negative: memory-ingest with empty stdin returns continue:true (opencode)" { - run bash -c "echo '' | '${MEMORY_INGEST_BIN}'" - [ "$status" -eq 0 ] - - [[ "$output" == *'{"continue":true}'* ]] || { - echo "Expected {\"continue\":true} for empty stdin but got: $output" - false - } -} - -# Test 4: memory-ingest with unknown event type returns continue:true -@test "negative: memory-ingest with unknown event type returns continue:true (opencode)" { - run bash -c "echo '{\"hook_event_name\":\"UnknownEventType\",\"session_id\":\"neg-o4\",\"agent\":\"opencode\"}' | '${MEMORY_INGEST_BIN}'" - [ "$status" -eq 0 ] - - [[ "$output" == *'{"continue":true}'* ]] || { - echo "Expected {\"continue\":true} for unknown event type but got: $output" - false - } -} - -# ========================================================================= -# OpenCode-specific timeout/skip test -# ========================================================================= - -# Test 5: opencode headless timeout produces skip-friendly exit -@test "negative: opencode headless timeout produces skip-friendly exit (skip if not installed)" { - require_cli opencode "OpenCode" - - if [[ -z "${TIMEOUT_CMD}" ]]; then - skip "Skipping: no timeout command available (timeout/gtimeout)" - fi - - # Run opencode with a very short timeout -- we expect it to time out - # Exit codes 0 (completed fast), 124 (timeout), 137 (killed) are all acceptable - run "${TIMEOUT_CMD}" 2s opencode run --format json "echo test" 2>/dev/null - [[ "$status" -eq 0 || "$status" -eq 124 || "$status" -eq 137 ]] || { - echo "Expected exit 0, 124, or 137 but got: $status" - echo "Output: $output" - false - } -} diff --git a/tests/cli/opencode/pipeline.bats b/tests/cli/opencode/pipeline.bats deleted file mode 100644 index e4c324c..0000000 --- a/tests/cli/opencode/pipeline.bats +++ /dev/null @@ -1,231 +0,0 @@ -#!/usr/bin/env bats -# OpenCode CLI E2E pipeline tests -- full ingest -> query cycle (OPEN-03). -# -# Uses DIRECT CchEvent format with agent=opencode. -# OpenCode has 5 event types (no PreToolUse): -# SessionStart, UserPromptSubmit, PostToolUse, AssistantResponse, Stop. -# Uses OS-assigned random port for full workspace isolation. - -load '../lib/common' -load '../lib/cli_wrappers' - -setup_file() { - build_daemon_if_needed - setup_workspace - start_daemon -} - -teardown_file() { - stop_daemon - teardown_workspace -} - -# --- Helper: get current time in Unix ms --- - -_now_ms() { - # macOS date doesn't support %N, use python or perl fallback - if python3 -c "import time; print(int(time.time()*1000))" 2>/dev/null; then - return - fi - # Fallback: seconds * 1000 - echo "$(( $(date +%s) * 1000 ))" -} - -# --- Helper: ingest a full 5-event OpenCode session (direct CchEvent format) --- - -_ingest_full_session() { - local session_id="${1}" - local ts_base - ts_base="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - - # 1. SessionStart - ingest_event "{\"hook_event_name\":\"SessionStart\",\"session_id\":\"${session_id}\",\"agent\":\"opencode\",\"cwd\":\"/tmp/test\",\"timestamp\":\"${ts_base}\"}" >/dev/null - - # 2. UserPromptSubmit - ingest_event "{\"hook_event_name\":\"UserPromptSubmit\",\"session_id\":\"${session_id}\",\"message\":\"What is 2+2?\",\"agent\":\"opencode\",\"timestamp\":\"${ts_base}\"}" >/dev/null - - # 3. PostToolUse - ingest_event "{\"hook_event_name\":\"PostToolUse\",\"session_id\":\"${session_id}\",\"tool_name\":\"Read\",\"tool_input\":{\"path\":\"/test.rs\"},\"agent\":\"opencode\",\"timestamp\":\"${ts_base}\"}" >/dev/null - - # 4. AssistantResponse - ingest_event "{\"hook_event_name\":\"AssistantResponse\",\"session_id\":\"${session_id}\",\"message\":\"The answer is 4.\",\"agent\":\"opencode\",\"timestamp\":\"${ts_base}\"}" >/dev/null - - # 5. Stop - ingest_event "{\"hook_event_name\":\"Stop\",\"session_id\":\"${session_id}\",\"agent\":\"opencode\",\"timestamp\":\"${ts_base}\"}" >/dev/null -} - -# ========================================================================= -# Test 1: Complete session lifecycle via hook ingest -# ========================================================================= - -@test "pipeline: complete opencode session lifecycle via hook ingest" { - assert_daemon_running - - local session_id="opencode-pipeline-lifecycle-${RANDOM}" - - local time_before - time_before="$(_now_ms)" - - # Ingest full 5-event session - _ingest_full_session "${session_id}" - - # Allow time for async processing - sleep 2 - - local time_after - time_after="$(_now_ms)" - - # Query events in the time window - run grpc_query events --from "${time_before}" --to "${time_after}" - [ "$status" -eq 0 ] - - # Verify all 5 events were stored - [[ "$output" == *"5 found"* ]] || { - echo "Expected 5 events found in output" - echo "Query output: $output" - false - } - - # Verify event content: user prompt - [[ "$output" == *"What is 2+2?"* ]] || { - echo "Expected user prompt content in output" - echo "Query output: $output" - false - } - - # Verify event content: assistant response - [[ "$output" == *"The answer is 4."* ]] || { - echo "Expected assistant response content in output" - echo "Query output: $output" - false - } -} - -# ========================================================================= -# Test 2: Ingested events are queryable via TOC browse -# ========================================================================= - -@test "pipeline: opencode ingested events are queryable via TOC browse" { - assert_daemon_running - - # Query TOC root -- should succeed even if no TOC rollup has occurred - run grpc_query root - [ "$status" -eq 0 ] - - # The key assertion is that the gRPC query path is operational - [[ -n "$output" ]] -} - -# ========================================================================= -# Test 3: Events with cwd metadata are stored correctly -# ========================================================================= - -@test "pipeline: opencode events with cwd metadata are stored correctly" { - assert_daemon_running - - local session_id="opencode-pipeline-cwd-${RANDOM}" - - local time_before - time_before="$(_now_ms)" - - # Ingest event with specific cwd - ingest_event "{\"hook_event_name\":\"SessionStart\",\"session_id\":\"${session_id}\",\"agent\":\"opencode\",\"cwd\":\"/home/user/opencode-pipeline-test-project\"}" >/dev/null - - sleep 1 - - local time_after - time_after="$(_now_ms)" - - # Query events -- the event should be present - run grpc_query events --from "${time_before}" --to "${time_after}" - [ "$status" -eq 0 ] - - # Verify at least one event was returned - [[ "$output" == *"found"* ]] || { - echo "Expected events in query output after cwd ingest" - echo "Query output: $output" - false - } - - # Verify the query didn't return "No events found" - [[ "$output" != *"No events found"* ]] || { - echo "Expected events but got none after cwd ingest" - echo "Query output: $output" - false - } -} - -# ========================================================================= -# Test 4: OpenCode agent field is preserved through ingest -# ========================================================================= - -@test "pipeline: opencode agent field is preserved through ingest" { - assert_daemon_running - - local session_id="opencode-agent-field-${RANDOM}" - - ingest_event "{\"hook_event_name\":\"UserPromptSubmit\",\"session_id\":\"${session_id}\",\"message\":\"Hello from OpenCode pipeline\",\"agent\":\"opencode\"}" >/dev/null - - sleep 1 - - # Query all events (wide time window) - run grpc_query events --from 0 --to 9999999999999 - [ "$status" -eq 0 ] - - # Verify agent field or message content appears - [[ "$output" == *"opencode:"* ]] || [[ "$output" == *"Hello from OpenCode pipeline"* ]] || { - echo "Expected opencode agent field or message content in output" - echo "Query output: $output" - false - } -} - -# ========================================================================= -# Test 5: Concurrent sessions maintain isolation -# ========================================================================= - -@test "pipeline: opencode concurrent sessions maintain isolation" { - assert_daemon_running - - local msg_a="opencode-unique-marker-alpha-${RANDOM}" - local msg_b="opencode-unique-marker-beta-${RANDOM}" - - local time_before - time_before="$(_now_ms)" - - # Interleave events from two sessions (3 events each: Start, UserPrompt, Stop) - ingest_event "{\"hook_event_name\":\"SessionStart\",\"session_id\":\"opencode-iso-A-${RANDOM}\",\"agent\":\"opencode\"}" >/dev/null - ingest_event "{\"hook_event_name\":\"SessionStart\",\"session_id\":\"opencode-iso-B-${RANDOM}\",\"agent\":\"opencode\"}" >/dev/null - ingest_event "{\"hook_event_name\":\"UserPromptSubmit\",\"session_id\":\"opencode-iso-A\",\"message\":\"${msg_a}\",\"agent\":\"opencode\"}" >/dev/null - ingest_event "{\"hook_event_name\":\"UserPromptSubmit\",\"session_id\":\"opencode-iso-B\",\"message\":\"${msg_b}\",\"agent\":\"opencode\"}" >/dev/null - ingest_event "{\"hook_event_name\":\"Stop\",\"session_id\":\"opencode-iso-A\",\"agent\":\"opencode\"}" >/dev/null - ingest_event "{\"hook_event_name\":\"Stop\",\"session_id\":\"opencode-iso-B\",\"agent\":\"opencode\"}" >/dev/null - - sleep 2 - - local time_after - time_after="$(_now_ms)" - - # Query all events in time window - run grpc_query events --from "${time_before}" --to "${time_after}" - [ "$status" -eq 0 ] - - # Both session messages should appear in the output - [[ "$output" == *"${msg_a}"* ]] || { - echo "Expected message_a '${msg_a}' in output" - echo "Output: $output" - false - } - [[ "$output" == *"${msg_b}"* ]] || { - echo "Expected message_b '${msg_b}' in output" - echo "Output: $output" - false - } - - # Verify 6 events total (3 per session) - [[ "$output" == *"6 found"* ]] || { - echo "Expected 6 events for two concurrent sessions" - echo "Output: $output" - false - } -} diff --git a/tests/cli/opencode/smoke.bats b/tests/cli/opencode/smoke.bats deleted file mode 100644 index 3bc86c3..0000000 --- a/tests/cli/opencode/smoke.bats +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env bats -# OpenCode CLI smoke tests -- binary detection, basic ingest, daemon connectivity -# -# Tests 1-6: Always run (require only cargo-built binaries + daemon) -# Tests 7-8: Require opencode CLI binary (skip gracefully if not installed) - -load '../lib/common' -load '../lib/cli_wrappers' - -setup_file() { - build_daemon_if_needed - setup_workspace - start_daemon -} - -teardown_file() { - stop_daemon - teardown_workspace -} - -# --- Test 1: memory-daemon binary exists --- - -@test "memory-daemon binary exists and is executable" { - [ -f "$MEMORY_DAEMON_BIN" ] - [ -x "$MEMORY_DAEMON_BIN" ] -} - -# --- Test 2: memory-ingest binary exists --- - -@test "memory-ingest binary exists and is executable" { - [ -f "$MEMORY_INGEST_PATH" ] - [ -x "$MEMORY_INGEST_PATH" ] -} - -# --- Test 3: daemon is running and healthy --- - -@test "daemon is running and healthy" { - assert_daemon_running - daemon_health_check -} - -# --- Test 4: memory-capture.ts plugin file exists --- - -@test "memory-capture.ts plugin file exists" { - local plugin_file="${PROJECT_ROOT}/plugins/memory-opencode-plugin/.opencode/plugin/memory-capture.ts" - [ -f "$plugin_file" ] || { - echo "Plugin file not found at: $plugin_file" - false - } -} - -# --- Test 5: memory-ingest produces continue:true on valid CchEvent JSON --- - -@test "memory-ingest produces continue:true on valid CchEvent JSON" { - local json='{"hook_event_name":"SessionStart","session_id":"opencode-smoke-001","timestamp":"2026-02-26T10:00:00Z","cwd":"/tmp/test-workspace","agent":"opencode"}' - - run ingest_event "$json" - - [ "$status" -eq 0 ] || { - echo "Expected exit 0 from memory-ingest, got $status" - false - } - [[ "$output" == *'"continue":true'* ]] || [[ "$output" == *'"continue": true'* ]] || { - echo "Expected continue:true in output" - echo "Actual output: $output" - false - } -} - -# --- Test 6: memory-ingest produces continue:true on malformed JSON --- - -@test "memory-ingest produces continue:true on malformed JSON" { - local fixture_dir="${PROJECT_ROOT}/tests/cli/fixtures/opencode" - local json - json="$(cat "${fixture_dir}/malformed.json")" - - run ingest_event "$json" - - [ "$status" -eq 0 ] || { - echo "Expected exit 0 from memory-ingest on malformed input, got $status" - false - } - [[ "$output" == *'"continue":true'* ]] || [[ "$output" == *'"continue": true'* ]] || { - echo "Expected continue:true on malformed JSON (fail-open)" - echo "Actual output: $output" - false - } -} - -# --- Test 7: opencode binary detection works (skip if not installed) --- - -@test "opencode binary detection works (skip if not installed)" { - require_cli opencode "OpenCode" - - run opencode --version - [ "$status" -eq 0 ] -} - -# --- Test 8: opencode headless mode produces output (skip if not installed) --- - -@test "opencode headless mode produces output (skip if not installed)" { - require_cli opencode "OpenCode" - - run run_opencode "echo hello" - - # Timeout exits 124 or 137 -- known quirk of headless mode - if [[ "$status" -eq 124 ]] || [[ "$status" -eq 137 ]]; then - skip "OpenCode headless mode timed out (known quirk)" - fi - - [ "$status" -eq 0 ] || { - echo "Expected exit 0 from opencode headless, got $status" - echo "Output: $output" - false - } - [[ -n "$output" ]] || { - echo "Expected non-empty output from opencode headless mode" - false - } -}