diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ea100be..a494538 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -236,11 +236,63 @@ jobs:
print("smoke ok", d["metric"], "questions", d["total_questions"])
PY
+ bench-cli-smoke:
+ name: Benchmark CLI Isolation Smoke
+ runs-on: ubuntu-24.04
+ needs: [build]
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install system dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y protobuf-compiler libclang-dev
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@master
+ with:
+ toolchain: "1.97"
+
+ - name: Cache cargo registry
+ uses: Swatinem/rust-cache@v2
+ with:
+ shared-key: "bench-cli-smoke"
+
+ - name: Build daemon, CLI, and bench
+ run: cargo build -p memory-daemon -p memory-cli -p memory-bench
+
+ - name: Live-backend locomo smoke (spawn-per-conversation)
+ run: |
+ export PATH="$PWD/target/debug:$PATH"
+ cargo run -p memory-bench -- locomo \
+ --dataset benchmarks/fixtures/locomo-smoke.json \
+ --backend cli \
+ --scorer mock \
+ --isolation daemon-per-conversation \
+ --output /tmp/locomo-cli-smoke.json
+ python3 - <<'PY'
+ import json
+ d = json.load(open("/tmp/locomo-cli-smoke.json"))
+ assert d["conversations"] == 1, d
+ assert d["isolation"] == "per-conversation daemon", d
+ assert d["metric"] == "context_hit_rate", d
+ conv = d["per_conversation"][0]
+ assert "drain_wait_ms" in conv, conv
+ assert conv["drain_wait_ms"] >= 0
+ print("cli smoke ok", d["isolation"], "drain_wait_ms", conv["drain_wait_ms"])
+ PY
+
+ - name: Isolation bleed test
+ env:
+ MEMORY_BENCH_LIVE: "1"
+ run: |
+ export PATH="$PWD/target/debug:$PATH"
+ cargo test -p memory-bench cli_isolated_daemons_do_not_bleed -- --nocapture
# Summary job that depends on all other jobs
ci-success:
name: CI Success
- needs: [fmt, clippy, test, build, doc, e2e, benchmark-smoke, release-guards]
+ needs: [fmt, clippy, test, build, doc, e2e, benchmark-smoke, bench-cli-smoke, release-guards]
runs-on: ubuntu-24.04
if: always()
steps:
@@ -253,6 +305,7 @@ jobs:
[[ "${{ needs.doc.result }}" != "success" ]] || \
[[ "${{ needs.e2e.result }}" != "success" ]] || \
[[ "${{ needs.benchmark-smoke.result }}" != "success" ]] || \
+ [[ "${{ needs.bench-cli-smoke.result }}" != "success" ]] || \
[[ "${{ needs.release-guards.result }}" != "success" ]]; then
echo "One or more jobs failed"
exit 1
diff --git a/.planning/MILESTONES.md b/.planning/MILESTONES.md
index 0dee9c5..088e657 100644
--- a/.planning/MILESTONES.md
+++ b/.planning/MILESTONES.md
@@ -8,11 +8,12 @@ and a repo whose backlog is public.
**Spec:** `docs/plans/v3.2-prove-it-plan.md`
-**Phases:** 59 Guardrails and Inventory (executing), 60 Real Numbers, 61
-Operate It, 62 Cross-encoder rerank (conditional on #39).
+**Phases:** 59 Guardrails and Inventory (complete #45), 60 Real Numbers
+(60-01 executing), 61 Operate It, 62 Cross-encoder rerank (conditional on #39).
-**Known Gaps (now issues):** #39 LOCOMO run, #40 vector/topic quality, #41
-backfill, #42 install-service, #43 TOC rebuild, #44 cross-encoder.
+**Known Gaps (issues labelled `v3.2`):** #39 LOCOMO run, #40 vector quality,
+#47 topic quality, #41 backfill, #42 install-service, #43 TOC rebuild,
+#48 uninstall/status, #44 cross-encoder.
---
diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md
index 6dbaeb2..171aab6 100644
--- a/.planning/PROJECT.md
+++ b/.planning/PROJECT.md
@@ -15,9 +15,9 @@ them provable.
**Target work:**
- Release pipeline guards (tag on main, crate version matches tag, all five platforms) — Phase 59
- Committed LOCOMO LLM-judge result on the real dataset — Phase 60 / #39
-- Quality fixtures for vector search and the topic graph — Phase 60 / #40
+- Quality fixtures for vector search (#40) and the topic graph (#47) — Phase 60
- Backfill, `install-service`, offline TOC rebuild, panic audit — Phase 61 / #41 #42 #43
-- Claude Code plugin registration + installer uninstall/status — Phase 61
+- Claude Code plugin registration + installer uninstall/status — Phase 61 / #48
- Cross-encoder rerank only if 60-02 says retrieval is the bottleneck — Phase 62 / #44
**Previous version:** v3.1.0 (Shipped 2026-09-01) — Make It True. No new
@@ -37,8 +37,8 @@ The system implements a complete 6-layer cognitive stack with control plane, mul
- Layer 6: Ranking Policy (salience, usage, novelty, lifecycle) + StaleFilter (time-decay, supersession)
- Control: Retrieval Policy (intent routing, tier detection, fallbacks) + MemoryOrchestrator (RRF fusion, optional LLM rerank, explainability)
- Dedup: InFlightBuffer + HNSW composite gate, configurable threshold, fail-open
-- Installer: memory-installer crate with RuntimeConverter trait, 5 converters (Claude, Gemini, Codex, Copilot, generic skills), tool mapping tables
-- Adapters: Claude Code, Gemini CLI, Copilot CLI, Codex CLI (via installer). OpenCode removed in v3.1 Phase 57 — the converter reported success and wrote nothing
+- Installer: memory-installer crate with RuntimeConverter trait, converters for Claude, Gemini, Codex, Copilot, generic skills; tool mapping tables
+- Adapters: Claude Code, Gemini CLI, Copilot CLI, Codex CLI (via installer). Supported surfaces are those four; registration for Gemini/Codex/Copilot is v3.3+
- Discovery: ListAgents, GetAgentActivity, agent-filtered topics
- Testing: 1,205 workspace + 60 e2e cargo tests; 114 bats CLI tests; Tier 1 (Claude Code, Codex) gates PRs, Tier 2 (Gemini, Copilot) weekly
- CI/CD: Dedicated E2E job + CLI matrix report; rust-toolchain pinned to 1.97
diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md
index f64a156..9900896 100644
--- a/.planning/REQUIREMENTS.md
+++ b/.planning/REQUIREMENTS.md
@@ -1,6 +1,6 @@
-# Requirements: Agent Memory v3.0
+# Requirements: Agent Memory
-**Defined:** 2026-03-22
+**Defined:** 2026-03-22 (v3.0); extended 2026-09-01 (v3.2)
**Core Value:** Agent can answer "what were we talking about last week?" without scanning everything
## v3.0 Requirements
@@ -42,59 +42,105 @@ Requirements for the Competitive Parity & Benchmarks milestone. Each maps to roa
- [x] **BENCH-07**: CI runs benchmark suite (non-blocking, skips LOCOMO without `--dataset` flag)
- [x] **BENCH-08**: JSON + markdown report output for all benchmark types
-## Future Requirements (v3.1+)
+## v3.2 Requirements (Prove It)
-- **ORCH-F01**: Cross-encoder reranking (requires new inference path in memory-embeddings)
+Requirements for making v3.1's claims provable and operable. Each maps to
+exactly one plan in `docs/plans/v3.2-prove-it-plan.md`.
+
+### Release pipeline (REL)
+
+- [x] **REL-01**: Tagged commit must be an ancestor of `origin/main` (59-01)
+- [x] **REL-02**: `workspace.package.version` must equal the tag minus `v` (59-01)
+- [x] **REL-03**: Any failed platform build → no GitHub release (59-01)
+- [x] **REL-04**: Release body is the matching CHANGELOG section (59-01)
+
+### Benchmarks (BENCH)
+
+- [x] **BENCH-10**: Per-conversation isolation on `--backend cli` (60-01)
+- [x] **BENCH-11**: Deterministic drain wait (poll checkpoints, no blind sleep) (60-01)
+- [ ] **BENCH-12**: Committed `locomo_llm_judge` full-dataset result (60-02 / #39)
+- [ ] **BENCH-13**: Layer switch `bm25|vector|hybrid` on the custom harness (60-03)
+
+### Quality evidence (QUAL)
+
+- [ ] **QUAL-01**: Semantic fixture set, ≥15 tests (60-03 / #40)
+- [ ] **QUAL-02**: Topic clustering purity + ARI artifact (60-03 / #47)
+- [ ] **QUAL-03**: README "Solid" rows cite committed artifacts (60-03)
+
+### Operate it (OPS)
+
+- [ ] **OPS-01**: `admin backfill-index` resumable, idempotent (61-01 / #41)
+- [ ] **OPS-02**: `install-service`/`uninstall-service` macOS+Linux (61-02 / #42)
+- [ ] **OPS-03**: Zero fallible `unwrap` on request paths (61-03)
+- [ ] **OPS-04**: Hostile-input e2e over all RPCs (61-03)
+- [ ] **OPS-05**: `admin rebuild-toc` real (61-04 / #43)
+
+### Installer (INST)
+
+- [ ] **INST-01**: Claude Code plugin registration (CREG/META) (61-05)
+- [ ] **INST-02**: `memory-installer uninstall` (61-05 / #48)
+- [ ] **INST-03**: `memory-installer status` (61-05 / #48)
+
+## Future Requirements (v3.3+)
+
+- **ORCH-F01**: Cross-encoder reranking — Phase 62 *if* 60-02 shows retrieval is the bottleneck (#44)
- **CLI-F01**: REST/HTTP endpoint wrapping CLI commands
- **CLI-F02**: Python SDK wrapping CLI binary
- **BENCH-F01**: Continuous benchmark regression tracking in CI
+- **REG-F01**: Gemini/Codex/Copilot plugin registration
+- **INST-F01**: `--for all` / `--all` installer flags
+- **OPS-F01**: Windows service install; true double-fork daemonization
## Out of Scope
| Feature | Reason |
|---------|--------|
-| REST/HTTP endpoint | Future milestone — CLI-first for v3.0 |
+| REST/HTTP endpoint | Future milestone — CLI-first |
| Python SDK | Future milestone — wraps CLI |
| Memory views UI | Future milestone |
-| Cross-encoder reranking | Requires new inference path in memory-embeddings; extension point only |
+| Cross-encoder reranking | Conditional Phase 62; extension point only until then |
| Multi-agent shared memory changes | Shipped in v2.1 |
## Traceability
+### v3.0 (complete)
+
| Requirement | Phase | Status |
|-------------|-------|--------|
-| ORCH-01 | Phase 51 | Complete |
-| ORCH-02 | Phase 51 | Complete |
-| ORCH-03 | Phase 51 | Complete |
-| ORCH-04 | Phase 51 | Complete |
-| ORCH-05 | Phase 51 | Complete |
-| ORCH-06 | Phase 51 | Complete |
-| ORCH-07 | Phase 51 | Complete |
-| ORCH-08 | Phase 51 | Complete |
-| CLI-01 | Phase 52 | Complete |
-| CLI-02 | Phase 52 | Complete |
-| CLI-03 | Phase 52 | Complete |
-| CLI-04 | Phase 52 | Complete |
-| CLI-05 | Phase 52 | Complete |
-| CLI-06 | Phase 52 | Complete |
-| CLI-07 | Phase 52 | Complete |
-| CLI-08 | Phase 52 | Complete |
-| CLI-09 | Phase 52 | Complete |
-| CLI-10 | Phase 52 | Complete |
-| BENCH-01 | Phase 53 | Complete |
-| BENCH-02 | Phase 53 | Complete |
-| BENCH-03 | Phase 53 | Complete |
-| BENCH-04 | Phase 53 | Complete |
-| BENCH-05 | Phase 53 | Complete |
-| BENCH-06 | Phase 53 | Complete |
-| BENCH-07 | Phase 53 | Complete |
-| BENCH-08 | Phase 53 | Complete |
+| ORCH-01..08 | Phase 51 | Complete |
+| CLI-01..10 | Phase 52 | Complete |
+| BENCH-01..08 | Phase 53 | Complete |
+
+### v3.2
+
+| Requirement | Plan | Status |
+|-------------|------|--------|
+| REL-01 | 59-01 | Complete (#45) |
+| REL-02 | 59-01 | Complete (#45) |
+| REL-03 | 59-01 | Complete (#45) |
+| REL-04 | 59-01 | Complete (#45) |
+| BENCH-10 | 60-01 | In progress |
+| BENCH-11 | 60-01 | In progress |
+| BENCH-12 | 60-02 | Open (#39) |
+| BENCH-13 | 60-03 | Open (#40) |
+| QUAL-01 | 60-03 | Open (#40) |
+| QUAL-02 | 60-03 | Open (#47) |
+| QUAL-03 | 60-03 | Open |
+| OPS-01 | 61-01 | Open (#41) |
+| OPS-02 | 61-02 | Open (#42) |
+| OPS-03 | 61-03 | Open |
+| OPS-04 | 61-03 | Open |
+| OPS-05 | 61-04 | Open (#43) |
+| INST-01 | 61-05 | Open |
+| INST-02 | 61-05 | Open (#48) |
+| INST-03 | 61-05 | Open (#48) |
**Coverage:**
-- v3.0 requirements: 26 total
-- Mapped to phases: 26
+- v3.0 requirements: 26 total, all complete
+- v3.2 requirements: 19 total, 4 complete (REL), 15 open
- Unmapped: 0 ✓
---
*Requirements defined: 2026-03-22*
-*Last updated: 2026-03-22 after spec review*
+*Last updated: 2026-09-01 — v3.2 IDs added from the adopted Prove It plan*
+
diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 27abccd..aa56a07 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -13,7 +13,7 @@
- ✅ **v2.7 Multi-Runtime Portability** — Phases 45-50 (shipped 2026-03-22)
- **v3.0 Competitive Parity & Benchmarks** — Phases 51-53 + Phase 51.5 (shipped 2026-05-14; Phase 53 merged as #30)
- ✅ **v3.1 Make It True** — Phases 54-58 (shipped 2026-09-01 as v3.1.0)
-- **v3.2 Prove It** — Phases 59-62 (in progress; Phase 59 executing 2026-09-01)
+- **v3.2 Prove It** — Phases 59-62 (Phase 59 complete 2026-09-01; Phase 60 executing)
## Phases
@@ -230,7 +230,7 @@ Phases execute in numeric order: 51 -> 51.5 (merged out-of-band) -> 52 -> 53
| v2.7 Multi-Runtime Portability | 45-50 | 11/11 | Complete | 2026-03-22 |
| v3.0 Competitive Parity | 51-53 + 51.5, 53.5 | 10/10 | Complete | 2026-05-14 |
| v3.1 Make It True | 54-58 | 14/14 | Complete | 2026-09-01 |
-| v3.2 Prove It | 59-62 | 3/13 | In progress | Phase 59 executing |
+| v3.2 Prove It | 59-62 | 3/13 | In progress | Phase 59 complete; 60-01 executing |
---
@@ -315,17 +315,17 @@ v3.1 made the claims true. v3.2 makes them provable: a real benchmark number,
evidence behind every "Solid", and a daemon someone can run for a week.
Nothing on this list is a new capability except Phase 62, which is conditional.
-### Phase 59: Guardrails and Inventory (3/3 plans) — IN EXECUTION 2026-09-01
+### Phase 59: Guardrails and Inventory (3/3 plans) — COMPLETE 2026-09-01 (#45)
- [x] 59-01: Release pipeline checks (ancestor of main, crate version, all five platforms, CHANGELOG notes)
-- [x] 59-02: Orphan branch triage (`docs/plans/phase-59-orphan-branch-triage.md`)
-- [x] 59-03: Planning truth (PROJECT.md, ROADMAP, STATE, GitHub issues #39–#44)
+- [x] 59-02: Orphan branch triage (`docs/plans/phase-59-orphan-branch-triage.md`); OpenCode branch deleted
+- [x] 59-03: Planning truth (PROJECT.md, ROADMAP, STATE, 8 GitHub issues labelled `v3.2`)
-### Phase 60: Real Numbers (0/3)
+### Phase 60: Real Numbers (0/3) — 60-01 executing
-- [ ] 60-01: Live-backend isolation for `memory-bench locomo --backend cli`
+- [ ] 60-01: Live-backend isolation for `memory-bench locomo --backend cli` (this PR)
- [ ] 60-02: The run — maintainer, needs API key + documented machine (#39)
-- [ ] 60-03: Vector and topic quality fixtures (#40)
+- [ ] 60-03: Vector (#40) and topic (#47) quality fixtures
### Phase 61: Operate It (0/5)
@@ -333,11 +333,11 @@ Nothing on this list is a new capability except Phase 62, which is conditional.
- [ ] 61-02: `install-service` launchd/systemd (#42)
- [ ] 61-03: Panic audit (`unwrap()`/`expect()` on request paths)
- [ ] 61-04: Offline TOC rebuild (#43)
-- [ ] 61-05: Installer register / uninstall / status (CREG/META from 59-02)
+- [ ] 61-05: Installer register / uninstall / status (CREG/META from 59-02; #48)
### Phase 62: Cross-encoder rerank (conditional) (#44)
- [ ] Only if 60-02 shows retrieval, not generation, is the bottleneck
-*Updated: 2026-09-01 — v3.1.0 released; v3.2 Prove It adopted; Phase 59 executing*
+*Updated: 2026-09-02 — Phase 59 complete (#45); expanded v3.2 plan adopted; 60-01 executing*
diff --git a/.planning/STATE.md b/.planning/STATE.md
index bb194ec..c9b258f 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -3,11 +3,11 @@ gsd_state_version: 1.0
milestone_name: Prove It
status: executing
stopped_at: null
-last_updated: "2026-09-01T23:30:00.000Z"
-last_activity: 2026-09-01 — v3.1.0 released; v3.2 adopted; Phase 59 Guardrails and Inventory executing
+last_updated: "2026-09-02T01:40:00.000Z"
+last_activity: 2026-09-02 — Phase 60-01 live-backend isolation verified (spawn-per-conversation, checkpoint drain, CLI smoke)
progress:
total_phases: 4
- completed_phases: 0
+ completed_phases: 1
total_plans: 13
completed_plans: 3
percent: 23
@@ -20,15 +20,15 @@ progress:
See: .planning/PROJECT.md (updated 2026-09-01)
**Core value:** Agent can answer "what were we talking about last week?" without scanning everything
-**Current focus:** v3.2 Phase 59 — Guardrails and Inventory. Release pipeline cannot repeat the stale-tag incident; March gsd/ line inventoried; planning docs match v3.1.0 shipped.
+**Current focus:** v3.2 Phase 60-01 live-backend isolation ready to merge. Phase 59 Guardrails and Inventory is on `main` (#45).
## Current Position
-Phase: 59 of 62 (Guardrails and Inventory)
-Status: v3.1.0 shipped 2026-09-01 (5 of 5 platforms). v3.2 Prove It adopted. Phase 59 in execution.
-Last activity: 2026-09-01 — issues #39–#44 opened; release guards + orphan triage + PROJECT.md rewrite
+Phase: 60 of 62 (Real Numbers) — plan 60-01 verified locally
+Status: v3.1.0 shipped 2026-09-01 (5 of 5 platforms). v3.2 Prove It adopted (expanded spec). Phase 59 complete.
+Last activity: 2026-09-02 — 60-01 spawn-per-conversation + GetIndexCheckpoints drain + live CLI smoke + isolation bleed test
-Progress: [██░░░░░░░░] 3/13 plans (Phase 59). Phases 60–62 not started.
+Progress: [███░░░░░░░] 3/13 plans (Phase 59 complete). Phase 60-01 verified, awaiting merge.
## Out-of-band Work
@@ -36,23 +36,26 @@ Progress: [██░░░░░░░░] 3/13 plans (Phase 59). Phases 60–62
| PR | What | Status |
|---|---|---|
-| _(this branch)_ | Phase 59 Guardrails and Inventory | Open |
+| _(this branch)_ | Phase 60-01 live-backend isolation + remaining 59 gaps | Open |
### Open issues (the v3.2 backlog)
| Issue | What | Phase |
|---|---|---|
| #39 | Real LOCOMO LLM-judge run | 60-02 |
-| #40 | Vector and topic-graph quality fixtures | 60-03 |
+| #40 | Vector quality fixtures | 60-03 / QUAL-01 |
+| #47 | Topic-graph quality (purity + ARI) | 60-03 / QUAL-02 |
| #41 | Backfill BM25/vector for pre-v3.1 events | 61-01 |
| #42 | `install-service` (launchd/systemd) | 61-02 |
| #43 | Offline TOC rebuild | 61-04 |
+| #48 | Installer uninstall + status | 61-05 |
| #44 | Cross-encoder rerank (conditional) | 62 |
### Recently Merged
| PR | What | Merged |
|---|---|---|
+| #45 | Phase 59 Guardrails and Inventory | 2026-09-01 |
| #38 | docs: correct the "no tags" claim and record the release blocker | 2026-08-31 |
| #37 | chore(v3.1): release prep — version 3.1.0, changelog, working release archives | 2026-08-31 |
| #36 | Phase 57 Shop Window & Positioning | 2026-08-31 |
@@ -65,7 +68,8 @@ Progress: [██░░░░░░░░] 3/13 plans (Phase 59). Phases 60–62
## Decisions
- v3.2 scope: Prove It — no new capabilities except conditional Phase 62
-- Maintainer decisions 2026-09-01: cherry-pick March export/import + CREG/META by feature (not by branch); skip OpenCode converter; blog now / product posts after #39; daemonization is unit files not double-fork
+- Maintainer decisions 2026-09-01 (all four accepted): cherry-pick March export/import + CREG/META by feature; skip OpenCode (branch deleted); blog now / product posts after #39; daemonization is unit files not double-fork; backfill is stopped-daemon CLI only
+- Canonical spec: `docs/plans/v3.2-prove-it-plan.md` (expanded GSD form)
- v3.1.0 first tag push shipped `acc7294` (Cargo.toml 2.7.0) for 17 minutes — Phase 59-01 exists because of that
- March `gsd/phase-56-import-bootstrap` is 88 ahead / 20 behind; naïve merge regresses orchestrator and bench. Inventory: `docs/plans/phase-59-orphan-branch-triage.md`
- HOLD comparison marketing until #39 lands a `locomo_llm_judge` artifact
diff --git a/.planning/phases/60-real-numbers/60-01-PLAN.md b/.planning/phases/60-real-numbers/60-01-PLAN.md
new file mode 100644
index 0000000..a9e9f9f
--- /dev/null
+++ b/.planning/phases/60-real-numbers/60-01-PLAN.md
@@ -0,0 +1,102 @@
+---
+phase: 60-real-numbers
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - proto/memory.proto
+ - crates/memory-storage/src/db.rs
+ - crates/memory-service/src/ingest.rs
+ - crates/memory-client/src/client.rs
+ - crates/memory-client/src/lib.rs
+ - crates/memory-daemon/src/cli.rs
+ - crates/memory-daemon/src/commands.rs
+ - crates/memory-daemon/src/main.rs
+ - crates/memory-daemon/Cargo.toml
+ - crates/memory-bench/src/cli.rs
+ - crates/memory-bench/src/runner.rs
+ - crates/memory-bench/src/main.rs
+ - crates/memory-search/src/searcher.rs
+ - crates/memory-indexing/src/pipeline.rs
+ - .github/workflows/ci.yml
+ - docs/benchmarks.md
+autonomous: true
+requirements: [BENCH-10, BENCH-11]
+must_haves:
+ truths:
+ - "`memory-bench locomo --backend cli` defaults to daemon-per-conversation isolation"
+ - "Two isolated daemons do not retrieve each other's turns"
+ - "Drain wait polls GetIndexCheckpoints; no std::thread::sleep in the cli-backend path"
+ - "Result JSON records isolation and per-conversation drain_wait_ms"
+ - "CI job bench-cli-smoke runs the 1-conversation fixture against a spawned daemon"
+ artifacts:
+ - path: proto/memory.proto
+ provides: GetIndexCheckpoints RPC
+ contains: rpc GetIndexCheckpoints
+ - path: crates/memory-bench/src/runner.rs
+ provides: IsolatedDaemon + wait_for_drain
+ contains: DaemonPerConversation
+ - path: .github/workflows/ci.yml
+ provides: bench-cli-smoke job
+ contains: bench-cli-smoke
+---
+
+
+Live-backend LOCOMO isolation: spawn one daemon per conversation, poll GetIndexCheckpoints instead of sleeping, record drain_wait_ms.
+
+
+
+
+
+ Task 1: GetIndexCheckpoints RPC
+ proto/memory.proto, crates/memory-storage/src/db.rs, crates/memory-service/src/ingest.rs, crates/memory-client/src/client.rs, crates/memory-client/src/lib.rs
+
+ Add read-only GetIndexCheckpoints RPC. Storage exposes outbox_head(). Service reads index_bm25 / index_vector / index_combined checkpoints plus the outbox head. Client wraps the RPC. Missing checkpoints are omitted (fresh store is empty).
+
+
+ cargo test -p memory-service --lib ingest -- --test-threads=1
+ cargo test -p memory-storage --lib test_outbox_head
+
+ Empty store returns outbox_head=0 and no checkpoints; ingest + put_checkpoint("index_bm25") is visible via the RPC.
+
+
+
+ Task 2: Daemon pid-file + query checkpoints
+ crates/memory-daemon/src/cli.rs, crates/memory-daemon/src/commands.rs, crates/memory-daemon/src/main.rs, crates/memory-daemon/Cargo.toml
+
+ Add --pid-file on start/stop so spawned daemons do not clobber a user's PID file. Add `memory-daemon query checkpoints` that prints GetIndexCheckpoints as JSON {checkpoints, outbox_head}.
+
+
+ cargo test -p memory-daemon --lib test_cli_query_checkpoints test_cli_start_pid_file test_resolve_pid_file_override
+
+ CLI parses `query checkpoints` and `--pid-file`; JSON shape matches the bench parser.
+
+
+
+ Task 3: IsolatedDaemon + drain poll
+ crates/memory-bench/src/runner.rs, crates/memory-bench/src/cli.rs, crates/memory-bench/src/main.rs, crates/memory-bench/src/locomo.rs
+
+ Isolation::{Shared, DaemonPerConversation}. Default for --backend cli is daemon-per-conversation. IsolatedDaemon: tempdir + free port + pid-file + health poll via query checkpoints. wait_for_drain polls until BM25 last_sequence >= outbox_head-1 (vector only if that checkpoint exists). poll_pause uses mpsc::recv_timeout, not thread::sleep. Record drain_wait_ms per conversation. --limit-questions for 60-02 dry-run.
+
+
+ cargo test -p memory-bench --lib
+ MEMORY_BENCH_LIVE=1 cargo test -p memory-bench cli_isolated_daemons_do_not_bleed -- --nocapture
+
+ Unit tests cover drain_caught_up, parse_checkpoint_json, isolation labels, and (with MEMORY_BENCH_LIVE=1) two daemons do not bleed.
+
+
+
+ Task 4: CI smoke + docs
+ .github/workflows/ci.yml, docs/benchmarks.md
+
+ Add Linux-only bench-cli-smoke after build: locomo --backend cli --scorer mock --isolation daemon-per-conversation against locomo-smoke.json; assert isolation label and drain_wait_ms. Document spawn-per-conversation and the drain poll in docs/benchmarks.md.
+
+
+ grep -n bench-cli-smoke .github/workflows/ci.yml
+ grep -n "per-conversation daemon" docs/benchmarks.md
+
+ CI job exists; docs describe isolation, drain poll, and the smoke command.
+
+
+
diff --git a/.planning/phases/60-real-numbers/60-CONTEXT.md b/.planning/phases/60-real-numbers/60-CONTEXT.md
new file mode 100644
index 0000000..12d6435
--- /dev/null
+++ b/.planning/phases/60-real-numbers/60-CONTEXT.md
@@ -0,0 +1,23 @@
+# Phase 60: Real Numbers - Context
+
+**Gathered:** 2026-09-02
+**Status:** 60-01 in execution
+**Source:** docs/plans/v3.2-prove-it-plan.md
+
+## Phase Boundary
+
+A committed `locomo_llm_judge` number and quality artifacts behind every
+"Solid". 60-01 is the harness; 60-02 is the maintainer run; 60-03 is
+vector/topic fixtures.
+
+## 60-01 decisions
+
+- Design A: spawn-per-conversation. Design B (AdminReset) rejected.
+- `--isolation daemon-per-conversation` is the default for `--backend cli`.
+- `--pid-file` on daemon start/stop so spawned daemons do not clobber a
+ user's PID file.
+- GetIndexCheckpoints is read-only. Drain waits on BM25; vector is required
+ only when that checkpoint exists (the outbox pipeline currently registers
+ BM25 only).
+- Poll interval is `mpsc::recv_timeout`, not `std::thread::sleep`.
+- `--limit-questions` exists so 60-02 can dry-run.
diff --git a/.planning/phases/60-real-numbers/60-VERIFICATION.md b/.planning/phases/60-real-numbers/60-VERIFICATION.md
new file mode 100644
index 0000000..2d91ac1
--- /dev/null
+++ b/.planning/phases/60-real-numbers/60-VERIFICATION.md
@@ -0,0 +1,20 @@
+---
+phase: 60-real-numbers
+verified: 2026-09-02
+status: 60-01-verified
+---
+
+# Phase 60: Real Numbers Verification
+
+## 60-01 (this PR)
+
+| # | Truth | Status | Evidence |
+|---|-------|--------|----------|
+| 1 | `--backend cli` isolation is per-conversation daemon | UNIT | `isolation_default_label`; locomo result `isolation` field |
+| 2 | Two isolated daemons do not bleed | RUN | `cli_isolated_daemons_do_not_bleed` under `MEMORY_BENCH_LIVE=1` |
+| 3 | Drain wait polls checkpoints | UNIT | `drain_caught_up_*`; `parse_checkpoint_json_roundtrip` |
+| 4 | No `std::thread::sleep` in cli-backend path | CODE | `poll_pause` uses `recv_timeout` |
+| 5 | CI `bench-cli-smoke` | CI | `.github/workflows/ci.yml` job after `build` |
+| 6 | `drain_wait_ms` per conversation | CODE | `LocomoConversationResult.drain_wait_ms` |
+| 7 | Query reader sees indexer commits | CODE | `TeleportSearcher::search` calls `reload()` |
+| 8 | Sequence 0 advances BM25 checkpoint | UNIT | `test_process_batch_sequence_zero_advances_checkpoint` |
diff --git a/CLAUDE.md b/CLAUDE.md
index 6dfd468..fb2c3f6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -93,20 +93,22 @@ GitHub Actions workflows in `.github/workflows/`:
### Release Process
-```bash
-# Bump version
-cargo set-version 0.2.0
+Do **not** tag `HEAD` of a local branch. Always tag an explicit SHA that is
+already on `origin/main`. The full procedure, what the pipeline refuses, and
+the zsh quoting trap (`^{commit}`, `#`) are in
+[`docs/RELEASING.md`](docs/RELEASING.md).
-# Commit and tag
-git add -A && git commit -m "chore: release v0.2.0"
-git tag -a v0.2.0 -m "Release v0.2.0"
-git push origin main --tags
+```bash
+git fetch origin
+SHA="$(git rev-parse origin/main)"
+git tag -a vX.Y.Z "$SHA" -m "Release vX.Y.Z"
+git push origin vX.Y.Z
```
-The release workflow automatically builds for:
-- Linux x86_64 / ARM64
-- macOS Intel / Apple Silicon
-- Windows x86_64
+Do **not** `git push origin main --tags`. The release workflow builds for
+Linux x86_64 / ARM64, macOS Intel / Apple Silicon, and Windows x86_64, and
+refuses to publish unless every platform succeeded and the crate version
+matches the tag.
## GSD Workflow
diff --git a/README.md b/README.md
index d259cb1..63c2924 100644
--- a/README.md
+++ b/README.md
@@ -165,7 +165,7 @@ is experimental.
| 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`). Events indexed before v3.1 have empty `text_preview` and there is no backfill command — see [UPGRADING](docs/UPGRADING.md) and [#41](https://github.com/SpillwaveSolutions/agent-memory/issues/41) |
| Vector search (HNSW + Candle) | **Solid** | Mechanism is wired; retrieval *quality* is not yet measured ([#40](https://github.com/SpillwaveSolutions/agent-memory/issues/40)). 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 ([#40](https://github.com/SpillwaveSolutions/agent-memory/issues/40)) |
+| Topic graph | **Works** | Clustering quality is not benchmarked ([#47](https://github.com/SpillwaveSolutions/agent-memory/issues/47)) |
| 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 |
diff --git a/crates/memory-bench/src/cli.rs b/crates/memory-bench/src/cli.rs
index 8635af9..42d2082 100644
--- a/crates/memory-bench/src/cli.rs
+++ b/crates/memory-bench/src/cli.rs
@@ -15,9 +15,13 @@ pub struct Cli {
#[arg(long, global = true, default_value = "mock")]
pub backend: String,
- /// gRPC endpoint for `--backend cli`.
+ /// gRPC endpoint for `--backend cli` (ignored when isolation spawns a daemon).
#[arg(long, global = true, default_value = "http://127.0.0.1:50051")]
pub endpoint: String,
+
+ /// Path to memory-daemon binary (used by `--isolation daemon-per-conversation`).
+ #[arg(long, global = true, default_value = "memory-daemon")]
+ pub daemon_bin: String,
}
/// Available benchmark subcommands.
@@ -85,6 +89,12 @@ pub enum Commands {
/// Path to baselines TOML file.
#[arg(long, default_value = "benchmarks/baselines.toml")]
baselines: String,
+ /// Isolation: `daemon-per-conversation` (default for `--backend cli`) or `shared`.
+ #[arg(long, value_parser = ["daemon-per-conversation", "shared"])]
+ isolation: Option,
+ /// Cap total questions across conversations (60-02 dry-run).
+ #[arg(long)]
+ limit_questions: Option,
},
/// CI smoke: 1-conversation fixture + mock backend + mock judge.
Smoke {
diff --git a/crates/memory-bench/src/locomo.rs b/crates/memory-bench/src/locomo.rs
index c686e89..292148f 100644
--- a/crates/memory-bench/src/locomo.rs
+++ b/crates/memory-bench/src/locomo.rs
@@ -90,6 +90,9 @@ pub struct LocomoConversationResult {
pub score: f64,
pub by_type: HashMap,
pub questions: Vec,
+ /// Time spent polling index checkpoints after ingest. 0 on the mock path.
+ #[serde(default)]
+ pub drain_wait_ms: u64,
}
/// Aggregate across conversations. `metric` is the only name that may be
@@ -432,6 +435,7 @@ pub fn evaluate_sample(
score,
by_type,
questions,
+ drain_wait_ms: 0,
}
}
@@ -442,6 +446,7 @@ pub fn aggregate_results(
judge_label: &str,
model: Option,
temperature: Option,
+ isolation: &str,
) -> LocomoAggregateResult {
let mut total_questions = 0;
let mut total_correct = 0;
@@ -475,13 +480,22 @@ pub fn aggregate_results(
.collect();
let mut caveats = vec![
- "one isolated mock store per conversation (no cross-conversation bleed)".into(),
+ format!("isolation={isolation}"),
format!(
"metric is '{}' — do not quote as a published LOCOMO leaderboard number unless scorer is llm-judge with a pinned model",
kind.metric_name()
),
"dataset license is CC BY-NC 4.0; verify LICENSE.txt before commercial use".into(),
];
+ if isolation.contains("shared") {
+ caveats.push(
+ "shared daemon: conversation N can retrieve conversation 1..=N-1; \
+ numbers from this mode must not be committed"
+ .into(),
+ );
+ } else {
+ caveats.push("one isolated store per conversation (no cross-conversation bleed)".into());
+ }
if kind == ScorerKind::Mock {
caveats.push(
"mock scorer is substring context_hit_rate over token-overlap retrieval; \
@@ -496,7 +510,7 @@ pub fn aggregate_results(
temperature,
model,
dataset: dataset.to_string(),
- isolation: "per-conversation temp store".into(),
+ isolation: isolation.to_string(),
conversations: results.len(),
total_questions,
overall_score,
@@ -665,7 +679,15 @@ mod tests {
assert!(
result.by_type.contains_key("temporal") || result.by_type.contains_key("multi_hop")
);
- let agg = aggregate_results(&[result], ScorerKind::Mock, "fixture", "mock", None, None);
+ let agg = aggregate_results(
+ &[result],
+ ScorerKind::Mock,
+ "fixture",
+ "mock",
+ None,
+ None,
+ "per-conversation temp store",
+ );
assert_eq!(agg.metric, "context_hit_rate");
assert!(agg.caveats.iter().any(|c| c.contains("not comparable")));
}
diff --git a/crates/memory-bench/src/main.rs b/crates/memory-bench/src/main.rs
index 6c43098..00f0b0c 100644
--- a/crates/memory-bench/src/main.rs
+++ b/crates/memory-bench/src/main.rs
@@ -4,17 +4,30 @@ use std::path::Path;
mod cli;
use memory_bench::judge::{ApiJudge, Judge, MockJudge, ScorerKind};
-use memory_bench::runner::{BackendKind, MockStore, RunConfig};
+use memory_bench::runner::{BackendKind, IsolatedDaemon, Isolation, MockStore, RunConfig};
use memory_bench::{baseline, fixture, locomo, report, runner, scorer};
use scorer::BenchmarkReport;
+fn resolve_bin(configured: &str) -> String {
+ let p = Path::new(configured);
+ if p.is_file() {
+ return configured.to_string();
+ }
+ runner::find_bin(configured)
+ .map(|pb| pb.to_string_lossy().into_owned())
+ .unwrap_or_else(|| configured.to_string())
+}
+
fn main() -> anyhow::Result<()> {
let cli = cli::Cli::parse();
let backend = BackendKind::parse(&cli.backend)?;
let config = RunConfig {
- memory_bin: cli.memory_bin.clone(),
+ memory_bin: resolve_bin(&cli.memory_bin),
+ daemon_bin: resolve_bin(&cli.daemon_bin),
endpoint: cli.endpoint.clone(),
backend,
+ isolation: Isolation::Shared,
+ limit_questions: None,
};
match cli.command {
@@ -57,6 +70,8 @@ fn main() -> anyhow::Result<()> {
top,
compare,
baselines: _baselines,
+ isolation,
+ limit_questions,
} => {
let kind = ScorerKind::parse(&scorer)?;
if compare && kind == ScorerKind::Mock {
@@ -65,10 +80,26 @@ fn main() -> anyhow::Result<()> {
and must not share a table with published LLM-judge numbers"
);
}
+ let isolation = match isolation.as_deref() {
+ Some(s) => Isolation::parse(s)?,
+ None if backend == BackendKind::Cli => Isolation::DaemonPerConversation,
+ None => Isolation::Shared,
+ };
+ if backend == BackendKind::Cli && isolation == Isolation::Shared {
+ eprintln!(
+ "warning: --isolation shared: conversation N can retrieve 1..=N-1 \
+ (cross-conversation bleed). Do not commit this number."
+ );
+ }
let judge: Box = match kind {
ScorerKind::Mock => Box::new(MockJudge),
ScorerKind::LlmJudge => Box::new(ApiJudge::from_env()?),
};
+ let config = RunConfig {
+ isolation,
+ limit_questions,
+ ..config
+ };
let aggregate = run_locomo(&dataset, judge.as_ref(), kind, top, &config)?;
let json = serde_json::to_string_pretty(&aggregate)?;
println!("{json}");
@@ -122,26 +153,39 @@ fn run_locomo(
top: usize,
config: &RunConfig,
) -> anyhow::Result {
- let conversations = locomo::load_dataset(Path::new(dataset))?;
+ let mut conversations = locomo::load_dataset(Path::new(dataset))?;
+ if let Some(limit) = config.limit_questions {
+ let mut left = limit;
+ for conv in &mut conversations {
+ if left == 0 {
+ conv.qa.clear();
+ } else if conv.qa.len() > left {
+ conv.qa.truncate(left);
+ left = 0;
+ } else {
+ left -= conv.qa.len();
+ }
+ }
+ conversations.retain(|c| !c.qa.is_empty());
+ }
eprintln!(
- "Loaded {} conversations from {} (backend={} scorer={})",
+ "Loaded {} conversations from {} (backend={} scorer={} isolation={})",
conversations.len(),
dataset,
config.backend.as_str(),
- kind.metric_name()
+ kind.metric_name(),
+ config.isolation.result_label(config.backend),
);
let mut results = Vec::new();
for conv in &conversations {
- // Fresh store per conversation — no shared-store bleed.
match config.backend {
BackendKind::Mock => {
let store = locomo::ingest_sample_mock(conv);
results.push(locomo::evaluate_sample(conv, &store, judge, top));
}
BackendKind::Cli => {
- locomo::ingest_sample_cli(conv, config)?;
- results.push(evaluate_sample_cli(conv, config, judge, top)?);
+ results.push(run_locomo_cli_conversation(conv, config, judge, top)?);
}
}
}
@@ -162,18 +206,44 @@ fn run_locomo(
&judge_label,
model,
temperature,
+ config.isolation.result_label(config.backend),
))
}
+fn run_locomo_cli_conversation(
+ conv: &locomo::LocomoSample,
+ config: &RunConfig,
+ judge: &dyn Judge,
+ top: usize,
+) -> anyhow::Result {
+ match config.isolation {
+ Isolation::DaemonPerConversation => {
+ let daemon = IsolatedDaemon::spawn(&config.daemon_bin)?;
+ let mut isolated = config.clone();
+ isolated.endpoint = daemon.endpoint.clone();
+ locomo::ingest_sample_cli(conv, &isolated)?;
+ let drain_wait_ms = runner::wait_for_drain(&isolated.daemon_bin, &isolated.endpoint)?;
+ let mut result = evaluate_sample_cli(conv, &isolated, judge, top)?;
+ result.drain_wait_ms = drain_wait_ms;
+ daemon.stop()?;
+ Ok(result)
+ }
+ Isolation::Shared => {
+ locomo::ingest_sample_cli(conv, config)?;
+ let drain_wait_ms = runner::wait_for_drain(&config.daemon_bin, &config.endpoint)?;
+ let mut result = evaluate_sample_cli(conv, config, judge, top)?;
+ result.drain_wait_ms = drain_wait_ms;
+ Ok(result)
+ }
+ }
+}
+
fn evaluate_sample_cli(
sample: &locomo::LocomoSample,
config: &RunConfig,
judge: &dyn Judge,
top: usize,
) -> anyhow::Result {
- // Build a one-shot mock store from CLI retrieval *per question* by
- // stuffing CLI hits into evaluate_sample would mix questions. Do it
- // question-by-question and reuse locomo types.
use locomo::{QuestionResult, TypeScore};
use std::collections::HashMap;
@@ -236,6 +306,7 @@ fn evaluate_sample_cli(
},
by_type,
questions,
+ drain_wait_ms: 0,
})
}
diff --git a/crates/memory-bench/src/runner.rs b/crates/memory-bench/src/runner.rs
index d41194b..cfa6414 100644
--- a/crates/memory-bench/src/runner.rs
+++ b/crates/memory-bench/src/runner.rs
@@ -7,8 +7,8 @@ use anyhow::{bail, Context, Result};
use serde_json::Value;
use std::io::BufRead;
use std::path::{Path, PathBuf};
-use std::process::Command;
-use std::time::Instant;
+use std::process::{Child, Command, Stdio};
+use std::time::{Duration, Instant};
/// Which retrieval backend to drive.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -36,21 +36,65 @@ impl BackendKind {
}
}
+/// How the CLI backend isolates conversations.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Isolation {
+ /// One shared daemon. Conversation N can see 1..=N-1. Debug only.
+ Shared,
+ /// Spawn `memory-daemon start --db-path --port ` per conversation.
+ DaemonPerConversation,
+}
+
+impl Isolation {
+ pub fn parse(s: &str) -> Result {
+ match s {
+ "shared" => Ok(Self::Shared),
+ "daemon-per-conversation" => Ok(Self::DaemonPerConversation),
+ other => bail!("unknown isolation '{other}' (expected daemon-per-conversation|shared)"),
+ }
+ }
+
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Shared => "shared",
+ Self::DaemonPerConversation => "daemon-per-conversation",
+ }
+ }
+
+ /// Value written to results.json `isolation`.
+ pub fn result_label(self, backend: BackendKind) -> &'static str {
+ match (backend, self) {
+ (BackendKind::Mock, _) => "per-conversation temp store",
+ (BackendKind::Cli, Self::DaemonPerConversation) => "per-conversation daemon",
+ (BackendKind::Cli, Self::Shared) => "shared daemon (cross-conversation bleed)",
+ }
+ }
+}
+
/// Configuration for the benchmark runner.
+#[derive(Debug, Clone)]
pub struct RunConfig {
/// Path to the memory binary (default: "memory").
pub memory_bin: String,
+ /// Path to the memory-daemon binary (isolation spawn).
+ pub daemon_bin: String,
/// gRPC endpoint for CLI backend.
pub endpoint: String,
pub backend: BackendKind,
+ pub isolation: Isolation,
+ /// Cap total questions across conversations.
+ pub limit_questions: Option,
}
impl Default for RunConfig {
fn default() -> Self {
Self {
memory_bin: "memory".to_string(),
+ daemon_bin: "memory-daemon".to_string(),
endpoint: "http://127.0.0.1:50051".to_string(),
backend: BackendKind::Mock,
+ isolation: Isolation::Shared,
+ limit_questions: None,
}
}
}
@@ -338,6 +382,261 @@ pub fn run_query_cli(query: &str, config: &RunConfig, top: usize) -> Result();
+ let _ = rx.recv_timeout(POLL_INTERVAL);
+ drop(tx);
+}
+
+/// Snapshot from `memory-daemon query checkpoints`.
+#[derive(Debug, Clone)]
+pub struct CheckpointSnapshot {
+ pub checkpoints: Vec,
+ pub outbox_head: u64,
+}
+
+#[derive(Debug, Clone)]
+pub struct CheckpointInfo {
+ pub index_type: String,
+ pub last_sequence: u64,
+ pub processed_count: u64,
+}
+
+/// Drain is complete when BM25 (and vector, if present) has processed every
+/// assigned outbox sequence. A missing vector checkpoint does not block:
+/// the daemon's outbox pipeline currently registers only the BM25 updater.
+pub fn drain_caught_up(snap: &CheckpointSnapshot) -> bool {
+ if snap.outbox_head == 0 {
+ return true;
+ }
+ let target = snap.outbox_head.saturating_sub(1);
+ let bm25 = snap.checkpoints.iter().find(|c| c.index_type == "bm25");
+ let Some(bm25) = bm25 else {
+ return false;
+ };
+ if bm25.processed_count == 0 || bm25.last_sequence < target {
+ return false;
+ }
+ match snap.checkpoints.iter().find(|c| c.index_type == "vector") {
+ Some(v) => v.processed_count > 0 && v.last_sequence >= target,
+ None => true,
+ }
+}
+
+pub fn fetch_checkpoints(daemon_bin: &str, endpoint: &str) -> Result {
+ let output = Command::new(daemon_bin)
+ .args(["query", "--endpoint", endpoint, "checkpoints"])
+ .output()
+ .with_context(|| format!("spawning `{daemon_bin} query checkpoints`"))?;
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ bail!(
+ "query checkpoints failed at {endpoint}: status={} stderr={stderr}",
+ output.status
+ );
+ }
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ parse_checkpoint_json(&stdout).with_context(|| format!("parsing checkpoints JSON: {stdout}"))
+}
+
+fn parse_checkpoint_json(stdout: &str) -> Result {
+ let v: Value = serde_json::from_str(stdout.trim())?;
+ let outbox_head = v
+ .get("outbox_head")
+ .and_then(|x| x.as_u64())
+ .context("missing outbox_head")?;
+ let mut checkpoints = Vec::new();
+ if let Some(arr) = v.get("checkpoints").and_then(|x| x.as_array()) {
+ for c in arr {
+ checkpoints.push(CheckpointInfo {
+ index_type: c
+ .get("index_type")
+ .and_then(|x| x.as_str())
+ .unwrap_or("")
+ .to_string(),
+ last_sequence: c.get("last_sequence").and_then(|x| x.as_u64()).unwrap_or(0),
+ processed_count: c
+ .get("processed_count")
+ .and_then(|x| x.as_u64())
+ .unwrap_or(0),
+ });
+ }
+ }
+ Ok(CheckpointSnapshot {
+ checkpoints,
+ outbox_head,
+ })
+}
+
+/// Poll GetIndexCheckpoints until BM25 is caught up, or 5 minutes.
+pub fn wait_for_drain(daemon_bin: &str, endpoint: &str) -> Result {
+ let start = Instant::now();
+ loop {
+ match fetch_checkpoints(daemon_bin, endpoint) {
+ Ok(snap) if drain_caught_up(&snap) => {
+ return Ok(start.elapsed().as_millis() as u64);
+ }
+ Ok(_) | Err(_) => {
+ if start.elapsed() > DRAIN_TIMEOUT {
+ bail!(
+ "index drain timed out after {}ms at {endpoint} \
+ (BM25 checkpoint never reached outbox_head-1)",
+ DRAIN_TIMEOUT.as_millis()
+ );
+ }
+ poll_pause();
+ }
+ }
+ }
+}
+
+/// Bind 127.0.0.1:0, return the port, drop the listener.
+pub fn free_port() -> Result {
+ let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
+ let port = listener.local_addr()?.port();
+ drop(listener);
+ Ok(port)
+}
+
+/// One daemon, one temp store, one free port. Drop/stop kills the child.
+pub struct IsolatedDaemon {
+ child: Child,
+ pub endpoint: String,
+ _dir: tempfile::TempDir,
+ daemon_bin: String,
+ pid_file: PathBuf,
+}
+
+impl IsolatedDaemon {
+ pub fn spawn(daemon_bin: &str) -> Result {
+ let dir = tempfile::tempdir().context("creating isolation tempdir")?;
+ let db = dir.path().join("db");
+ std::fs::create_dir_all(&db)?;
+ let pid_file = dir.path().join("daemon.pid");
+ let stderr_path = dir.path().join("daemon.stderr");
+ let stderr_file =
+ std::fs::File::create(&stderr_path).context("creating daemon stderr log")?;
+ let port = free_port()?;
+ let mut child = Command::new(daemon_bin)
+ .args([
+ "start",
+ "--db-path",
+ db.to_str().context("db path utf-8")?,
+ "--port",
+ &port.to_string(),
+ "--pid-file",
+ pid_file.to_str().context("pid path utf-8")?,
+ "--log-level",
+ "warn",
+ ])
+ .stdout(Stdio::null())
+ .stderr(Stdio::from(stderr_file))
+ .spawn()
+ .with_context(|| format!("spawning `{daemon_bin} start`"))?;
+
+ let endpoint = format!("http://127.0.0.1:{port}");
+ let start = Instant::now();
+ loop {
+ if let Some(status) = child.try_wait()? {
+ let log = std::fs::read_to_string(&stderr_path).unwrap_or_default();
+ bail!("daemon exited before becoming healthy: {status}\n{log}");
+ }
+ if fetch_checkpoints(daemon_bin, &endpoint).is_ok() {
+ break;
+ }
+ if start.elapsed() > HEALTH_TIMEOUT {
+ let log = std::fs::read_to_string(&stderr_path).unwrap_or_default();
+ let _ = child.kill();
+ bail!("daemon did not become healthy at {endpoint} within 300s\n{log}");
+ }
+ poll_pause();
+ }
+
+ Ok(Self {
+ child,
+ endpoint,
+ _dir: dir,
+ daemon_bin: daemon_bin.to_string(),
+ pid_file,
+ })
+ }
+
+ pub fn stop(mut self) -> Result<()> {
+ let _ = Command::new(&self.daemon_bin)
+ .args([
+ "stop",
+ "--pid-file",
+ self.pid_file.to_str().unwrap_or_default(),
+ ])
+ .status();
+ let deadline = Instant::now() + Duration::from_secs(15);
+ loop {
+ if self.child.try_wait()?.is_some() {
+ break;
+ }
+ if Instant::now() > deadline {
+ let _ = self.child.kill();
+ let _ = self.child.wait();
+ break;
+ }
+ poll_pause();
+ }
+ Ok(())
+ }
+}
+
+impl Drop for IsolatedDaemon {
+ fn drop(&mut self) {
+ let _ = self.child.kill();
+ let _ = self.child.wait();
+ }
+}
+
+/// Locate `memory` / `memory-daemon` for live isolation tests.
+pub fn find_bin(name: &str) -> Option {
+ let env_key = match name {
+ "memory-daemon" => "MEMORY_BENCH_DAEMON_BIN",
+ "memory" => "MEMORY_BENCH_MEMORY_BIN",
+ _ => "",
+ };
+ if !env_key.is_empty() {
+ if let Ok(p) = std::env::var(env_key) {
+ let pb = PathBuf::from(p);
+ if pb.is_file() {
+ return Some(pb);
+ }
+ }
+ }
+ let debug = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .join("../../target/debug")
+ .join(name);
+ if debug.is_file() {
+ return Some(debug);
+ }
+ let release = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .join("../../target/release")
+ .join(name);
+ if release.is_file() {
+ return Some(release);
+ }
+ if let Some(paths) = std::env::var_os("PATH") {
+ for dir in std::env::split_paths(&paths) {
+ let candidate = dir.join(name);
+ if candidate.is_file() {
+ return Some(candidate);
+ }
+ }
+ }
+ None
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -402,6 +701,7 @@ mod tests {
memory_bin: "/definitely/not/a/memory/binary".into(),
endpoint: "http://127.0.0.1:1".into(),
backend: BackendKind::Cli,
+ ..RunConfig::default()
};
let err = ingest_session_cli(path.to_str().unwrap(), &cfg).unwrap_err();
let msg = err.to_string();
@@ -455,4 +755,196 @@ mod tests {
// not panic. Accuracy is a mock number — asserted only as a pipeline.
assert!(tests.len() >= 25);
}
+
+ #[test]
+ fn drain_caught_up_empty_outbox() {
+ let snap = CheckpointSnapshot {
+ checkpoints: vec![],
+ outbox_head: 0,
+ };
+ assert!(drain_caught_up(&snap));
+ }
+
+ #[test]
+ fn drain_caught_up_requires_bm25() {
+ let pending = CheckpointSnapshot {
+ checkpoints: vec![],
+ outbox_head: 3,
+ };
+ assert!(!drain_caught_up(&pending));
+
+ let incomplete = CheckpointSnapshot {
+ checkpoints: vec![CheckpointInfo {
+ index_type: "bm25".into(),
+ last_sequence: 0,
+ processed_count: 0,
+ }],
+ outbox_head: 3,
+ };
+ assert!(!drain_caught_up(&incomplete));
+
+ let ready = CheckpointSnapshot {
+ checkpoints: vec![CheckpointInfo {
+ index_type: "bm25".into(),
+ last_sequence: 2,
+ processed_count: 3,
+ }],
+ outbox_head: 3,
+ };
+ assert!(drain_caught_up(&ready));
+ }
+
+ #[test]
+ fn drain_caught_up_first_outbox_sequence() {
+ let ready = CheckpointSnapshot {
+ checkpoints: vec![CheckpointInfo {
+ index_type: "bm25".into(),
+ last_sequence: 0,
+ processed_count: 1,
+ }],
+ outbox_head: 1,
+ };
+ assert!(drain_caught_up(&ready));
+ }
+
+ #[test]
+ fn drain_caught_up_vector_optional_unless_present() {
+ let bm25_only = CheckpointSnapshot {
+ checkpoints: vec![CheckpointInfo {
+ index_type: "bm25".into(),
+ last_sequence: 4,
+ processed_count: 5,
+ }],
+ outbox_head: 5,
+ };
+ assert!(drain_caught_up(&bm25_only));
+
+ let vector_lagging = CheckpointSnapshot {
+ checkpoints: vec![
+ CheckpointInfo {
+ index_type: "bm25".into(),
+ last_sequence: 4,
+ processed_count: 5,
+ },
+ CheckpointInfo {
+ index_type: "vector".into(),
+ last_sequence: 0,
+ processed_count: 0,
+ },
+ ],
+ outbox_head: 5,
+ };
+ assert!(!drain_caught_up(&vector_lagging));
+ }
+
+ #[test]
+ fn parse_checkpoint_json_roundtrip() {
+ let snap = parse_checkpoint_json(
+ r#"{"checkpoints":[{"index_type":"bm25","last_sequence":1,"processed_count":2}],"outbox_head":2}"#,
+ )
+ .unwrap();
+ assert_eq!(snap.outbox_head, 2);
+ assert_eq!(snap.checkpoints[0].index_type, "bm25");
+ assert!(drain_caught_up(&snap));
+ }
+
+ #[test]
+ fn isolation_parse_roundtrip() {
+ assert_eq!(
+ Isolation::parse("daemon-per-conversation").unwrap(),
+ Isolation::DaemonPerConversation
+ );
+ assert_eq!(Isolation::parse("shared").unwrap(), Isolation::Shared);
+ assert!(Isolation::parse("reset-rpc").is_err());
+ }
+
+ #[test]
+ fn isolation_default_label() {
+ assert_eq!(
+ Isolation::DaemonPerConversation.result_label(BackendKind::Cli),
+ "per-conversation daemon"
+ );
+ assert_eq!(
+ Isolation::Shared.result_label(BackendKind::Cli),
+ "shared daemon (cross-conversation bleed)"
+ );
+ assert_eq!(
+ Isolation::DaemonPerConversation.result_label(BackendKind::Mock),
+ "per-conversation temp store"
+ );
+ }
+
+ #[test]
+ fn cli_isolated_daemons_do_not_bleed() {
+ if std::env::var("MEMORY_BENCH_LIVE").ok().as_deref() != Some("1") {
+ eprintln!("skip: set MEMORY_BENCH_LIVE=1 after building memory + memory-daemon");
+ return;
+ }
+ let Some(daemon) = find_bin("memory-daemon") else {
+ eprintln!("skip: memory-daemon binary not found (CI bench-cli-smoke builds it)");
+ return;
+ };
+ let Some(memory) = find_bin("memory") else {
+ eprintln!("skip: memory binary not found");
+ return;
+ };
+
+ eprintln!(
+ "live isolation: daemon={} memory={}",
+ daemon.display(),
+ memory.display()
+ );
+
+ let a = IsolatedDaemon::spawn(daemon.to_str().unwrap()).expect("spawn A");
+ eprintln!("spawned A at {}", a.endpoint);
+ let b = IsolatedDaemon::spawn(daemon.to_str().unwrap()).expect("spawn B");
+ eprintln!("spawned B at {}", b.endpoint);
+
+ let cfg_a = RunConfig {
+ memory_bin: memory.to_string_lossy().into_owned(),
+ daemon_bin: daemon.to_string_lossy().into_owned(),
+ endpoint: a.endpoint.clone(),
+ backend: BackendKind::Cli,
+ isolation: Isolation::DaemonPerConversation,
+ limit_questions: None,
+ };
+ let mut cfg_b = cfg_a.clone();
+ cfg_b.endpoint = b.endpoint.clone();
+
+ let dir = tempfile::tempdir().unwrap();
+ let path_a = dir.path().join("a.jsonl");
+ let path_b = dir.path().join("b.jsonl");
+ std::fs::write(&path_a, r#"{"content":"UNIQUE_ALPHA_TOKEN jwt rotation"}"#).unwrap();
+ std::fs::write(&path_b, r#"{"content":"UNIQUE_BETA_TOKEN redis cache"}"#).unwrap();
+
+ ingest_session_cli(path_a.to_str().unwrap(), &cfg_a).unwrap();
+ ingest_session_cli(path_b.to_str().unwrap(), &cfg_b).unwrap();
+ eprintln!("ingested; draining A");
+ wait_for_drain(&cfg_a.daemon_bin, &cfg_a.endpoint).unwrap();
+ eprintln!("drained A; draining B");
+ wait_for_drain(&cfg_b.daemon_bin, &cfg_b.endpoint).unwrap();
+ eprintln!("drained B; searching");
+
+ let hits_b = run_query_cli("UNIQUE_ALPHA_TOKEN", &cfg_b, 5).unwrap();
+ assert!(
+ hits_b
+ .ranked
+ .iter()
+ .all(|h| !h.text.contains("UNIQUE_ALPHA_TOKEN")),
+ "daemon B must not see A's events: {:?}",
+ hits_b.ranked
+ );
+ let hits_a = run_query_cli("UNIQUE_ALPHA_TOKEN", &cfg_a, 5).unwrap();
+ assert!(
+ hits_a
+ .ranked
+ .iter()
+ .any(|h| h.text.contains("UNIQUE_ALPHA_TOKEN")),
+ "daemon A must see its own token: {:?}",
+ hits_a.ranked
+ );
+
+ a.stop().ok();
+ b.stop().ok();
+ }
}
diff --git a/crates/memory-client/src/client.rs b/crates/memory-client/src/client.rs
index 1a15fc8..6e80da5 100644
--- a/crates/memory-client/src/client.rs
+++ b/crates/memory-client/src/client.rs
@@ -8,13 +8,13 @@ use tracing::{debug, info};
use memory_service::pb::{
memory_service_client::MemoryServiceClient, BrowseTocRequest, Event as ProtoEvent,
EventRole as ProtoEventRole, EventType as ProtoEventType, ExpandGripRequest,
- GetDedupStatusRequest, GetDedupStatusResponse, GetEventsRequest, GetNodeRequest,
- GetRankingStatusRequest, GetRankingStatusResponse, GetRelatedTopicsRequest, GetTocRootRequest,
- GetTopTopicsRequest, GetTopicGraphStatusRequest, GetTopicsByQueryRequest,
- GetVectorIndexStatusRequest, Grip as ProtoGrip, HybridSearchRequest, HybridSearchResponse,
- IngestEventRequest, RouteQueryRequest, RouteQueryResponse, TeleportSearchRequest,
- TeleportSearchResponse, TocNode as ProtoTocNode, Topic as ProtoTopic, VectorIndexStatus,
- VectorTeleportRequest, VectorTeleportResponse,
+ GetDedupStatusRequest, GetDedupStatusResponse, GetEventsRequest, GetIndexCheckpointsRequest,
+ GetIndexCheckpointsResponse, GetNodeRequest, GetRankingStatusRequest, GetRankingStatusResponse,
+ GetRelatedTopicsRequest, GetTocRootRequest, GetTopTopicsRequest, GetTopicGraphStatusRequest,
+ GetTopicsByQueryRequest, GetVectorIndexStatusRequest, Grip as ProtoGrip, HybridSearchRequest,
+ HybridSearchResponse, IngestEventRequest, RouteQueryRequest, RouteQueryResponse,
+ TeleportSearchRequest, TeleportSearchResponse, TocNode as ProtoTocNode, Topic as ProtoTopic,
+ VectorIndexStatus, VectorTeleportRequest, VectorTeleportResponse,
};
use memory_types::{Event, EventRole, EventType};
@@ -339,6 +339,16 @@ impl MemoryClient {
Ok(response.into_inner())
}
+ /// Read BM25/vector index checkpoints and the outbox head (Phase 60-01).
+ pub async fn get_index_checkpoints(
+ &mut self,
+ ) -> Result {
+ debug!("GetIndexCheckpoints request");
+ let request = tonic::Request::new(GetIndexCheckpointsRequest {});
+ let response = self.inner.get_index_checkpoints(request).await?;
+ Ok(response.into_inner())
+ }
+
// ===== Observability Methods (Phase 42) =====
/// Get dedup gate status and metrics.
diff --git a/crates/memory-client/src/lib.rs b/crates/memory-client/src/lib.rs
index a44b9a0..6cc26d2 100644
--- a/crates/memory-client/src/lib.rs
+++ b/crates/memory-client/src/lib.rs
@@ -43,8 +43,9 @@ pub use client::{
pub use error::ClientError;
pub use hook_mapping::{map_hook_event, HookEvent, HookEventType};
pub use memory_service::pb::{
- Event as ProtoEvent, ExplainabilityPayload, HybridSearchResponse, RetrievalResult,
- RouteQueryResponse, VectorIndexStatus, VectorMatch, VectorTeleportResponse,
+ Event as ProtoEvent, ExplainabilityPayload, GetIndexCheckpointsResponse, HybridSearchResponse,
+ IndexCheckpointInfo, RetrievalResult, RouteQueryResponse, VectorIndexStatus, VectorMatch,
+ VectorTeleportResponse,
};
// Re-export Event type for convenience
diff --git a/crates/memory-daemon/Cargo.toml b/crates/memory-daemon/Cargo.toml
index cd41dab..96d8abb 100644
--- a/crates/memory-daemon/Cargo.toml
+++ b/crates/memory-daemon/Cargo.toml
@@ -30,6 +30,7 @@ anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
serde = { workspace = true }
+serde_json = { workspace = true }
chrono = { workspace = true }
toml = { workspace = true }
shellexpand = "3.1"
diff --git a/crates/memory-daemon/src/cli.rs b/crates/memory-daemon/src/cli.rs
index 41ce1c7..23852a8 100644
--- a/crates/memory-daemon/src/cli.rs
+++ b/crates/memory-daemon/src/cli.rs
@@ -46,10 +46,20 @@ pub enum Commands {
/// Override database path
#[arg(long)]
db_path: Option,
+
+ /// PID file path. Defaults to `$XDG_RUNTIME_DIR/agent-memory/daemon.pid`.
+ /// Isolation harnesses pass a per-conversation path so they do not
+ /// clobber a user's running daemon.
+ #[arg(long)]
+ pid_file: Option,
},
/// Stop the running daemon
- Stop,
+ Stop {
+ /// PID file to signal. Must match the path used at start.
+ #[arg(long)]
+ pid_file: Option,
+ },
/// Show daemon status
Status {
@@ -212,6 +222,9 @@ pub enum QueryCommands {
#[arg(long, default_value = "10")]
limit: u32,
},
+
+ /// Print BM25/vector checkpoints and the outbox head as JSON (Phase 60-01).
+ Checkpoints,
}
/// Admin subcommands
@@ -701,7 +714,40 @@ mod tests {
#[test]
fn test_cli_stop() {
let cli = Cli::parse_from(["memory-daemon", "stop"]);
- assert!(matches!(cli.command, Commands::Stop));
+ assert!(matches!(cli.command, Commands::Stop { pid_file: None }));
+ }
+
+ #[test]
+ fn test_cli_stop_pid_file() {
+ let cli = Cli::parse_from(["memory-daemon", "stop", "--pid-file", "/tmp/am.pid"]);
+ match cli.command {
+ Commands::Stop { pid_file } => {
+ assert_eq!(pid_file.as_deref(), Some("/tmp/am.pid"));
+ }
+ _ => panic!("Expected Stop command"),
+ }
+ }
+
+ #[test]
+ fn test_cli_start_pid_file() {
+ let cli = Cli::parse_from(["memory-daemon", "start", "--pid-file", "/tmp/am.pid"]);
+ match cli.command {
+ Commands::Start { pid_file, .. } => {
+ assert_eq!(pid_file.as_deref(), Some("/tmp/am.pid"));
+ }
+ _ => panic!("Expected Start command"),
+ }
+ }
+
+ #[test]
+ fn test_cli_query_checkpoints() {
+ let cli = Cli::parse_from(["memory-daemon", "query", "checkpoints"]);
+ match cli.command {
+ Commands::Query { command, .. } => {
+ assert!(matches!(command, QueryCommands::Checkpoints));
+ }
+ _ => panic!("Expected Query command"),
+ }
}
#[test]
diff --git a/crates/memory-daemon/src/commands.rs b/crates/memory-daemon/src/commands.rs
index d111b90..79063fc 100644
--- a/crates/memory-daemon/src/commands.rs
+++ b/crates/memory-daemon/src/commands.rs
@@ -42,7 +42,7 @@ use crate::cli::{
SchedulerCommands, TeleportCommand, TopicsCommand,
};
-/// Get the PID file path
+/// Get the default PID file path
fn pid_file_path() -> PathBuf {
directories::BaseDirs::new()
.map(|dirs| {
@@ -63,22 +63,26 @@ fn pid_file_path() -> PathBuf {
.join("daemon.pid")
}
+fn resolve_pid_file(override_path: Option<&str>) -> PathBuf {
+ override_path
+ .map(PathBuf::from)
+ .unwrap_or_else(pid_file_path)
+}
+
/// Write PID to file
-fn write_pid_file() -> Result<()> {
- let pid_path = pid_file_path();
+fn write_pid_file(pid_path: &Path) -> Result<()> {
if let Some(parent) = pid_path.parent() {
fs::create_dir_all(parent)?;
}
- fs::write(&pid_path, std::process::id().to_string())?;
+ fs::write(pid_path, std::process::id().to_string())?;
info!("Wrote PID file: {:?}", pid_path);
Ok(())
}
/// Remove PID file
-fn remove_pid_file() {
- let pid_path = pid_file_path();
+fn remove_pid_file(pid_path: &Path) {
if pid_path.exists() {
- if let Err(e) = fs::remove_file(&pid_path) {
+ if let Err(e) = fs::remove_file(pid_path) {
warn!("Failed to remove PID file: {}", e);
} else {
info!("Removed PID file");
@@ -87,9 +91,8 @@ fn remove_pid_file() {
}
/// Read PID from file
-fn read_pid_file() -> Option {
- let pid_path = pid_file_path();
- fs::read_to_string(&pid_path)
+fn read_pid_file(pid_path: &Path) -> Option {
+ fs::read_to_string(pid_path)
.ok()
.and_then(|s| s.trim().parse().ok())
}
@@ -570,6 +573,7 @@ pub async fn start_daemon(
port_override: Option,
db_path_override: Option<&str>,
log_level_override: Option<&str>,
+ pid_file_override: Option<&str>,
) -> Result<()> {
if background {
anyhow::bail!(
@@ -737,8 +741,10 @@ pub async fn start_daemon(
None
};
+ let pid_path = resolve_pid_file(pid_file_override);
+
// Write PID file
- write_pid_file()?;
+ write_pid_file(&pid_path)?;
// Parse address
let addr: SocketAddr = settings
@@ -797,17 +803,18 @@ pub async fn start_daemon(
.await;
// Cleanup
- remove_pid_file();
+ remove_pid_file(&pid_path);
result.map_err(|e| anyhow::anyhow!("Server error: {}", e))
}
/// Stop the running daemon by sending SIGTERM.
-pub fn stop_daemon() -> Result<()> {
- let pid = read_pid_file().context("No PID file found - daemon may not be running")?;
+pub fn stop_daemon(pid_file_override: Option<&str>) -> Result<()> {
+ let pid_path = resolve_pid_file(pid_file_override);
+ let pid = read_pid_file(&pid_path).context("No PID file found - daemon may not be running")?;
if !is_process_running(pid) {
- remove_pid_file();
+ remove_pid_file(&pid_path);
anyhow::bail!("Daemon not running (stale PID file removed)");
}
@@ -835,7 +842,7 @@ pub fn stop_daemon() -> Result<()> {
pub fn show_status() -> Result<()> {
let pid_path = pid_file_path();
- match read_pid_file() {
+ match read_pid_file(&pid_path) {
Some(pid) if is_process_running(pid) => {
println!("Memory daemon is running (PID {})", pid);
println!("PID file: {:?}", pid_path);
@@ -1088,6 +1095,29 @@ pub async fn handle_query(endpoint: &str, command: QueryCommands) -> Result<()>
fields,
limit,
} => handle_search(endpoint, query, node, parent, fields, limit).await?,
+
+ QueryCommands::Checkpoints => {
+ let resp = client
+ .get_index_checkpoints()
+ .await
+ .context("GetIndexCheckpoints failed")?;
+ let checkpoints: Vec = resp
+ .checkpoints
+ .iter()
+ .map(|c| {
+ serde_json::json!({
+ "index_type": c.index_type,
+ "last_sequence": c.last_sequence,
+ "processed_count": c.processed_count,
+ })
+ })
+ .collect();
+ let payload = serde_json::json!({
+ "checkpoints": checkpoints,
+ "outbox_head": resp.outbox_head,
+ });
+ println!("{}", serde_json::to_string(&payload)?);
+ }
}
Ok(())
@@ -3305,6 +3335,12 @@ mod tests {
.contains("agent-memory"));
}
+ #[test]
+ fn test_resolve_pid_file_override() {
+ let path = resolve_pid_file(Some("/tmp/am-test.pid"));
+ assert_eq!(path, PathBuf::from("/tmp/am-test.pid"));
+ }
+
#[test]
fn test_status_no_daemon() {
// Just verify it doesn't panic
diff --git a/crates/memory-daemon/src/main.rs b/crates/memory-daemon/src/main.rs
index 89e03fc..ef11f01 100644
--- a/crates/memory-daemon/src/main.rs
+++ b/crates/memory-daemon/src/main.rs
@@ -38,6 +38,7 @@ async fn main() -> Result<()> {
background,
port,
db_path,
+ pid_file,
} => {
start_daemon(
cli.config.as_deref(),
@@ -45,11 +46,12 @@ async fn main() -> Result<()> {
port,
db_path.as_deref(),
cli.log_level.as_deref(),
+ pid_file.as_deref(),
)
.await?;
}
- Commands::Stop => {
- stop_daemon()?;
+ Commands::Stop { pid_file } => {
+ stop_daemon(pid_file.as_deref())?;
}
Commands::Status { verbose, endpoint } => {
show_status()?;
diff --git a/crates/memory-indexing/src/pipeline.rs b/crates/memory-indexing/src/pipeline.rs
index 286eec5..b429fd0 100644
--- a/crates/memory-indexing/src/pipeline.rs
+++ b/crates/memory-indexing/src/pipeline.rs
@@ -36,12 +36,13 @@ impl ProcessResult {
/// Add a result for an index type.
pub fn add_result(&mut self, index_type: IndexType, result: UpdateResult) {
self.total_processed += result.processed;
- if let Some(last_seq) = self.last_sequence {
- if result.last_sequence > last_seq {
- self.last_sequence = Some(result.last_sequence);
- }
- } else if result.last_sequence > 0 {
- self.last_sequence = Some(result.last_sequence);
+ // Sequence 0 is a real outbox id. Using `last_sequence > 0` dropped the
+ // first event, so a one-entry drain never wrote a checkpoint.
+ if result.total() > 0 {
+ self.last_sequence = Some(match self.last_sequence {
+ Some(last_seq) => last_seq.max(result.last_sequence),
+ None => result.last_sequence,
+ });
}
self.by_index.insert(index_type, result);
}
@@ -273,7 +274,12 @@ impl IndexingPipeline {
if let Some(last_seq) = result.last_sequence {
for (index_type, checkpoint) in &mut self.checkpoints {
if let Some(idx_result) = result.by_index.get(index_type) {
- if idx_result.last_sequence > checkpoint.last_sequence {
+ // Sequence 0 is a valid last_sequence. A fresh
+ // checkpoint is last_sequence=0/processed_count=0, so
+ // `>` would skip the first outbox entry forever.
+ if checkpoint.processed_count == 0
+ || idx_result.last_sequence > checkpoint.last_sequence
+ {
checkpoint
.update(idx_result.last_sequence, idx_result.processed as u64);
}
@@ -448,6 +454,18 @@ mod tests {
(Arc::new(storage), temp_dir)
}
+ #[test]
+ fn test_add_result_records_sequence_zero() {
+ let mut result = ProcessResult::new();
+ let mut update = UpdateResult::new();
+ update.record_success();
+ update.set_sequence(0);
+ result.add_result(IndexType::Bm25, update);
+ assert_eq!(result.last_sequence, Some(0));
+ assert_eq!(result.total_processed, 1);
+ assert!(result.has_updates());
+ }
+
#[test]
fn test_pipeline_creation() {
let (storage, _temp) = create_test_storage();
@@ -537,6 +555,35 @@ mod tests {
assert!(result.committed);
}
+ #[test]
+ fn test_process_batch_sequence_zero_advances_checkpoint() {
+ let (storage, _temp_dir) = create_test_storage();
+ let outbox_entry = OutboxEntry::for_index("event-0".to_string(), 0);
+ let outbox_bytes = outbox_entry.to_bytes().unwrap();
+ storage
+ .put_event(&ulid::Ulid::new().to_string(), b"test", &outbox_bytes)
+ .unwrap();
+
+ let mut pipeline = IndexingPipeline::new(storage.clone(), PipelineConfig::default());
+ pipeline.add_updater(Box::new(MockUpdater::new(IndexType::Bm25, "bm25")));
+ pipeline.load_checkpoints().unwrap();
+
+ let result = pipeline.process_batch(100).unwrap();
+ assert!(result.has_updates());
+ assert_eq!(result.total_processed, 1);
+
+ let bytes = storage
+ .get_checkpoint("index_bm25")
+ .unwrap()
+ .expect("sequence-0 batch must persist a checkpoint");
+ let cp = IndexCheckpoint::from_bytes(&bytes).unwrap();
+ assert_eq!(cp.last_sequence, 0);
+ assert!(
+ cp.processed_count > 0,
+ "fresh checkpoint must record processed_count after seq 0"
+ );
+ }
+
#[test]
fn test_process_until_caught_up() {
let (storage, _temp_dir) = create_test_storage();
diff --git a/crates/memory-search/src/searcher.rs b/crates/memory-search/src/searcher.rs
index a14f0f6..cf7566a 100644
--- a/crates/memory-search/src/searcher.rs
+++ b/crates/memory-search/src/searcher.rs
@@ -111,6 +111,11 @@ impl TeleportSearcher {
return Ok(Vec::new());
}
+ // The outbox indexer commits on a separate IndexWriter. Tantivy's
+ // OnCommitWithDelay only notifies readers on the same Index object,
+ // so this process-local reader must reload to see the latest commit.
+ self.reload()?;
+
let searcher = self.reader.searcher();
// Parse the text query
diff --git a/crates/memory-service/src/ingest.rs b/crates/memory-service/src/ingest.rs
index 22acd1f..4bbdb86 100644
--- a/crates/memory-service/src/ingest.rs
+++ b/crates/memory-service/src/ingest.rs
@@ -29,20 +29,21 @@ use crate::pb::{
CompleteEpisodeResponse, Event as ProtoEvent, EventRole as ProtoEventRole,
EventType as ProtoEventType, ExpandGripRequest, ExpandGripResponse, GetAgentActivityRequest,
GetAgentActivityResponse, GetDedupStatusRequest, GetDedupStatusResponse, GetEventsRequest,
- GetEventsResponse, GetNodeRequest, GetNodeResponse, GetRankingStatusRequest,
- GetRankingStatusResponse, GetRelatedTopicsRequest, GetRelatedTopicsResponse,
- GetRetrievalCapabilitiesRequest, GetRetrievalCapabilitiesResponse, GetSchedulerStatusRequest,
- GetSchedulerStatusResponse, GetSimilarEpisodesRequest, GetSimilarEpisodesResponse,
- GetTocRootRequest, GetTocRootResponse, GetTopTopicsRequest, GetTopTopicsResponse,
- GetTopicGraphStatusRequest, GetTopicGraphStatusResponse, GetTopicsByQueryRequest,
- GetTopicsByQueryResponse, GetVectorIndexStatusRequest, HybridSearchRequest,
- HybridSearchResponse, IngestEventRequest, IngestEventResponse, ListAgentsRequest,
- ListAgentsResponse, PauseJobRequest, PauseJobResponse, PruneBm25IndexRequest,
- PruneBm25IndexResponse, PruneVectorIndexRequest, PruneVectorIndexResponse, RecordActionRequest,
- RecordActionResponse, ResumeJobRequest, ResumeJobResponse, RouteQueryRequest,
- RouteQueryResponse, SearchChildrenRequest, SearchChildrenResponse, SearchNodeRequest,
- SearchNodeResponse, StartEpisodeRequest, StartEpisodeResponse, TeleportSearchRequest,
- TeleportSearchResponse, VectorIndexStatus, VectorTeleportRequest, VectorTeleportResponse,
+ GetEventsResponse, GetIndexCheckpointsRequest, GetIndexCheckpointsResponse, GetNodeRequest,
+ GetNodeResponse, GetRankingStatusRequest, GetRankingStatusResponse, GetRelatedTopicsRequest,
+ GetRelatedTopicsResponse, GetRetrievalCapabilitiesRequest, GetRetrievalCapabilitiesResponse,
+ GetSchedulerStatusRequest, GetSchedulerStatusResponse, GetSimilarEpisodesRequest,
+ GetSimilarEpisodesResponse, GetTocRootRequest, GetTocRootResponse, GetTopTopicsRequest,
+ GetTopTopicsResponse, GetTopicGraphStatusRequest, GetTopicGraphStatusResponse,
+ GetTopicsByQueryRequest, GetTopicsByQueryResponse, GetVectorIndexStatusRequest,
+ HybridSearchRequest, HybridSearchResponse, IndexCheckpointInfo, IngestEventRequest,
+ IngestEventResponse, ListAgentsRequest, ListAgentsResponse, PauseJobRequest, PauseJobResponse,
+ PruneBm25IndexRequest, PruneBm25IndexResponse, PruneVectorIndexRequest,
+ PruneVectorIndexResponse, RecordActionRequest, RecordActionResponse, ResumeJobRequest,
+ ResumeJobResponse, RouteQueryRequest, RouteQueryResponse, SearchChildrenRequest,
+ SearchChildrenResponse, SearchNodeRequest, SearchNodeResponse, StartEpisodeRequest,
+ StartEpisodeResponse, TeleportSearchRequest, TeleportSearchResponse, VectorIndexStatus,
+ VectorTeleportRequest, VectorTeleportResponse,
};
use crate::query;
use crate::retrieval::RetrievalHandler;
@@ -367,6 +368,49 @@ impl MemoryServiceImpl {
self.retrieval_service = Some(Arc::new(retrieval));
}
+ /// Read persisted BM25/vector checkpoints plus the outbox head.
+ ///
+ /// Missing checkpoints are omitted (a fresh store has none). The live-backend
+ /// LOCOMO harness polls this instead of sleeping.
+ pub fn read_index_checkpoints(&self) -> GetIndexCheckpointsResponse {
+ const KEYS: [(&str, &str); 3] = [
+ ("index_bm25", "bm25"),
+ ("index_vector", "vector"),
+ ("index_combined", "combined"),
+ ];
+ let mut checkpoints = Vec::new();
+ for (key, index_type) in KEYS {
+ match self.storage.get_checkpoint(key) {
+ Ok(Some(bytes)) => {
+ let parsed = serde_json::from_slice::(&bytes).ok();
+ let last_sequence = parsed
+ .as_ref()
+ .and_then(|v| v.get("last_sequence"))
+ .and_then(|v| v.as_u64())
+ .unwrap_or(0);
+ let processed_count = parsed
+ .as_ref()
+ .and_then(|v| v.get("processed_count"))
+ .and_then(|v| v.as_u64())
+ .unwrap_or(0);
+ checkpoints.push(IndexCheckpointInfo {
+ index_type: index_type.to_string(),
+ last_sequence,
+ processed_count,
+ });
+ }
+ Ok(None) => {}
+ Err(e) => {
+ tracing::warn!(key, error = %e, "failed to read index checkpoint");
+ }
+ }
+ }
+ GetIndexCheckpointsResponse {
+ checkpoints,
+ outbox_head: self.storage.outbox_head(),
+ }
+ }
+
/// Convert proto EventRole to domain EventRole
fn convert_role(proto_role: ProtoEventRole) -> EventRole {
match proto_role {
@@ -741,6 +785,16 @@ impl MemoryService for MemoryServiceImpl {
}
}
+ /// Read BM25/vector index checkpoints and the outbox head.
+ ///
+ /// Phase 60-01: the live-backend LOCOMO harness polls this instead of sleeping.
+ async fn get_index_checkpoints(
+ &self,
+ _request: Request,
+ ) -> Result, Status> {
+ Ok(Response::new(self.read_index_checkpoints()))
+ }
+
/// Get topic graph status and statistics.
///
/// Per TOPIC-08: Returns topic graph availability and stats.
@@ -1546,4 +1600,58 @@ mod tests {
let event = MemoryServiceImpl::convert_event(proto).unwrap();
assert!(event.agent.is_none()); // Empty string treated as None
}
+
+ #[tokio::test]
+ async fn test_get_index_checkpoints_empty_store() {
+ let (service, _temp) = create_test_service();
+ let resp = service
+ .get_index_checkpoints(Request::new(GetIndexCheckpointsRequest {}))
+ .await
+ .unwrap()
+ .into_inner();
+ assert_eq!(resp.outbox_head, 0);
+ assert!(resp.checkpoints.is_empty());
+ }
+
+ #[tokio::test]
+ async fn test_get_index_checkpoints_reads_bm25_and_outbox_head() {
+ let (service, _temp) = create_test_service();
+
+ let request = Request::new(IngestEventRequest {
+ event: Some(ProtoEvent {
+ event_id: ulid::Ulid::new().to_string(),
+ session_id: "session-123".to_string(),
+ timestamp_ms: chrono::Utc::now().timestamp_millis(),
+ event_type: ProtoEventType::UserMessage as i32,
+ role: ProtoEventRole::User as i32,
+ text: "checkpoint probe".to_string(),
+ metadata: HashMap::new(),
+ agent: None,
+ }),
+ });
+ service.ingest_event(request).await.unwrap();
+
+ let checkpoint = serde_json::json!({
+ "index_type": "bm25",
+ "last_sequence": 0,
+ "processed_count": 1,
+ "last_processed_time": 0,
+ "created_at": 0,
+ });
+ service
+ .storage
+ .put_checkpoint("index_bm25", &serde_json::to_vec(&checkpoint).unwrap())
+ .unwrap();
+
+ let resp = service
+ .get_index_checkpoints(Request::new(GetIndexCheckpointsRequest {}))
+ .await
+ .unwrap()
+ .into_inner();
+ assert_eq!(resp.outbox_head, 1);
+ assert_eq!(resp.checkpoints.len(), 1);
+ assert_eq!(resp.checkpoints[0].index_type, "bm25");
+ assert_eq!(resp.checkpoints[0].last_sequence, 0);
+ assert_eq!(resp.checkpoints[0].processed_count, 1);
+ }
}
diff --git a/crates/memory-storage/src/db.rs b/crates/memory-storage/src/db.rs
index ea0c7e7..38c4743 100644
--- a/crates/memory-storage/src/db.rs
+++ b/crates/memory-storage/src/db.rs
@@ -93,6 +93,11 @@ impl Storage {
Ok(0)
}
+ /// Next outbox sequence that will be assigned.
+ pub fn outbox_head(&self) -> u64 {
+ self.outbox_sequence.load(Ordering::SeqCst)
+ }
+
/// Get next outbox sequence number
fn next_outbox_sequence(&self) -> u64 {
self.outbox_sequence.fetch_add(1, Ordering::SeqCst)
@@ -1082,6 +1087,23 @@ mod tests {
assert!(entries.is_empty());
}
+ #[test]
+ fn test_outbox_head_tracks_next_sequence() {
+ let (storage, _temp) = create_test_storage();
+ assert_eq!(storage.outbox_head(), 0);
+
+ let event_id = ulid::Ulid::new().to_string();
+ let outbox_entry = memory_types::OutboxEntry::for_index(event_id.clone(), 1000);
+ storage
+ .put_event(&event_id, b"test event", &outbox_entry.to_bytes().unwrap())
+ .unwrap();
+
+ assert_eq!(storage.outbox_head(), 1);
+ let entries = storage.get_outbox_entries(0, 10).unwrap();
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].0, 0);
+ }
+
#[test]
fn test_get_outbox_entries_after_event() {
let (storage, _temp) = create_test_storage();
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
index cac601f..923ad21 100644
--- a/docs/RELEASING.md
+++ b/docs/RELEASING.md
@@ -23,9 +23,14 @@ grep -A2 '\[workspace.package\]' Cargo.toml # version must equal the tag minus
# Tag that SHA, not a local name that might have drifted:
SHA="$(git rev-parse origin/main)"
git tag -a vX.Y.Z "$SHA" -m "Release vX.Y.Z"
+git rev-parse 'vX.Y.Z^{commit}' # must equal $SHA
git push origin vX.Y.Z
```
+zsh: quote the peel (`'vX.Y.Z^{commit}'`). `^` is history expansion, and
+with `interactivecomments` a bare `#` starts a comment — both will silently
+rewrite the command. bash users can copy the block as-is.
+
Pushing a tag matching `v[0-9]+.[0-9]+.[0-9]+` starts
[`.github/workflows/release.yml`](../.github/workflows/release.yml).
diff --git a/docs/benchmarks.md b/docs/benchmarks.md
index d7fee64..bc46aa7 100644
--- a/docs/benchmarks.md
+++ b/docs/benchmarks.md
@@ -171,6 +171,31 @@ cargo run -p memory-bench -- all --backend mock --output benchmarks/results/cust
`--backend cli` shells out to a running `memory` daemon; `memory add` /
`memory search` failures abort the run (a dead daemon is not accuracy 0.0).
+## LOCOMO live backend (Phase 60-01)
+
+`--backend cli` on `memory-bench locomo` defaults to
+`--isolation daemon-per-conversation`: for each conversation the harness
+creates a tempdir, spawns `memory-daemon start --db-path --port
+--pid-file /daemon.pid`, waits until `memory-daemon query checkpoints`
+answers, ingests, then **polls GetIndexCheckpoints** until the BM25
+checkpoint covers the outbox head (timeout 5 minutes). Result JSON records
+`"isolation": "per-conversation daemon"` and per-conversation
+`drain_wait_ms`.
+
+`--isolation shared` is local debugging only. It prints a bleed caveat and
+must not be committed.
+
+There is no `std::thread::sleep` standing in for drain. Poll interval is a
+channel `recv_timeout`.
+
+```bash
+# CI / local live-backend smoke (1 conversation, mock judge, spawned daemon)
+cargo run -p memory-bench -- locomo \
+ --dataset benchmarks/fixtures/locomo-smoke.json \
+ --backend cli --scorer mock \
+ --isolation daemon-per-conversation
+```
+
Metrics:
| Metric | What it is |
@@ -218,8 +243,13 @@ cargo run -p memory-bench -- smoke --output benchmarks/results/locomo-smoke.json
# Full dataset, still not a LOCOMO score
cargo run -p memory-bench -- locomo --dataset locomo-data --scorer mock
-# The only path that may be labeled locomo_llm_judge
-cargo run -p memory-bench -- locomo --dataset locomo-data --scorer llm-judge --output benchmarks/results/locomo-$(date -u +%F).json
+# Live daemon, isolated per conversation (the 60-02 path)
+cargo run -p memory-bench -- locomo --dataset locomo-data --backend cli --scorer llm-judge \
+ --isolation daemon-per-conversation --output benchmarks/results/locomo-$(date -u +%F).json
+
+# Cost-capped dry run
+cargo run -p memory-bench -- locomo --dataset locomo-data --backend cli --scorer llm-judge \
+ --limit-questions 200 --output benchmarks/results/locomo-$(date -u +%F)-partial.json
```
`memory add --timestamp RFC3339 --session-id ID --role user|assistant` exists
diff --git a/docs/plans/v3.2-prove-it-plan.md b/docs/plans/v3.2-prove-it-plan.md
index 5159a79..cadcf9c 100644
--- a/docs/plans/v3.2-prove-it-plan.md
+++ b/docs/plans/v3.2-prove-it-plan.md
@@ -1,366 +1,712 @@
-# v3.2 "Prove It" — Review and Plan of Action
+# v3.2 "Prove It" — Detailed Plan
**Date:** 2026-09-01
**Verified against:** `main` @ `d6c8ac7`, release `v3.1.0` (run 33458631284)
-**Status:** Adopted 2026-09-01 — GSD milestone v3.2, Phase 59 executing
-**Issues:** #39 (LOCOMO), #40 (vector/topic quality), #41 (backfill), #42 (install-service), #43 (TOC rebuild), #44 (cross-encoder / 62)
+**Status:** Adopted 2026-09-01 — canonical GSD spec (Claude artifact
+d559ad58 / commit `18d6638`). Phase 59 landed as
+[#45](https://github.com/SpillwaveSolutions/agent-memory/pull/45)
+(`2ecc3c1`). Phase 60-01 executing.
+**Issues (8, labelled `v3.2`):** #39 LOCOMO (60-02) · #40 vector quality
+(60-03) · #47 topic quality (60-03) · #41 backfill (61-01) · #42
+install-service (61-02) · #43 TOC rebuild (61-04) · #48 uninstall/status
+(61-05) · #44 cross-encoder (62)
+**Format:** follows `docs/plans/v3.1-make-it-true-plan.md` — every plan item
+names the files it touches and carries acceptance criteria testable at PR time.
---
-## 1. Where we are
+## 0. Thesis
-Everything in this section was checked against the repository, the release,
-or the workflow history today, not recalled from planning docs.
+v3.1 made the project's claims true. v3.2 makes them **provable and
+operable**: a real benchmark number committed next to the competitor figures,
+a quality artifact behind every "Solid" in the README status table, and a
+daemon someone can run for a week without a terminal open. No new retrieval
+capability ships unless a number says it is the bottleneck.
-| Fact | Value |
-|---|---|
-| Latest release | `v3.1.0`, published 2026-09-01 01:45 UTC, **5 of 5 platforms** — first full-platform release in the repo's history |
-| Code | 20 crates, 64,626 LOC Rust |
-| Tests | 1,205 workspace + 60 e2e cargo tests green; 114 bats CLI tests in 16 files; Tier 2 weekly run passed once |
-| Open GitHub issues | **0** — the backlog exists only in `.planning/` |
-| Committed benchmark results | 2 files, both **mock backend + mock judge** (`custom-harness-mock.json`, `locomo-smoke.json`, 4 questions) |
-| README status table | Solid ×6 · Works ×2 · Experimental ×2 · Not implemented ×3 |
-| Unmerged work | `origin/gsd/phase-56-import-bootstrap` — 81 commits, "v3.1 Memory Export/Import", March 2026, never merged |
-
-### What v3.1 delivered
+Same rules as v3.1, unchanged: execution-evidence (run-dependent
+requirements cite a committed artifact), reachability (`cargo tree -i` shows
+a shipped dependent), `task pr-precheck` green on every PR.
-v3.1 was a truth milestone. It shipped no capability; it made the claims match
-the code: the orchestrator is reachable, hybrid fusion actually fuses,
-explainability reports what ran, the 64.6 s TOC figure was retracted, the
-benchmark harness stopped calling a substring metric "LOCOMO", and the repo
-gained a README, LICENSE, positioning doc, and a supported-surface tier.
-
-### What v3.1 deliberately left open
+---
-From the README, CHANGELOG, and positioning doc's own words:
+## 1. Where we are (verified today)
-- No real-backend / real-judge LOCOMO run, so **no comparative claim anywhere**
-- Vector retrieval quality and topic-graph clustering are **not benchmarked**
-- No BM25 backfill for pre-v3.1 events
-- Cross-encoder rerank, offline TOC rebuild, background daemonization:
- **not implemented**, and now say so instead of pretending
+| Fact | Value | How checked |
+|---|---|---|
+| Latest release | `v3.1.0`, 2026-09-01 01:45 UTC, **5/5 platforms** | release page, run 33458631284 |
+| Code | 20 crates, 64,626 LOC Rust | `find crates -name '*.rs' \| xargs cat \| wc -l` |
+| Tests | 1,205 workspace + 60 e2e cargo tests green; 114 bats tests / 16 files; Tier 2 weekly ran once, passed | local run on clean `target/`; Actions |
+| GitHub issues | **0 ever** (open or closed) | API |
+| Benchmark artifacts | 2, both mock backend + mock judge | `benchmarks/results/` |
+| README status | Solid ×6 · Works ×2 · Experimental ×2 · Not implemented ×3 | README |
+| Unmerged work | one nested line of 99 commits on `origin/gsd/phase-58-claude-registration-metadata` | `git rev-list --count origin/main..` |
### What was planned before this, and where it went
-There is no GitHub issue holding a plan — the repo has never had an issue.
-The earlier plans live in three places:
-
| Plan | Where | Status today |
|---|---|---|
-| **v3.1 "Make It True"** (PR #31, `docs/plans/v3.1-make-it-true-plan.md`) | merged | Shipped as v3.1.0. This document is its sequel |
-| **March roadmap** — v3.1 *Memory Export/Import* (Phases 54–56), v3.2 *Plugin Installer & OpenCode Converter* (57–59), v3.3+ (`--for all`, `--all`, Gemini/Codex/Copilot registration) | `.planning/ROADMAP.md` on `origin/gsd/phase-58-claude-registration-metadata` | **Never merged.** Superseded 2026-03-26 when v3.1 was re-planned. Export/import: built. Claude Code registration + plugin metadata (CREG/META): built, all requirements checked. OpenCode converter: built, now obsolete. Uninstall + Status (UNINST/STAT): not started. None of it is on `main` — `memory-installer` has no registration, uninstall, or status today |
-| **v3 design spec "does not include"** (`docs/superpowers/specs/2026-03-21-v3-competitive-parity-design.md`) | merged | REST/HTTP endpoint, Python SDK, memory views UI, cross-encoder rerank — all still future. Cross-encoder is Phase 62 below; the other three stay **v3.3+** because they are new capabilities and v3.2's job is proof and operability |
-
-The March line is one nested branch of 99 commits (import-bootstrap ⊂
-opencode-converter ⊂ claude-registration), not three efforts, and it diverges
-from `main` in `memory-orchestrator` and `memory-bench` as well as the
-installer — it was cut before v3.0's PRs were squash-merged. That is why
-59-02 produces a conflict map before anyone cherry-picks.
+| **v3.1 "Make It True"** — PR #31 | merged | Shipped as v3.1.0. This document is its sequel |
+| **March roadmap** — v3.1 *Memory Export/Import* (54–56), v3.2 *Plugin Installer & OpenCode Converter* (57–59), v3.3+ (`--for all`, `--all`, Gemini/Codex/Copilot registration) | `.planning/ROADMAP.md` on the `gsd/` branches | **Never merged**; superseded 2026-03-26. Export/import built. Claude Code registration + plugin metadata built (all CREG/META boxes ticked). OpenCode converter built, now obsolete. Uninstall + Status not started. `main` has none of it |
+| **v3 design spec "does not include"** | `docs/superpowers/specs/2026-03-21-v3-competitive-parity-design.md` | REST endpoint, Python SDK, memory views UI → **v3.3+**. Cross-encoder rerank → Phase 62 (conditional) |
+
+What the March line actually contains, by `git diff --stat origin/main...`:
+
+- `crates/memory-service/src/backup.rs` (+308), `import.rs` (+280),
+ `tests/import_round_trip.rs` (+130), deltas to `ingest.rs`/`query.rs`
+- `proto/memory.proto` (+110): the export/import/streaming RPCs
+- `crates/memory-storage/src/db.rs` (+35), `episodes.rs` (+41)
+- `crates/memory-cli`: `commands/timeline.rs` (+212), `output.rs` (+265)
+- `crates/memory-installer/src/converters/claude.rs` (+584 — registration),
+ `opencode.rs` (+779 — obsolete), `tests/e2e_converters.rs` (+117)
+- A **parallel `memory-orchestrator`** (`orchestrator.rs`, `fusion.rs`,
+ `rerank.rs`, `expand.rs`…) — the branch was cut before #28 landed main's
+ orchestrator. This is a duplicate, not a modification. **Never cherry-pick
+ it.**
---
## 2. Findings
-Severity: **blocker** stops the next thing you want to do · **high** will bite
-within a milestone · **medium** debt worth scheduling.
-
-### F1 — The release process has no guardrails `blocker`
-
-Today a `git push origin v3.1.0` shipped a tag from a stale local ref. The
-Release workflow built `acc7294` — `Cargo.toml` said `2.7.0`, none of Phases
-54–57 present — labelled it "Release 3.1.0", and published it. It was public
-for 17 minutes. Nothing in the pipeline checks that the tagged commit is on
-`main`, that the crate version matches the tag, or that all five platforms
-built (`release: if: always()` publishes whatever succeeded).
-
-The four-binary archive guard added in #37 ran for the first time today and
-held. The rest of the pipeline has no equivalent.
-
-### F2 — 81 commits of unmerged work nobody is tracking `high`
-
-`origin/gsd/phase-56-import-bootstrap` holds a complete earlier "v3.1":
-daily markdown export, structured JSONL backup with incremental support,
-import/bootstrap with round-trip validation, and the codebase's first gRPC
-streaming RPCs. It was finished 2026-03-24 and superseded two days later when
-v3.1 was re-planned. The same line continues through
-`gsd/phase-57-opencode-converter-registration` (obsolete — Phase 57 of the
-*current* roadmap removed OpenCode) into
-`gsd/phase-58-claude-registration-metadata`, which holds a finished Claude
-Code plugin registration (`known_marketplaces.json`, `installed_plugins.json`,
-`settings.json`, `.claude-plugin/plugin.json`) that `main` still lacks.
-
-This matters beyond hygiene: **an import path is a backfill path.** The BM25
-backfill gap (F5) may already be half-built on a branch nobody remembers.
-
-### F3 — The real benchmark is one command away and has never been run `blocker`
-
-The harness is done. `memory-bench locomo --backend cli --scorer llm-judge`
-is wired end to end; `benchmarks/scripts/download-locomo.sh` fetches the real
-dataset; the judge honors `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` and records
-the model id. The only committed results are mock/mock.
-
-Every comparison claim in the positioning doc is gated on this run by the
-doc's own rule ("until one is committed next to the claim"). The Show HN
-draft cannot answer the first question it will get.
-
-One known gap in the harness for a real run: with `--backend cli` all
-conversations share the daemon unless it is restarted between them (the
-harness's own caveat). Per-conversation isolation on the live backend is a
-task, not an assumption.
-
-### F4 — Retrieval quality has evidence for one layer out of three `high`
-
-BM25 has fixtures with labelled relevant items (recall@k in the custom
-harness). Vector search has **zero** quality tests — `grep` for recall,
-precision, MRR, or nDCG across `memory-vector`, `memory-search`, and
-`e2e-tests` finds only lifecycle code. The topic graph's clustering is
-unbenchmarked by the README's own admission. The status table's "Solid" for
-vector search is accurate about the mechanism and silent about whether it
-returns the right things.
-
-### F5 — Operational gaps a real user hits in week one `high`
-
-- **No backfill.** Anyone with pre-v3.1 events has empty `text_preview`
- forever, or starts a new store. `admin rebuild-bm25` is a prune.
-- **No daemonization.** `--background` exits non-zero. Users need a
- launchd/systemd unit or a terminal they never close.
-- **No offline TOC rebuild.** If the rollup job misses, there is no repair.
-- **Panic surface.** Phase 54-06 cut lock-poisoning sites from 27 to 5. There
- are 266 `unwrap()`/`expect()` calls in non-test daemon and service code.
- Most are surely on infallible paths; none have been audited as such.
-
-### F6 — The planning source of truth is stale `medium`
-
-`.planning/PROJECT.md` — which CLAUDE.md names as the home of architectural
-decisions — says "Version: v3.0 (In Progress)", lists OpenCode among the
-adapters, and lists "API-based summarizer wiring" as deferred though #27
-shipped it. `STATE.md` and `MILESTONES.md` are current; `PROJECT.md` is what
-a new contributor reads first. And with zero GitHub issues, the entire backlog
-is invisible to anyone outside the planning folder.
-
-### F7 — Launch timing is coupled to F3 `medium`
-
-The blog post is about process and stands on its own. The Show HN, r/rust, and
-r/LocalLLaMA drafts are product posts, and the product's positioning doc
-forbids the comparison those audiences will ask for first.
+### F1 · Release pipeline has no guardrails — `blocker`
+
+Evidence: today's first `v3.1.0` push built `acc7294` (`Cargo.toml` = 2.7.0,
+no Phase 54–57 code), published "Release 3.1.0", public for 17 minutes.
+`.github/workflows/release.yml`:
+- `Get version` (line 49) derives the version from the tag name only
+- no check that `GITHUB_SHA` is an ancestor of `main`
+- no check that `workspace.package.version` == tag
+- `release: if: always() && !cancelled()` (line 181) publishes whatever
+ platforms succeeded; 7 of 13 historical runs were partial or failed
+- `generate_release_notes: true` (line 230) produces a PR-title list, not
+ the CHANGELOG entry
+
+The one guard that exists — four binaries per archive, added in #37 — ran for
+the first time today and held.
+
+### F2 · 99 commits of unmerged work — `high`
+
+Evidence: section 1. The export/import feature is finished and tested
+(`import_round_trip.rs`); the Claude Code plugin registration is finished.
+Neither is on `main`. **An import path is a backfill path** (F5).
+
+### F3 · The real benchmark has never been run — `blocker`
+
+Evidence: `crates/memory-bench/src/cli.rs:14` (`--backend mock|cli`),
+`judge.rs:101–126` (`--scorer llm-judge` reads `OPENAI_API_KEY` /
+`ANTHROPIC_API_KEY`, records model id), `main.rs:137–147` (cli backend
+ingests via `memory add` and evaluates per question),
+`benchmarks/scripts/download-locomo.sh`. Only `locomo-smoke.json`
+(4 questions, mock/mock) is committed.
+
+Harness gap for a real run: `RunConfig` (`runner.rs:40`) carries one
+`endpoint`; `run_locomo` (`main.rs:134`) loops conversations against it. The
+mock path gets a fresh `MockStore` per conversation; the cli path shares one
+daemon, so conversation N sees N−1's events — cross-conversation bleed the
+harness's own caveat names.
+
+### F4 · Retrieval quality evidence exists for one layer — `high`
+
+Evidence: custom-harness fixtures (`benchmarks/fixtures/*.toml`) carry
+`relevant = [...]` and report `recall_at_k` — but every committed fixture is
+answerable by token overlap (`expected_contains = ["JWT"]` with `JWT` in the
+source). No fixture requires semantic retrieval. `grep -rl
+"recall\|ndcg\|mrr" crates/memory-vector crates/memory-search
+crates/e2e-tests` → lifecycle code only. `memory-topics` exposes
+`cluster()` and `create_topics()` (`extraction.rs:51,96`) with no quality
+metric anywhere.
+
+### F5 · Operational gaps — `high`
+
+- **Backfill:** `IndexingPipeline::process_until_caught_up`
+ (`memory-indexing/src/pipeline.rs:307`) only drains the outbox forward
+ from `IndexCheckpoint`; there is no path that re-reads events already past
+ the checkpoint. `admin rebuild-bm25` (`memory-daemon/src/cli.rs:311`) is a
+ prune.
+- **Daemonization:** `commands.rs:574` rejects `--background` with guidance
+ to use systemd/launchd; nothing generates the unit.
+- **TOC rebuild:** `AdminCommands::RebuildToc` (`cli.rs:231`) exits
+ non-zero.
+- **Panic surface:** 5 `lock().unwrap()` sites remain
+ (`vector_updater.rs:459`, `novelty.rs:709,745`, `sync.rs:43` + 1). A
+ line-based grep counts 266 `unwrap()/expect()` in daemon+service, but it
+ cannot exclude inline `#[cfg(test)]` modules (e.g. `retrieval.rs:978+` is
+ test code). **The real production count is unknown.** That is the first
+ task of 61-03.
+
+### F6 · Planning source of truth is stale — `medium`
+
+`.planning/PROJECT.md`: "Version: v3.0 (In Progress)"; adapter list includes
+OpenCode (removed in Phase 57); "API-based summarizer wiring" under Deferred
+though #27 shipped it (`commands.rs:394` `resolve_api_key`). Zero GitHub
+issues means the backlog is invisible outside `.planning/`.
+
+### F7 · Launch is coupled to F3 — `medium`
+
+The blog post stands alone. The Show HN / r/rust / r/LocalLLaMA drafts are
+product posts; the positioning doc's own rule forbids the comparison those
+audiences ask first.
+
+---
+
+## 3. Target state (milestone exit)
+
+1. One committed `locomo_llm_judge` result on the full dataset, real backend,
+ hardware/model/dataset-SHA recorded, next to the competitor figures with a
+ commensurability note.
+2. Every "Solid" row in the README status table cites a committed quality
+ artifact, or is relabelled.
+3. `memory-daemon install-service` + `admin backfill-index` + `admin
+ rebuild-toc` exist and are covered by e2e/bats; no `unwrap()` on a
+ request path user input can reach.
+4. `PROJECT.md` accurate; known gaps are GitHub issues; no orphan `gsd/`
+ branches; release pipeline refuses a bad tag.
---
-## 3. Where the project should be
+## 4. Plan
-By the end of v3.2, a stranger arriving from a Show HN link should find:
+Owner is **agent** unless it needs credentials, hardware, or a judgment
+call. Effort in agent sessions (one session ≈ one merged PR of v3.1 size).
+
+### Phase 59 — Guardrails and Inventory (3 plans · 3 sessions)
+
+#### 59-01 · Release pipeline checks
+
+**Why:** F1. Today's incident is reproducible by anyone with a stale local
+tag.
+
+**Files:** `.github/workflows/release.yml`; new `docs/RELEASING.md`;
+`CLAUDE.md` Release Process section.
+
+**Steps:**
+1. Add a `verify` job before `build`, `runs-on: ubuntu-latest`, that:
+ - `git fetch origin main` and fails unless
+ `git merge-base --is-ancestor "$GITHUB_SHA" origin/main`
+ - reads `workspace.package.version` with `cargo metadata --no-deps
+ --format-version 1 | jq -r '.packages[0].version'` (or
+ `grep -m1 '^version' Cargo.toml`) and fails unless it equals
+ `${GITHUB_REF_NAME#v}`; on `workflow_dispatch` compares to the input
+ - fails unless `CHANGELOG.md` contains a heading for that version
+2. `build: needs: verify`.
+3. `release:` — replace `if: always() && !cancelled()` with `needs: [verify,
+ build]` and no `if:`. A failed platform fails the release. Keep the
+ four-binary check from #37.
+4. Replace `generate_release_notes: true` with `body_path:` pointing at a
+ file the job extracts from the matching `CHANGELOG.md` section
+ (`awk '/^## \[?'"$VER"'/{f=1;next} /^## /{f=0} f'`).
+5. `docs/RELEASING.md`: the explicit-SHA procedure —
+ `git tag -a vX.Y.Z -m "..."`, `git rev-parse vX.Y.Z^{commit}`
+ before push, what the guard will refuse and why. Note the zsh
+ `interactivecomments` trap. Update `CLAUDE.md` to point at it.
+
+**Acceptance:**
+- [x] A tag on a commit not in `main` fails at `verify` before any build
+ starts (unit tests in `scripts/release-guards-test.sh`; live verify is
+ `workflow_dispatch` + `dry_run: true` — `v0.0.0-guardtest` does **not**
+ match the tag trigger `v[0-9]+.[0-9]+.[0-9]+`)
+- [x] A tag whose version ≠ `Cargo.toml` fails at `verify`
+- [x] A build-job failure on one platform yields **no** GitHub release
+ (`if: success()`, not `always()`)
+- [x] The release body is the CHANGELOG section verbatim
+- [x] `docs/RELEASING.md` exists and `CLAUDE.md` links it
+
+**Verify:** the guard-test tag runs above; `actionlint` clean; YAML parses.
+
+**Effort:** 1 session · **Owner:** agent (tag pushes for the test: maintainer)
-1. **A number.** One committed LOCOMO LLM-judge result on the real dataset,
- real backend, documented hardware and model, sitting next to the
- competitor figures with an honest note on commensurability.
-2. **Evidence for every "Solid".** Each retrieval layer has a quality fixture
- and a committed result, or its label drops to "Works".
-3. **Something they can run for a week.** Daemon starts at login, existing
- events get indexed, and the daemon does not panic on the paths a user can
- reach.
-4. **A repo that looks alive.** Issues for the known gaps, an accurate
- `PROJECT.md`, no orphan branches.
+---
-Nothing on that list is a new capability. v3.2 is the second half of v3.1's
-thesis: v3.1 made the claims true; v3.2 makes them **provable**.
+#### 59-02 · Orphan branch triage
+
+**Why:** F2. Decide with a conflict map, not a feeling.
+
+**Files (read-only this plan):** the three `gsd/` branches;
+output `docs/plans/march-branch-triage.md`.
+
+**Steps:**
+1. For each of `backup.rs`, `import.rs`, `import_round_trip.rs`, the
+ `ingest.rs`/`query.rs`/`db.rs`/`episodes.rs` deltas, the proto additions,
+ the `memory-cli` timeline/output deltas, and `converters/claude.rs`:
+ `git diff origin/main...origin/gsd/phase-58-claude-registration-metadata
+ -- ` and record: applies clean / conflicts / superseded.
+2. For the proto additions: check message and RPC names against
+ `proto/memory.proto` on `main` (no `Export*`/`Import*`/`Backup*` RPCs
+ exist today, so likely clean; the streaming RPC style needs review
+ against tonic version on `main`).
+3. Explicitly mark the branch's `memory-orchestrator` and `memory-bench`
+ directories **do not port** (parallel implementations).
+4. Write the recommendation: cherry-pick list with commit SHAs, in order,
+ for 61-01 (export/import) and 61-05 (registration); estimated conflict
+ count per file.
+5. After maintainer decision: delete
+ `gsd/phase-57-opencode-converter-registration` immediately; delete the
+ other two once 61-01 and 61-05 merge.
+
+**Acceptance:**
+- [x] `docs/plans/phase-59-orphan-branch-triage.md` lists every ported file with
+ clean/conflict status and the exact `git cherry-pick`/`git checkout
+ -- ` sequence
+- [x] Maintainer decision recorded in the doc (port / rewrite / drop)
+- [x] OpenCode branch deleted (`origin/gsd/phase-57-opencode-converter-registration`,
+ 2026-09-01). The other two `gsd/` branches stay until 61-01 and 61-05 merge.
+
+**Effort:** 1 session · **Owner:** agent; decision: maintainer
---
-## 4. Plan
+#### 59-03 · Planning truth and a public backlog
+
+**Why:** F6.
+
+**Files:** `.planning/PROJECT.md`, `.planning/ROADMAP.md`,
+`.planning/STATE.md`, `.planning/REQUIREMENTS.md`; GitHub issues.
+
+**Steps:**
+1. `PROJECT.md`: Current State → v3.1 shipped 2026-09-01; Current Milestone
+ → v3.2 (this plan); adapters list → Claude Code, Codex (Tier 1), Gemini,
+ Copilot (Tier 2); move "API-based summarizer wiring" to Validated with
+ the #27 reference; add "true daemonization" → superseded by
+ `install-service` (61-02) once decided.
+2. `REQUIREMENTS.md`: replace the v3.0 block with v3.2 requirement IDs
+ (REL-01..04, BENCH-10..13, QUAL-01..03, OPS-01..05, INST-01..03) mapped
+ 1:1 to the plans below; keep the Future (v3.3+) list.
+3. Open one GitHub issue per known gap, each linking its README row and its
+ plan ID: real LOCOMO run, vector quality, topic quality, backfill,
+ daemonization, TOC rebuild, cross-encoder, uninstall/status. Label
+ `v3.2`.
+4. `ROADMAP.md`/`STATE.md`: add v3.2 phases 59–62 in the existing format.
+
+**Acceptance:**
+- [x] Current State is v3.1 shipped / v3.2 executing; OpenCode is not a
+ supported adapter. Historical Validated rows in PROJECT.md still
+ mention OpenCode as v2.1/v2.4 facts — those are not current-state.
+- [x] 8 open issues labelled `v3.2`, each with a plan ID (#39 #40 #41 #42
+ #43 #44 #47 #48)
+- [x] Every requirement ID in `REQUIREMENTS.md` appears in exactly one plan
+
+**Effort:** 1 session · **Owner:** agent
+
+---
+
+### Phase 60 — Real Numbers (3 plans · 4 sessions + 1 maintainer run)
+
+#### 60-01 · Live-backend isolation for LOCOMO
+
+**Why:** F3. Without isolation the number is contaminated and cannot be
+committed.
+
+**Files:** `crates/memory-bench/src/runner.rs` (`RunConfig`),
+`main.rs` (`run_locomo`, `evaluate_sample_cli`), `locomo.rs`
+(`ingest_sample_cli`), `cli.rs`; `crates/memory-daemon/src/cli.rs` (Start
+args: `--port`, database path override already exist).
+
+**Design (pick A; B is fallback):**
+- **A. Spawn-per-conversation.** `RunConfig` gains `daemon_bin:
+ Option`, `isolation: Isolation::{Shared, DaemonPerConversation}`.
+ For each conversation: create a `tempfile::tempdir()`, spawn
+ `memory-daemon start --db-path --port `, wait for
+ `memory-daemon status` healthy, run ingest + evaluate against that
+ endpoint, send `stop`, drop the dir. Result JSON records
+ `"isolation": "per-conversation daemon"`.
+- **B. Reset RPC.** Add `AdminReset` gated by a `--allow-reset` daemon flag.
+ Rejected unless A proves infeasible: it adds a destructive RPC to the
+ daemon for the benefit of a benchmark.
+- **Drain wait:** replace any sleep with polling `GetVectorIndexStatus` /
+ a new lightweight `GetIndexCheckpoints` RPC until BM25 and vector
+ checkpoints ≥ the outbox sequence after ingest; timeout 5 min with a
+ loud error. Record wait time per conversation in the result.
+
+**Steps:**
+1. Implement A behind `--isolation daemon-per-conversation` (default for
+ `--backend cli`; `shared` remains for local debugging and prints the
+ bleed caveat).
+2. Add the checkpoint poll. If no RPC exposes checkpoints, add
+ `GetIndexCheckpoints` to `proto/memory.proto` + `memory-service` — read
+ only, small.
+3. Extend `smoke` so CI runs the 1-conversation fixture under `--backend
+ cli` with a spawned daemon and the mock judge (`ci.yml` new job
+ `bench-cli-smoke`, Linux only, after `build`).
+4. Update `docs/benchmarks.md` "Run" and "Modes".
+
+**Acceptance:**
+- [ ] `memory-bench locomo --backend cli --scorer mock --dataset
+ benchmarks/fixtures/locomo-smoke.json` completes with
+ `isolation: per-conversation daemon` in the output and a
+ `drain_wait_ms` per conversation
+- [ ] A unit test asserts two conversations ingested under isolation A do
+ not retrieve each other's turns (mirror of
+ `mock_stores_do_not_bleed`, `runner.rs:346`)
+- [ ] CI job `bench-cli-smoke` green
+- [ ] No `std::thread::sleep` remains in the cli-backend path
+
+**Effort:** 1 session · **Owner:** agent
+
+---
+
+#### 60-02 · The run
+
+**Why:** F3, F7. The single most valuable artifact the project can produce.
+
+**Prereqs:** 60-01 merged; `OPENAI_API_KEY` (or Anthropic); a machine you
+are willing to name in the result; `--release` build.
+
+**Steps:**
+1. `benchmarks/scripts/download-locomo.sh` → `locomo-data/locomo10.json`;
+ record its SHA-256.
+2. Dry run, cost cap: `memory-bench locomo --dataset locomo-data --backend
+ cli --scorer llm-judge --limit-questions 200 --output
+ benchmarks/results/locomo-2026-MM-DD-partial.json` (add `--limit-questions`
+ in 60-01 if absent). Check the judge's recorded cost/tokens.
+3. Full run to `benchmarks/results/locomo-2026-MM-DD.json`. Expect
+ ~2,000 questions; under ~$10 on `gpt-4o-mini`; 1–2 h wall clock
+ dominated by ingest + drain.
+4. Commit the result. Update `docs/benchmarks.md` "Committed result" table
+ with hardware, profile (`--release`), model, temperature 0, dataset
+ SHA, isolation mode, and the per-type breakdown (single-hop / multi-hop /
+ temporal / adversarial) the harness already emits.
+5. Update the positioning doc "Benchmarks: what we can and cannot say" and
+ the Claims Ledger row "Our own committed benchmark artifacts".
+6. Decision gate (from v3.1 56-03, unchanged): publish the number whatever
+ it is; the only thing that can hold it is a methodology defect.
+
+**Acceptance:**
+- [ ] `benchmarks/results/locomo-*.json` with `"metric":
+ "locomo_llm_judge"`, `"isolation": "per-conversation daemon"`,
+ non-null `model`, `hardware`, `dataset_sha256`
+- [ ] `docs/benchmarks.md` and the positioning doc cite the file path
+- [ ] Per-type scores present (feeds the Phase 62 gate)
+
+**Effort:** 1 maintainer session · **Owner:** maintainer (agent prepares
+the exact commands and reviews the artifact)
+
+---
+
+#### 60-03 · Vector and topic quality fixtures
+
+**Why:** F4. "Solid" needs an artifact.
+
+**Files:** `benchmarks/fixtures/semantic-001.toml` (new),
+`benchmarks/fixtures/sessions/*.jsonl` (new sessions), `crates/memory-bench`
+(a `--layers bm25|vector|hybrid` switch on the custom harness),
+`crates/memory-topics` (a `metrics` module), `crates/e2e-tests/tests/
+topic_graph_test.rs`, README status table, positioning Claims Ledger.
+
+**Steps:**
+1. **Semantic fixture set (≥ 15 tests).** Each test's `relevant` items
+ share meaning but not tokens with the query — "token expiry policy" →
+ session text says "JWT lifetime"; "container orchestration cutover" →
+ "EKS migration". Build the sessions so a BM25-only run scores recall@5
+ < 0.4 on the set (that is the point) and record it.
+2. Harness switch: `memory-bench run --category semantic --layers
+ bm25|vector|hybrid` maps to `TeleportSearch` / `VectorTeleport` /
+ `HybridSearch`. Commit three result files.
+3. **Topic quality.** A labelled fixture of 60–100 short documents in 6–8
+ known clusters; `memory-topics::metrics::{purity, adjusted_rand_index}`
+ over `cluster()` output. Commit `benchmarks/results/topics-quality.json`.
+4. Status table: vector row cites the hybrid-vs-bm25 delta; topic row moves
+ from "Works · not benchmarked" to "Works · ARI x.xx" or stays "Works"
+ with the number. Positioning Claims Ledger gains both rows.
+
+**Acceptance:**
+- [ ] `benchmarks/results/semantic-{bm25,vector,hybrid}.json` committed;
+ hybrid recall@5 > bm25 recall@5 on the semantic set (if not, that is a
+ finding and the README changes accordingly)
+- [ ] `topics-quality.json` committed with purity and ARI
+- [ ] README rows for vector and topic graph link the artifacts
+- [ ] `cargo test -p memory-topics metrics` covers purity/ARI on a
+ hand-computed 3-cluster example
+
+**Effort:** 2 sessions · **Owner:** agent
+
+---
+
+### Phase 61 — Operate It (5 plans · 6 sessions)
+
+#### 61-01 · Backfill
+
+**Why:** F5. Every pre-v3.1 store is stuck.
+
+**Files:** `crates/memory-indexing/src/pipeline.rs`, `checkpoint.rs`;
+`crates/memory-daemon/src/cli.rs` (`AdminCommands::BackfillIndex`),
+`commands.rs`; `crates/memory-storage` (event iteration by sequence);
+`docs/UPGRADING.md`, README status table. If 59-02 kept
+`memory-service/src/import.rs`, reuse its event replay.
+
+**Design:** `memory-daemon admin backfill-index --index bm25|vector|all
+[--from-sequence N] [--batch 500] [--dry-run]`. Runs against a stopped
+daemon (takes the RocksDB lock) or via a new admin RPC if the daemon is up —
+choose stopped-only for v3.2 (simpler, and the daemon already has
+`stop`). Algorithm: iterate events from `--from-sequence` (default 0) in
+batches; for each batch call the `IndexUpdater` for the chosen index;
+commit; write `IndexCheckpoint` = last sequence processed. Idempotent
+(re-indexing an existing doc is an upsert in Tantivy and HNSW). Resumable
+by reading the checkpoint on restart. Progress `n/total` on stderr every
+batch.
+
+**Steps:**
+1. `IndexingPipeline::backfill(from: u64, batch: usize, indexes:
+ &[IndexType]) -> Result` beside
+ `process_until_caught_up`.
+2. CLI subcommand + wiring; refuse to run if the daemon lock is held, with
+ the message naming `memory-daemon stop`.
+3. Fixture: a RocksDB store produced by the **v3.0** daemon (build
+ `68ab122`, ingest 50 events, commit the directory under
+ `crates/e2e-tests/fixtures/store-v3.0/`).
+4. e2e: open fixture copy → `backfill-index --index all` → `TeleportSearch`
+ returns previews for all 50.
+5. Docs: README BM25 row drops the "no backfill" note; `UPGRADING.md` v3.1
+ section gains the command.
+
+**Acceptance:**
+- [ ] e2e above passes; a second `backfill-index` run reports 0 new
+ documents
+- [ ] `--dry-run` prints counts and writes nothing (checkpoint unchanged)
+- [ ] Interrupting mid-run (test sends SIGINT after batch 1) and re-running
+ resumes from the checkpoint
+- [ ] README and UPGRADING updated in the same PR
+
+**Effort:** 2 sessions · **Owner:** agent
+
+---
+
+#### 61-02 · Daemon lifecycle via service units
+
+**Why:** F5. Decision 3.
+
+**Files:** `crates/memory-daemon/src/cli.rs`
+(`Commands::{InstallService, UninstallService}`), `commands.rs`, new
+`service.rs`; `tests/cli/claude-code/*.bats`, `tests/cli/codex/*.bats`;
+`docs/setup/quickstart.md`.
+
+**Design:** `memory-daemon install-service [--port] [--db-path]` writes
+`~/Library/LaunchAgents/com.spillwave.memory-daemon.plist` (macOS,
+`launchctl bootstrap gui/$UID`) or
+`~/.config/systemd/user/memory-daemon.service` (Linux, `systemctl --user
+enable --now`). `uninstall-service` reverses it. Windows: exit non-zero with
+guidance (out of scope; Tier 2 at best). `--background` keeps exiting
+non-zero, now naming `install-service`.
-Four phases plus the launch side quest. Phases are ordered by dependency:
-guardrails before anything ships, numbers before anything is claimed,
-operability before anyone is invited to try it.
-
-Effort is in agent sessions (one focused session ≈ one merged PR of the size
-v3.1 produced). Owner is **agent** unless it needs credentials, hardware, or
-a judgment call, then **maintainer**.
-
-### Phase 59 — Guardrails and Inventory (3 plans · ~3 sessions)
-
-Small, first, and it protects everything after it.
-
-**59-01 Release pipeline checks** · agent · 1 session
-- In `release.yml`, before any build: fail unless the tagged commit is an
- ancestor of `origin/main`; fail unless `Cargo.toml` `workspace.package.version`
- equals the tag minus `v`
-- Change `release: if: always() && !cancelled()` to require all `build`
- jobs succeeded; a missing platform fails the release instead of publishing
- three archives
-- Generate release notes from the matching `CHANGELOG.md` section, not
- GitHub's auto-list of PR titles
-- Verify: push a deliberately wrong tag (`v9.9.9-test` on a non-main commit)
- and confirm the run fails at the guard, then delete it
-- Document the tag procedure in `docs/RELEASING.md` with the explicit-SHA
- form: `git tag -a vX.Y.Z ` — today's incident was a bare `git tag -a`
- colliding with a stale local ref
-
-**59-02 Orphan branch triage** · agent + maintainer decision · 1 session
-- Produce a one-page inventory of the three `gsd/` branches: what each
- contains, which files conflict with current `main`, and which parts are
- still wanted
-- Recommendation going in: **cherry-pick by feature**, not by branch —
- export/import (JSONL backup, import/bootstrap, streaming RPCs) as the
- foundation for 61-01, and Claude Code registration + plugin metadata
- (CREG-01..06, META-01..03) for 61-05. Skip the OpenCode converter entirely
-- Map conflicts first: the line diverges from `main` in
- `memory-orchestrator` and `memory-bench`, not just the installer
-- Delete all three `gsd/` branches once the kept features are on `main`
-- Maintainer decides; agent executes in Phase 61
-
-**59-03 Planning truth** · agent · 1 session
-- Rewrite `PROJECT.md` Current State for v3.1 shipped; remove OpenCode from
- the adapter list; move API summarizer from Deferred to shipped
-- Open GitHub issues for F3, F4, F5's three gaps, and cross-encoder rerank,
- each linking the relevant README row, so the backlog is public
-- Add v3.2 to `ROADMAP.md` and `STATE.md` once the maintainer approves this
- plan
-
-### Phase 60 — Real Numbers (3 plans · ~4 sessions + one maintainer run)
-
-The milestone's centre of gravity.
-
-**60-01 Live-backend isolation** · agent · 1 session
-- `memory-bench locomo --backend cli` must give each conversation a fresh
- store. Implement per-conversation daemon spawn with a temp `--data-dir`
- (the harness already does this for mock), or an `admin reset` RPC guarded
- behind a bench-only flag
-- Wait for the outbox drain deterministically (poll the checkpoint, not
- `sleep 60`)
-- Verify: the 1-conversation smoke fixture runs under `--backend cli` in CI
- with a real daemon, mock judge, and the result file records
- `isolation: per-conversation daemon`
-
-**60-02 The run** · maintainer (needs API key + a documented machine) · 1 session
-- `benchmarks/scripts/download-locomo.sh`
-- `memory-bench locomo --dataset locomo-data --backend cli --scorer llm-judge --output benchmarks/results/locomo-2026-MM-DD.json`
-- Budget: ~2,000 questions × (retrieve + answer + judge) — under $10 on
- `gpt-4o-mini`; wall clock dominated by ingest and drain, expect 1–2 hours
-- Record hardware, model id, temperature, dataset SHA in the result (the
- harness already writes these fields)
-- Commit the result and update `docs/benchmarks.md` "Committed result"
-
-**60-03 Vector and topic quality fixtures** · agent · 2 sessions
-- Add a labelled semantic fixture to the custom harness: queries whose
- relevant items share meaning but not tokens (`jwt` vs `JSON Web Token`)
- so BM25 alone fails and vector must carry it; report recall@5 for
- BM25-only, vector-only, and hybrid
-- Add a topic-graph fixture: known clusters, report purity or adjusted Rand
- index against the labels
-- Commit results; downgrade any README row whose result does not support
- "Solid"
-- Update the positioning doc's Claims Ledger with each number and its
- artifact path
-
-### Phase 61 — Operate It (5 plans · ~6 sessions)
-
-**61-01 Backfill** · agent · 2 sessions
-- `memory-daemon admin backfill-index --index bm25|vector|all`: replay
- events from RocksDB into the index, reset the index checkpoint, resumable,
- idempotent; progress on stderr
-- Build on the import/bootstrap code from 59-02 if the triage kept it
-- Verify: e2e test ingests events with an old daemon build's schema
- (fixture), runs backfill, and `memory search` returns previews
-- Update README status row and `docs/UPGRADING.md` to say backfill exists
-
-**61-02 Daemon lifecycle** · agent · 1 session
-- Do **not** implement double-fork. Ship `memory-daemon install-service`
- that writes a launchd plist (macOS) or systemd user unit (Linux) and
- loads it, plus `uninstall-service`
-- Keep `--background` exiting non-zero, now pointing at `install-service`
-- Verify in the bats suites on both Tier 1 platforms
-
-**61-03 Panic audit** · agent · 1 session
-- Enumerate the 266 `unwrap()`/`expect()` sites in `memory-daemon` and
- `memory-service`; classify each as provably-infallible, needs-`?`, or
- needs-a-metric; convert the second class; finish the 5 remaining
- lock-poisoning sites
-- Verify: a fuzz-style e2e that sends malformed and boundary requests to
- every RPC and asserts the daemon is still answering afterward
-
-**61-04 Offline TOC rebuild** · agent · 1 session
-- Implement `admin rebuild-toc` for real: rebuild nodes from events for a
- date range, replacing the "exits non-zero with guidance" stub
-- Verify: delete TOC nodes in a fixture store, rebuild, assert byte-equal to
- the scheduled rollup's output
-
-**61-05 Installer: register, uninstall, status** · agent · 1 session
-- Land the March CREG/META work: `memory-installer install --agent claude`
- registers the plugin so Claude Code discovers it on launch; version from
- `.claude-plugin/plugin.json`; re-install idempotent
-- Build the two March phases that were never started: `memory-installer
- uninstall --agent ` (removes registry entries and files; no-op when not
- installed) and `memory-installer status` (installed runtimes, versions,
- paths)
-- `--for all` / `--all` and Gemini/Codex/Copilot registration stay v3.3+ as
- the March roadmap had them
-- Verify: bats — install, `status` shows it, Claude Code loads the plugin,
- `uninstall`, `status` says not installed, second `uninstall` exits 0
-
-### Phase 62 — Cross-encoder rerank (conditional · 2 sessions)
-
-Only if the 60-02 result shows retrieval, not generation, is the bottleneck
-(the harness's per-type breakdown will say). The extension point exists and
-returns an explicit error today; keep it that way until a number says
-otherwise. Do not build ahead of evidence — that is how v3.0 got here.
+**Acceptance:**
+- [ ] bats (both Tier 1 suites): `install-service` → `memory-daemon status`
+ healthy within 10 s → `uninstall-service` → status reports not
+ running; unit file removed
+- [ ] Re-running `install-service` is idempotent (unit rewritten, no
+ duplicate)
+- [ ] Quickstart shows `install-service` as the recommended path
+- [ ] README row "Background daemonization" → "Via service unit"
+
+**Effort:** 1 session · **Owner:** agent
+
+---
+
+#### 61-03 · Panic audit
+
+**Why:** F5. Get the real number, then fix the class that matters.
+
+**Files:** `crates/memory-service/src/{agents,retrieval,federated,
+episodes,teleport_service,topics,novelty}.rs`,
+`crates/memory-daemon/src/clod.rs`, `crates/memory-indexing/src/
+vector_updater.rs:459`, `memory-types/src/sync.rs:43`; new
+`crates/e2e-tests/tests/hostile_input_test.rs`.
+
+**Steps:**
+1. Real count: a `cargo clippy` run with
+ `-W clippy::unwrap_used -W clippy::expect_used` restricted to non-test
+ code (the lints already skip `#[cfg(test)]`). Commit the count in the PR
+ description.
+2. Classify each site: (a) provably infallible — annotate with
+ `// SAFETY:`-style `// INFALLIBLE:` comment or convert to
+ `expect("")`; (b) fallible on a request path — convert to `?`
+ with a `Status::internal`/`invalid_argument`; (c) lock poisoning —
+ finish the 5 with `parking_lot` or `unwrap_or_else(PoisonError::into_inner)`
+ per the 54-06 policy already chosen.
+3. Enable `#![warn(clippy::unwrap_used, clippy::expect_used)]` in
+ `memory-service` and `memory-daemon` lib roots so regressions are
+ visible (warn, not deny, so tests keep passing).
+4. `hostile_input_test.rs`: for every RPC in `proto/memory.proto`, send
+ empty, oversized (1 MiB string), malformed-UTF-8, negative/overflow
+ numeric, and unknown-enum requests via the direct-handler pattern
+ (`tonic::Request`, per v2.2 decision); after each, a health RPC must
+ still answer.
+
+**Acceptance:**
+- [ ] Clippy count of `unwrap_used + expect_used` in production code
+ recorded before/after; class (b) count is **0** after
+- [ ] `hostile_input_test.rs` covers all 30 RPCs and passes
+- [ ] Lock-poisoning grep returns 0 production sites
+
+**Effort:** 1 session · **Owner:** agent
+
+---
+
+#### 61-04 · Offline TOC rebuild
+
+**Why:** F5. The stub is honest but the gap is real.
+
+**Files:** `crates/memory-daemon/src/cli.rs:231`
+(`AdminCommands::RebuildToc`), `commands.rs`; `crates/memory-toc` (the
+rollup jobs the scheduler runs — reuse, do not reimplement);
+`crates/e2e-tests/tests/pipeline_test.rs`.
+
+**Design:** `admin rebuild-toc --from YYYY-MM-DD --to YYYY-MM-DD
+[--dry-run]` runs the same day/week/month/year rollup code the scheduler
+invokes, over events in range, replacing existing nodes for that range.
+Stopped-daemon only, same lock rule as 61-01.
+
+**Acceptance:**
+- [ ] e2e: build TOC via scheduler path → snapshot nodes → delete them →
+ `rebuild-toc` → nodes byte-equal to the snapshot (ids, ranges,
+ summaries)
+- [ ] `--dry-run` reports the node count it would write
+- [ ] README row "Offline TOC rebuild" → "Works"
+
+**Effort:** 1 session · **Owner:** agent
+
+---
+
+#### 61-05 · Installer: register, uninstall, status
+
+**Why:** F2, and the March v3.2's still-valid half.
+
+**Files:** `crates/memory-installer/src/main.rs` (`Commands::{Uninstall,
+Status}`), `converters/claude.rs` (port the registration from the branch:
+`known_marketplaces.json`, `installed_plugins.json`, `settings.json`
+`enabledPlugins`), `.claude-plugin/plugin.json` + `marketplace.json`
+(port), `tests/e2e_converters.rs`, `tests/cli/claude-code/*.bats`.
+
+**Steps:**
+1. Port CREG-01..06 + META-01..03 per the 59-02 sequence; drop every
+ OpenCode path.
+2. `uninstall --agent claude|codex|gemini|copilot`: remove registry entries
+ (Claude) and installed files; no-op exit 0 when nothing is installed.
+3. `status`: table of runtime · installed version · path · registered
+ (Claude only) · "not installed".
+4. Gemini/Codex/Copilot stay convert-only (registration is v3.3+ REG-F01).
+
+**Acceptance:**
+- [ ] bats: `install --agent claude` → `status` shows version+path+registered
+ → Claude Code launched headless lists the plugin → `uninstall` →
+ `status` "not installed" → second `uninstall` exits 0 silently
+- [ ] `.claude-plugin/plugin.json` version is the single source for the
+ install path (META-03)
+- [ ] Re-install is idempotent (CREG-06)
+
+**Effort:** 1 session · **Owner:** agent
+
+---
+
+### Phase 62 — Cross-encoder rerank (conditional · 2 sessions)
+
+**Gate:** run only if 60-02's per-type breakdown shows retrieval is the
+limiter — e.g. multi-hop and temporal recall@k high but judge accuracy low
+means generation, not retrieval; the reverse means rerank might help. The
+extension point (`memory-orchestrator` rerank trait, explicit
+`NotImplemented`) stays as is until then. If the gate opens: local
+cross-encoder via Candle in `memory-embeddings`, wired behind
+`--rerank=cross`, measured on the same fixtures, committed before any README
+change. **Do not build ahead of evidence.**
+
+---
### Launch (side quest · maintainer)
-- **Now:** publish the blog post. It is about the process and needs no
- number.
-- **After 60-02 lands:** repo description and topics, enable Discussions,
- record the demo, then Show HN / r/rust / r/LocalLLaMA with the number in
- the first paragraph. The drafts in `docs/launch/` need one edit each to
- cite it.
+- **Now:** blog post (process story, no number needed).
+- **After 60-02:** repo description + topics (`ai-agents`, `memory`,
+ `rust`, `claude-code`, `local-first`), enable Discussions, record the
+ demo, then Show HN / r/rust / r/LocalLLaMA with the number in paragraph
+ one. Each draft in `docs/launch/launch-copy.md` needs one edit to cite
+ it.
+
+---
+
+## 5. Requirement map
+
+| ID | Requirement | Plan |
+|---|---|---|
+| REL-01 | Tag must be an ancestor of `main` | 59-01 |
+| REL-02 | Crate version must equal tag | 59-01 |
+| REL-03 | Any failed platform build → no release | 59-01 |
+| REL-04 | Release body from CHANGELOG | 59-01 |
+| BENCH-10 | Per-conversation isolation on cli backend | 60-01 |
+| BENCH-11 | Deterministic drain wait | 60-01 |
+| BENCH-12 | Committed `locomo_llm_judge` full-dataset result | 60-02 |
+| BENCH-13 | Layer switch bm25/vector/hybrid on custom harness | 60-03 |
+| QUAL-01 | Semantic fixture set, ≥15 tests | 60-03 |
+| QUAL-02 | Topic clustering purity + ARI artifact | 60-03 |
+| QUAL-03 | README "Solid" rows cite artifacts | 60-03 |
+| OPS-01 | `admin backfill-index` resumable, idempotent | 61-01 |
+| OPS-02 | `install-service`/`uninstall-service` macOS+Linux | 61-02 |
+| OPS-03 | Zero fallible `unwrap` on request paths | 61-03 |
+| OPS-04 | Hostile-input e2e over all RPCs | 61-03 |
+| OPS-05 | `admin rebuild-toc` real | 61-04 |
+| INST-01 | Claude Code plugin registration (CREG/META) | 61-05 |
+| INST-02 | `memory-installer uninstall` | 61-05 |
+| INST-03 | `memory-installer status` | 61-05 |
---
-## 5. Sequencing and estimates
+## 6. Sequencing
```
-Phase 59 ████░░░░░░░░░░░░░░░░ 3 sessions guardrails, triage, planning truth
-Phase 60 ░░░░██████░░░░░░░░░░ 4 sessions + maintainer run
-Phase 61 ░░░░░░░░░░██████████ 6 sessions
-Phase 62 ░░░░░░░░░░░░░░░░░░██ 2 sessions only if 60-02 says so
+59-01 ──┐
+59-02 ──┼──▶ 61-01 (needs 59-02 decision)
+59-03 ──┘ 61-05 (needs 59-02 decision)
+60-01 ──▶ 60-02 (maintainer) ──▶ 62 gate
+60-03 (independent)
+61-02, 61-03, 61-04 (independent of each other; after 59-01 lands)
```
-59 and the agent half of 60 can overlap; 60-02 waits on 60-01; 61-01 waits
-on the 59-02 decision. Roughly **13–15 agent sessions plus one maintainer
-benchmark run**, which at v3.1's pace is two to three weeks.
+| Phase | Sessions | Notes |
+|---|---|---|
+| 59 | 3 | can overlap with 60-01 and 60-03 |
+| 60 | 4 + 1 maintainer run | 60-02 blocks the launch posts |
+| 61 | 6 | 61-01/61-05 wait on 59-02 |
+| 62 | 0 or 2 | gated |
+| **Total** | **13–15 agent sessions + 1 maintainer run** | two to three weeks at v3.1 pace |
-## 6. Risks
+## 7. Risks
| Risk | Mitigation |
|---|---|
-| The LOCOMO number is bad | Then it is bad and gets committed anyway; the positioning doc already says how to talk about it. A bad honest number beats the current no number. Phase 62 exists for this case |
-| Cherry-picking 81 commits is worse than rewriting | 59-02 produces the conflict map first; the maintainer chooses with data. Rewrite is acceptable — the branch is a reference, not an obligation |
-| Live-backend isolation is slow (10 daemon spawns) | Acceptable for a run that happens once per release. Do not optimise it before it exists |
-| API cost surprise | Cap the judge at 200 questions on the first run, commit that, then run the full set |
-
-## 7. Success criteria
-
-- [ ] A tag that is not on `main`, or whose version disagrees with
- `Cargo.toml`, cannot produce a release (59-01)
-- [ ] `benchmarks/results/` contains one `locomo_llm_judge` result on the full
- dataset, real backend, hardware and model recorded (60-02)
-- [ ] Every "Solid" row in the README status table cites a committed quality
- result (60-03)
-- [ ] A pre-v3.1 store can be backfilled without data loss (61-01)
-- [ ] `memory-daemon install-service` works on macOS and Linux Tier 1 (61-02)
-- [ ] `memory-installer install/uninstall/status` round-trips on Claude Code
- and the plugin is discovered without manual registry edits (61-05)
-- [ ] No `unwrap()` on a request path that user input can reach (61-03)
-- [ ] `PROJECT.md` describes the shipped system; the known gaps are GitHub
- issues; no orphan `gsd/` branches (59-03, 59-02)
-- [ ] `task pr-precheck` green on every PR (standing rule)
-
-## 8. Decisions needed from the maintainer
-
-Recorded 2026-09-01 (plan adopted as written; recommendations accepted):
-
-1. **The March branch line:** cherry-pick export/import and Claude Code
- registration by feature. OpenCode converter is dropped. Conflict map:
+| LOCOMO number is poor | Commit it anyway (56-03 gate). The positioning doc already frames it. Phase 62 exists for exactly this |
+| Cherry-pick conflicts worse than expected | 59-02 maps them before anyone commits to porting; rewrite is a valid outcome |
+| Spawn-per-conversation is slow | Once per release; acceptable. Do not optimise before it exists |
+| Judge cost surprise | `--limit-questions 200` dry run first |
+| Backfill on a large store takes hours | Batch + checkpoint make it resumable; document expected rate from the e2e fixture |
+| `clippy::unwrap_used` warn is noisy | Warn not deny; the PR records the count, CI stays green |
+
+## 8. Milestone success criteria
+
+- [ ] REL-01..04: a wrong tag cannot produce a release (59-01)
+- [ ] BENCH-12: one committed `locomo_llm_judge` result, full dataset, real
+ backend, provenance fields non-null (60-02)
+- [ ] QUAL-03: every README "Solid" row links a committed artifact (60-03)
+- [ ] OPS-01: v3.0 fixture store backfills and searches (61-01)
+- [ ] OPS-02: service install round-trips on both Tier 1 platforms (61-02)
+- [ ] OPS-03/04: zero fallible unwraps on request paths; hostile-input suite
+ green (61-03)
+- [ ] INST-01..03: install/status/uninstall round-trip on Claude Code (61-05)
+- [ ] `PROJECT.md` accurate; 8 `v3.2` issues; no `gsd/` branches (59-02,
+ 59-03)
+- [ ] `task pr-precheck` green on every PR
+
+## 9. Out of scope (stays v3.3+)
+
+REST/HTTP endpoint · Python SDK · memory views UI · `--for all` / `--all`
+installer flags · Gemini/Codex/Copilot registration · Windows service
+install · true double-fork daemonization · consolidation hook · cross-project
+unified memory · per-agent dedup scoping.
+
+## 10. Decisions (recorded 2026-09-01)
+
+Recommendations accepted as written:
+
+1. **March branch line:** cherry-pick export/import and Claude Code
+ registration **by feature**. OpenCode converter is dropped; its branch
+ `gsd/phase-57-opencode-converter-registration` is deleted. Conflict map:
[phase-59-orphan-branch-triage.md](phase-59-orphan-branch-triage.md).
Agent executes the cherry-picks in Phase 61, not wholesale merges.
2. **Launch timing:** blog now (maintainer); product posts after 60-02.
3. **Daemonization:** service unit files, not double-fork. See #42.
+4. **Backfill mode:** stopped-daemon CLI only for v3.2. No admin RPC against
+ a running daemon this milestone. See #41.
diff --git a/proto/memory.proto b/proto/memory.proto
index d902d4f..9c394b6 100644
--- a/proto/memory.proto
+++ b/proto/memory.proto
@@ -67,6 +67,10 @@ service MemoryService {
// Get vector index status and statistics
rpc GetVectorIndexStatus(GetVectorIndexStatusRequest) returns (VectorIndexStatus);
+ // Read BM25/vector index checkpoints and the outbox head (Phase 60-01).
+ // Used by the live-backend LOCOMO harness to wait for drain without sleep.
+ rpc GetIndexCheckpoints(GetIndexCheckpointsRequest) returns (GetIndexCheckpointsResponse);
+
// Topic Graph RPCs (Phase 14 - TOPIC-08)
// Get topic graph status and statistics
@@ -695,6 +699,26 @@ message VectorIndexStatus {
int64 size_bytes = 6;
}
+// Request for index checkpoints (Phase 60-01)
+message GetIndexCheckpointsRequest {}
+
+// One index's crash-recovery checkpoint.
+message IndexCheckpointInfo {
+ // "bm25" | "vector" | "combined"
+ string index_type = 1;
+ // Last outbox sequence processed
+ uint64 last_sequence = 2;
+ // Documents processed since checkpoint creation
+ uint64 processed_count = 3;
+}
+
+// Response: checkpoints plus the outbox head (next sequence to assign).
+message GetIndexCheckpointsResponse {
+ repeated IndexCheckpointInfo checkpoints = 1;
+ // Next outbox sequence that will be assigned
+ uint64 outbox_head = 2;
+}
+
// ===== Topic Graph Messages (Phase 14 - TOPIC-08) =====
// Request for topic graph status