Skip to content

Self-Hosted Memory Layer Bake-Off — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Stand up a one-week, throwaway, side-by-side evaluation of two self-hosted correctable memory layers for Claude Code — Candidate C (a Go explicit-store MCP over Qdrant) and Candidate B (Graphiti temporal knowledge graph over FalkorDB) — both fully local via Ollama-behind-LiteLLM, then measure them against pre-defined pass/fail criteria and tear them down cleanly.

Architecture: Both stacks deploy to a disposable mem-eval k3s namespace via two sibling ArgoCD Applications (mem-eval-c, mem-eval-b), each with its own resources-finalizer for clean cascade teardown. Both reach LLM/embedding capability only through the in-cluster LiteLLM gateway, which routes a wildcard ollama/* model family to the Mac-mini Ollama over the LAN; a LiteLLM virtual key whose model allowlist contains only ollama/* makes cloud egress structurally impossible. Claude Code attaches to each via streamable-HTTP MCP plus a SessionStart context-injection hook and a Stop capture-nudge hook. Memories are written agent-controlled (no firehose) except for a measured firehose baseline run against Candidate B only.

Tech Stack: Go 1.23 (modelcontextprotocol/go-sdk, qdrant/go-client), Qdrant, Graphiti (zepai/knowledge-graph-mcp, patched), FalkorDB, LiteLLM, Ollama (qwen2.5:14b extraction, nomic-embed-text 768d embeddings), k3s + ArgoCD + Kustomize, Traefik IngressRoute, cert-manager (vault-issuer), ExternalSecrets + Vault.

Spec: docs/engineering/specs/2026-05-30-selfhosted-memory-bakeoff-design.md · Bead: hl-73s


  • ArgoCD app: argocd/cluster-app/templates/<name>.yaml (Application CR, project: core-services).
  • App manifests: argocd/app-configs/<name>/ with kustomization.yaml + raw YAML.
  • Secrets: ExternalSecretClusterSecretStore named vault, refreshInterval: 5m, Vault KV path fzymgc-house/cluster/mem-eval.
  • Ingress: Traefik IngressRoute on websecure, host <name>.fzymgc.house, annotation router-hosts.fzymgc.house/enabled: "true"; TLS via cert-manager Certificate (ClusterIssuer vault-issuer).
  • Scope key (both stacks): run:tier:repo, e.g. eval-2026-05:project:selfhosted-cluster, plus eval-firehose:project:<repo> for the baseline. Tier ∈ {project, global}. In C this is the Qdrant payload field scope; in B it is the Graphiti group_id.
  • Provenance (both): every memory carries repo, workspace, worktree_path, base_dir, source (user-said|agent-inferred), category, created_at.
  • Commit discipline: conventional commits, one logical change per commit, AI byline Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>. VCS per references/vcs-preamble.md (jj-first repo).

Task 1: LiteLLM wildcard Ollama routes (chat + embeddings)

Section titled “Task 1: LiteLLM wildcard Ollama routes (chat + embeddings)”

Files:

  • Modify: argocd/app-configs/litellm/configmap.yaml (append to model_list)

  • Step 1: Add the wildcard route and embedding route

In argocd/app-configs/litellm/configmap.yaml, under model_list:, append:

# --- mem-eval: local Ollama (Mac mini), no cloud egress ---
- model_name: "ollama/*"
litellm_params:
model: "ollama_chat/*"
api_base: "http://<MAC_MINI_LAN_IP>:11434" # replace with the Mac-mini LAN IP/hostname
- model_name: "ollama/nomic-embed-text"
litellm_params:
model: "ollama/nomic-embed-text"
api_base: "http://<MAC_MINI_LAN_IP>:11434"

Rationale: LiteLLM supports provider-prefix wildcard routing (anthropic/*, groq/* are documented); ollama_chat/* uses the /api/chat path which is more reliable than /api/generate for tool-style prompts. The explicit embedding entry guarantees /v1/embeddings resolves even if the wildcard is treated as chat-only.

  • Step 2: Commit
docs(litellm): add wildcard ollama/* route for mem-eval (hl-73s)
  • Step 3: Sync and verify the routes register (after merge to main)

Run:

Terminal window
kubectl -n litellm rollout status deploy/litellm --timeout=120s
kubectl -n litellm exec deploy/litellm -- \
curl -sS http://localhost:4000/v1/models -H "Authorization: Bearer $LITELLM_MASTER_KEY" \
| grep -c 'ollama/'

Expected: ≥ 1 (routes present). If 0, the wildcard did not register — check the configmap mounted and the pod restarted (reloader.stakater.com/auto: "true" handles this).


Task 2: Expose Mac-mini Ollama to the cluster and pull models

Section titled “Task 2: Expose Mac-mini Ollama to the cluster and pull models”

Files: (host config — Mac mini; no repo files unless you manage it via the existing ansible/ tree)

  • Step 1: Bind Ollama to the LAN

On the Mac mini, set the service env and restart:

Terminal window
launchctl setenv OLLAMA_HOST "0.0.0.0:11434"
# Restart the Ollama app/service so it re-reads OLLAMA_HOST
  • Step 2: Restrict :11434 to the cluster egress source IPs

Add a packet-filter (pf) anchor allowing only the k3s node egress IP(s); default-deny otherwise. Record the exact node egress IP(s):

Terminal window
kubectl get nodes -o jsonpath='{range .items[*]}{.status.addresses[?(@.type=="InternalIP")].address}{"\n"}{end}'

Add a pf rule (illustrative): block in proto tcp to any port 11434 + pass in proto tcp from <NODE_IPS> to any port 11434.

  • Step 3: Pull the eval models
Terminal window
ollama pull qwen2.5:14b
ollama pull nomic-embed-text

Expected: both present in ollama list.

  • Step 4: Verify reachability from inside the cluster
Terminal window
kubectl -n litellm exec deploy/litellm -- \
curl -sS http://<MAC_MINI_LAN_IP>:11434/api/tags | grep -c 'qwen2.5:14b'

Expected: 1. If 0, fix firewall/binding before proceeding — every later task depends on this path.


Task 3: LiteLLM virtual key allowlisted to ollama/* (the egress guardrail)

Section titled “Task 3: LiteLLM virtual key allowlisted to ollama/* (the egress guardrail)”

Files:

  • Create: argocd/app-configs/mem-eval/external-secret.yaml (consumed in Task 11/15)

  • Step 1: Mint the restricted key

Terminal window
kubectl -n litellm exec deploy/litellm -- curl -sS http://localhost:4000/key/generate \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "Content-Type: application/json" \
-d '{"models": ["ollama/*"], "key_alias": "mem-eval-ollama", "metadata": {"purpose": "mem-eval bake-off; ollama-only"}}'

Capture the returned key (sk-...).

  • Step 2: Verify the guardrail rejects a cloud model (NEGATIVE test — load-bearing)
Terminal window
kubectl -n litellm exec deploy/litellm -- curl -sS -o /dev/null -w "%{http_code}\n" \
http://localhost:4000/v1/chat/completions \
-H "Authorization: Bearer <MEM_EVAL_KEY>" -H "Content-Type: application/json" \
-d '{"model": "or-claude-opus-4-7", "messages": [{"role":"user","content":"hi"}]}'

Expected: 401 or 400 (rejected). If it returns 200, the guardrail is broken — STOP. Fallback: re-mint the key with the enumerated allowlist ["ollama/qwen2.5:14b","ollama/nomic-embed-text"] and re-test.

  • Step 3: Verify the guardrail ALLOWS an ollama model (POSITIVE test)
Terminal window
kubectl -n litellm exec deploy/litellm -- curl -sS -o /dev/null -w "%{http_code}\n" \
http://localhost:4000/v1/chat/completions \
-H "Authorization: Bearer <MEM_EVAL_KEY>" -H "Content-Type: application/json" \
-d '{"model": "ollama/qwen2.5:14b", "messages": [{"role":"user","content":"reply with OK"}]}'

Expected: 200. Ordering: this step requires Task 1 (wildcard route registered) and Task 2 (Ollama reachable) to be complete — otherwise a non-200 here reflects a missing route/host, not the key.

  • Step 4: Store the key in Vault
Terminal window
vault kv put fzymgc-house/cluster/mem-eval litellm_ollama_key="<MEM_EVAL_KEY>"
  • Step 5: Write the shared ExternalSecret

argocd/app-configs/mem-eval/external-secret.yaml:

---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: mem-eval-litellm
namespace: mem-eval
spec:
refreshPolicy: Periodic
refreshInterval: 5m
secretStoreRef:
name: vault
kind: ClusterSecretStore
target:
name: mem-eval-litellm
creationPolicy: Owner
deletionPolicy: Delete
data:
- secretKey: litellm_ollama_key
remoteRef:
key: fzymgc-house/cluster/mem-eval
property: litellm_ollama_key
  • Step 6: Commit
feat(mem-eval): add ollama-only LiteLLM key ExternalSecret (hl-73s)

Phase 1 — Candidate C: Go explicit-store over Qdrant

Section titled “Phase 1 — Candidate C: Go explicit-store over Qdrant”

Phase 1 and Phase 2 are independent and may be built in parallel. Phase 1 produces a static Go binary + image, a Qdrant deployment, and an ArgoCD app.

Files:

  • Create: services/mem-eval-c/go.mod
  • Create: services/mem-eval-c/main.go
  • Step 1: Initialise the module
Terminal window
mkdir -p services/mem-eval-c && cd services/mem-eval-c
go mod init github.com/fzymgc-house/selfhosted-cluster/services/mem-eval-c
go get github.com/modelcontextprotocol/go-sdk/mcp@v1.4.0
go get github.com/qdrant/go-client/qdrant@v1.18.2
  • Step 2: Minimal main.go that builds

services/mem-eval-c/main.go:

package main
import (
"log"
"net/http"
"os"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func main() {
addr := envOr("MEM_LISTEN_ADDR", ":8080")
server := mcp.NewServer(&mcp.Implementation{Name: "mem-eval-c", Version: "0.1.0"}, nil)
registerTools(server) // defined in tools.go (Task 7)
handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, nil)
log.Printf("mem-eval-c listening on %s", addr)
if err := http.ListenAndServe(addr, handler); err != nil {
log.Fatalf("server failed: %v", err)
}
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
  • Step 3: Verify it compiles

Run: go build ./... Expected: fails with undefined: registerTools (expected until Task 7) — confirms wiring. Comment out the registerTools call to get a clean go build checkpoint, then restore it in Task 7.

  • Step 4: Commit
feat(mem-eval-c): scaffold Go MCP server module (hl-73s)

Task 5: Embeddings client (LiteLLM /v1/embeddings)

Section titled “Task 5: Embeddings client (LiteLLM /v1/embeddings)”

Files:

  • Create: services/mem-eval-c/internal/embed/embed.go
  • Test: services/mem-eval-c/internal/embed/embed_test.go
  • Step 1: Write the failing test

internal/embed/embed_test.go:

package embed
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestEmbedReturnsVector(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
t.Errorf("missing/wrong auth header: %q", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"data": []map[string]any{{"embedding": []float32{0.1, 0.2, 0.3}}},
})
}))
defer srv.Close()
c := New(srv.URL, "test-key", "ollama/nomic-embed-text")
vec, err := c.Embed(context.Background(), "hello")
if err != nil {
t.Fatalf("Embed: %v", err)
}
if len(vec) != 3 || vec[0] != 0.1 {
t.Fatalf("unexpected vector: %v", vec)
}
}
  • Step 2: Run it to verify it fails

Run: go test ./internal/embed/ -run TestEmbedReturnsVector -v Expected: FAIL (undefined: New).

  • Step 3: Implement

internal/embed/embed.go:

package embed
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
type Client struct {
baseURL string
apiKey string
model string
http *http.Client
}
func New(baseURL, apiKey, model string) *Client {
return &Client{baseURL: baseURL, apiKey: apiKey, model: model, http: &http.Client{Timeout: 30 * time.Second}}
}
type embedReq struct {
Model string `json:"model"`
Input string `json:"input"`
}
type embedResp struct {
Data []struct {
Embedding []float32 `json:"embedding"`
} `json:"data"`
}
func (c *Client) Embed(ctx context.Context, text string) ([]float32, error) {
body, _ := json.Marshal(embedReq{Model: c.model, Input: text})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/embeddings", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("embeddings: status %d", resp.StatusCode)
}
var out embedResp
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
if len(out.Data) == 0 {
return nil, fmt.Errorf("embeddings: empty data")
}
return out.Data[0].Embedding, nil
}
  • Step 4: Run to verify it passes

Run: go test ./internal/embed/ -v Expected: PASS.

  • Step 5: Commit
feat(mem-eval-c): add LiteLLM embeddings client (hl-73s)

Files:

  • Create: services/mem-eval-c/internal/store/store.go
  • Test: services/mem-eval-c/internal/store/store_test.go (integration; gated on a reachable Qdrant via MEM_QDRANT_TEST_ADDR)
  • Step 1: Define the Memory type and store interface

internal/store/store.go:

package store
import (
"context"
"time"
"github.com/qdrant/go-client/qdrant"
)
// Memory is the unit of storage. Fields map 1:1 to Qdrant payload keys.
type Memory struct {
ID string `json:"id"`
Content string `json:"content"`
Scope string `json:"scope"` // run:tier:repo, e.g. eval-2026-05:project:selfhosted-cluster
Repo string `json:"repo"`
Workspace string `json:"workspace"`
Worktree string `json:"worktree_path"`
BaseDir string `json:"base_dir"`
Source string `json:"source"` // user-said | agent-inferred
Category string `json:"category"`
Tags []string `json:"tags"`
CreatedAt time.Time `json:"created_at"`
}
type Store struct {
client *qdrant.Client
collection string
}
func New(c *qdrant.Client, collection string) *Store { return &Store{client: c, collection: collection} }
// EnsureCollection is idempotent: creates the collection at the given vector size if absent.
func (s *Store) EnsureCollection(ctx context.Context, dim uint64) error {
exists, err := s.client.CollectionExists(ctx, s.collection)
if err != nil {
return err
}
if exists {
return nil
}
return s.client.CreateCollection(ctx, &qdrant.CreateCollection{
CollectionName: s.collection,
VectorsConfig: qdrant.NewVectorsConfig(&qdrant.VectorParams{
Size: dim, Distance: qdrant.Distance_Cosine,
}),
})
}
  • Step 2: Add Upsert/Search/Get/Delete/List with a failing integration test

internal/store/store_test.go:

package store
import (
"context"
"net"
"os"
"strconv"
"testing"
"time"
"github.com/qdrant/go-client/qdrant"
)
func testStore(t *testing.T) *Store {
addr := os.Getenv("MEM_QDRANT_TEST_ADDR") // host:port (gRPC 6334)
if addr == "" {
t.Skip("set MEM_QDRANT_TEST_ADDR to run store integration tests")
}
host, portStr, _ := net.SplitHostPort(addr) // addr = MEM_QDRANT_TEST_ADDR, e.g. 127.0.0.1:6334
port, _ := strconv.Atoi(portStr)
c, err := qdrant.NewClient(&qdrant.Config{Host: host, Port: port})
if err != nil {
t.Fatalf("client: %v", err)
}
s := New(c, "mem_eval_test")
if err := s.EnsureCollection(context.Background(), 3); err != nil {
t.Fatalf("ensure: %v", err)
}
return s
}
func TestUpsertGetDeleteRoundtrip(t *testing.T) {
s := testStore(t)
ctx := context.Background()
m := Memory{ID: "11111111-1111-1111-1111-111111111111", Content: "uses jj for VCS",
Scope: "eval-test:project:selfhosted-cluster", Repo: "selfhosted-cluster",
Source: "user-said", Category: "preference", CreatedAt: time.Now().UTC()}
if err := s.Upsert(ctx, m, []float32{0.1, 0.2, 0.3}); err != nil {
t.Fatalf("upsert: %v", err)
}
got, err := s.Get(ctx, m.ID)
if err != nil || got.Content != m.Content {
t.Fatalf("get: %v / %+v", err, got)
}
if err := s.Delete(ctx, m.ID); err != nil {
t.Fatalf("delete: %v", err)
}
if _, err := s.Get(ctx, m.ID); err == nil {
t.Fatalf("expected not-found after delete")
}
}
  • Step 3: Run to verify it fails

Run: go test ./internal/store/ -run TestUpsertGetDeleteRoundtrip -v Expected: FAIL (undefined: (*Store).Upsert).

  • Step 4: Implement Upsert/Search/Get/Delete/List/DeleteAll

Append to internal/store/store.go:

func payload(m Memory) map[string]any {
return map[string]any{
"content": m.Content, "scope": m.Scope, "repo": m.Repo, "workspace": m.Workspace,
"worktree_path": m.Worktree, "base_dir": m.BaseDir, "source": m.Source,
"category": m.Category, "tags": m.Tags, "created_at": m.CreatedAt.Format(time.RFC3339),
}
}
func (s *Store) Upsert(ctx context.Context, m Memory, vec []float32) error {
_, err := s.client.Upsert(ctx, &qdrant.UpsertPoints{
CollectionName: s.collection, Wait: qdrant.PtrOf(true),
Points: []*qdrant.PointStruct{{
Id: qdrant.NewID(m.ID), Vectors: qdrant.NewVectors(vec...),
Payload: qdrant.NewValueMap(payload(m)),
}},
})
return err
}
func (s *Store) scopeFilter(scope string) *qdrant.Filter {
return &qdrant.Filter{Must: []*qdrant.Condition{qdrant.NewMatch("scope", scope)}}
}
func (s *Store) Search(ctx context.Context, scope string, vec []float32, k uint64) ([]Memory, error) {
res, err := s.client.Query(ctx, &qdrant.QueryPoints{
CollectionName: s.collection, Query: qdrant.NewQuery(vec...),
Filter: s.scopeFilter(scope), Limit: qdrant.PtrOf(k), WithPayload: qdrant.NewWithPayload(true),
})
if err != nil {
return nil, err
}
out := make([]Memory, 0, len(res))
for _, p := range res {
out = append(out, fromPayload(p.Id.GetUuid(), p.Payload))
}
return out, nil
}
func (s *Store) Get(ctx context.Context, id string) (Memory, error) {
pts, err := s.client.Get(ctx, &qdrant.GetPoints{
CollectionName: s.collection, Ids: []*qdrant.PointId{qdrant.NewID(id)},
WithPayload: qdrant.NewWithPayload(true),
})
if err != nil {
return Memory{}, err
}
if len(pts) == 0 {
return Memory{}, fmt.Errorf("not found: %s", id)
}
return fromPayload(id, pts[0].Payload), nil
}
func (s *Store) Delete(ctx context.Context, id string) error {
_, err := s.client.Delete(ctx, &qdrant.DeletePoints{
CollectionName: s.collection, Wait: qdrant.PtrOf(true),
Points: qdrant.NewPointsSelector(qdrant.NewID(id)),
})
return err
}
func (s *Store) DeleteAll(ctx context.Context, scope string) error {
_, err := s.client.Delete(ctx, &qdrant.DeletePoints{
CollectionName: s.collection, Wait: qdrant.PtrOf(true),
Points: qdrant.NewPointsSelectorFilter(s.scopeFilter(scope)),
})
return err
}

Add the fmt import and a fromPayload decoder that reverses payload() (reads each key via p.GetStringValue() etc.; parse created_at with time.Parse(time.RFC3339, …)).

  • Step 5: Run to verify it passes (with a local Qdrant)
Terminal window
docker run -d --rm -p 6334:6334 -p 6333:6333 qdrant/qdrant:v1.12.4
MEM_QDRANT_TEST_ADDR=127.0.0.1:6334 go test ./internal/store/ -v

Expected: PASS.

  • Step 6: Commit
feat(mem-eval-c): add Qdrant store layer with CRUD + scope filter (hl-73s)

Task 7: MCP tools (store/search/get/update/delete/list/delete_all)

Section titled “Task 7: MCP tools (store/search/get/update/delete/list/delete_all)”

Files:

  • Create: services/mem-eval-c/tools.go
  • Test: services/mem-eval-c/tools_test.go
  • Step 1: Write a failing handler test for store_memory

tools_test.go (uses a fake embedder + the integration store; gated on MEM_QDRANT_TEST_ADDR):

package main
import (
"context"
"os"
"testing"
"github.com/fzymgc-house/selfhosted-cluster/services/mem-eval-c/internal/store"
)
func TestStoreThenSearch(t *testing.T) {
if os.Getenv("MEM_QDRANT_TEST_ADDR") == "" {
t.Skip("integration")
}
d := newDeps(t) // builds store (dim 3) + a stub embedder returning a fixed 3-vector
ctx := context.Background()
id, err := d.storeMemory(ctx, storeArgs{Content: "prefers Go over Python", Scope: "s:project:r", Source: "user-said", Category: "preference"})
if err != nil || id == "" {
t.Fatalf("store: %v id=%q", err, id)
}
hits, err := d.searchMemory(ctx, searchArgs{Query: "language preference", Scope: "s:project:r", K: 5})
if err != nil || len(hits) == 0 {
t.Fatalf("search: %v hits=%d", err, len(hits))
}
_ = store.Memory{} // ensure import used
}
  • Step 2: Run to verify it fails

Run: MEM_QDRANT_TEST_ADDR=127.0.0.1:6334 go test . -run TestStoreThenSearch -v Expected: FAIL (undefined: newDeps).

  • Step 3: Implement the tool layer

tools.go — typed args structs (so mcp.AddTool infers JSON Schema), a deps holder, the business methods, and registration:

package main
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/fzymgc-house/selfhosted-cluster/services/mem-eval-c/internal/embed"
"github.com/fzymgc-house/selfhosted-cluster/services/mem-eval-c/internal/store"
)
type deps struct {
st *store.Store
em interface {
Embed(context.Context, string) ([]float32, error)
}
}
type storeArgs struct {
Content string `json:"content" jsonschema:"the memory text to persist"`
Scope string `json:"scope" jsonschema:"run:tier:repo, e.g. eval-2026-05:project:selfhosted-cluster"`
Source string `json:"source" jsonschema:"user-said or agent-inferred"`
Category string `json:"category" jsonschema:"decision|preference|convention|gotcha"`
Tags []string `json:"tags,omitempty"`
Repo string `json:"repo,omitempty"`
Workspace string `json:"workspace,omitempty"`
Worktree string `json:"worktree_path,omitempty"`
BaseDir string `json:"base_dir,omitempty"`
}
type searchArgs struct {
Query string `json:"query"`
Scope string `json:"scope"`
K uint64 `json:"k,omitempty"`
}
type idArgs struct {
ID string `json:"id"`
}
type updateArgs struct {
ID string `json:"id"`
Content string `json:"content"`
}
type scopeArgs struct {
Scope string `json:"scope"`
}
func (d *deps) storeMemory(ctx context.Context, a storeArgs) (string, error) {
vec, err := d.em.Embed(ctx, a.Content)
if err != nil {
return "", err
}
m := store.Memory{
ID: uuid.NewString(), Content: a.Content, Scope: a.Scope, Repo: a.Repo,
Workspace: a.Workspace, Worktree: a.Worktree, BaseDir: a.BaseDir,
Source: a.Source, Category: a.Category, Tags: a.Tags, CreatedAt: time.Now().UTC(),
}
return m.ID, d.st.Upsert(ctx, m, vec)
}
func (d *deps) searchMemory(ctx context.Context, a searchArgs) ([]store.Memory, error) {
if a.K == 0 {
a.K = 8
}
vec, err := d.em.Embed(ctx, a.Query)
if err != nil {
return nil, err
}
return d.st.Search(ctx, a.Scope, vec, a.K)
}
func (d *deps) updateMemory(ctx context.Context, a updateArgs) error {
cur, err := d.st.Get(ctx, a.ID)
if err != nil {
return err
}
cur.Content = a.Content
vec, err := d.em.Embed(ctx, a.Content)
if err != nil {
return err
}
return d.st.Upsert(ctx, cur, vec) // same ID → in-place replace + re-embed
}
func registerTools(server *mcp.Server) {
d := buildDepsFromEnv() // store from MEM_QDRANT_ADDR; embedder from MEM_LITELLM_URL/KEY/MODEL
mcp.AddTool(server, &mcp.Tool{Name: "store_memory", Description: "Persist a deliberate, well-formed memory. Do NOT store transient state, secrets, or timestamps."},
func(ctx context.Context, _ *mcp.CallToolRequest, a storeArgs) (*mcp.CallToolResult, any, error) {
id, err := d.storeMemory(ctx, a)
return textResult(fmt.Sprintf("stored %s", id)), map[string]string{"id": id}, err
})
mcp.AddTool(server, &mcp.Tool{Name: "search_memory", Description: "Semantic search within a scope."},
func(ctx context.Context, _ *mcp.CallToolRequest, a searchArgs) (*mcp.CallToolResult, any, error) {
hits, err := d.searchMemory(ctx, a)
return textResult(fmt.Sprintf("%d hits", len(hits))), map[string]any{"memories": hits}, err
})
mcp.AddTool(server, &mcp.Tool{Name: "get_memory", Description: "Fetch one memory by id."},
func(ctx context.Context, _ *mcp.CallToolRequest, a idArgs) (*mcp.CallToolResult, any, error) {
m, err := d.st.Get(ctx, a.ID)
return textResult(m.Content), m, err
})
mcp.AddTool(server, &mcp.Tool{Name: "update_memory", Description: "Replace a memory's content in place (re-embeds)."},
func(ctx context.Context, _ *mcp.CallToolRequest, a updateArgs) (*mcp.CallToolResult, any, error) {
err := d.updateMemory(ctx, a)
return textResult("updated"), nil, err
})
mcp.AddTool(server, &mcp.Tool{Name: "delete_memory", Description: "Delete one memory by id."},
func(ctx context.Context, _ *mcp.CallToolRequest, a idArgs) (*mcp.CallToolResult, any, error) {
err := d.st.Delete(ctx, a.ID)
return textResult("deleted"), nil, err
})
mcp.AddTool(server, &mcp.Tool{Name: "delete_all", Description: "Delete every memory in a scope (teardown)."},
func(ctx context.Context, _ *mcp.CallToolRequest, a scopeArgs) (*mcp.CallToolResult, any, error) {
err := d.st.DeleteAll(ctx, a.Scope)
return textResult("scope cleared"), nil, err
})
}
func textResult(s string) *mcp.CallToolResult {
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: s}}}
}

Implement buildDepsFromEnv() and the test helper newDeps(t) (which injects a stub embedder), plus add github.com/google/uuid to go.mod.

  • Step 4: Run to verify it passes

Run: MEM_QDRANT_TEST_ADDR=127.0.0.1:6334 go test ./... -v Expected: PASS across embed, store, and root package. Then go build ./... clean.

  • Step 5: Commit
feat(mem-eval-c): add MCP tools store/search/get/update/delete/delete_all (hl-73s)

Files:

  • Create: services/mem-eval-c/Dockerfile

  • Step 1: Multi-stage distroless Dockerfile

services/mem-eval-c/Dockerfile:

FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/mem-eval-c .
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/mem-eval-c /mem-eval-c
EXPOSE 8080
ENTRYPOINT ["/mem-eval-c"]
  • Step 2: Build, smoke-test, push
Terminal window
cd services/mem-eval-c
docker build -t ghcr.io/fzymgc-house/mem-eval-c:0.1.0 .
docker run --rm -e MEM_LISTEN_ADDR=:8080 -p 8080:8080 ghcr.io/fzymgc-house/mem-eval-c:0.1.0 &
sleep 2 && curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:8080/mcp # expect 400/406 (MCP needs proper headers) = server up
docker push ghcr.io/fzymgc-house/mem-eval-c:0.1.0
  • Step 3: Commit
feat(mem-eval-c): add distroless Dockerfile (hl-73s)

Task 9: Deploy Candidate C (Qdrant + Go server) via ArgoCD

Section titled “Task 9: Deploy Candidate C (Qdrant + Go server) via ArgoCD”

Files:

  • Create: argocd/app-configs/mem-eval-c/kustomization.yaml
  • Create: argocd/app-configs/mem-eval-c/qdrant.yaml (Deployment + Service, emptyDir — throwaway)
  • Create: argocd/app-configs/mem-eval-c/deployment.yaml (Go server)
  • Create: argocd/app-configs/mem-eval-c/service.yaml
  • Create: argocd/app-configs/mem-eval-c/certificate.yaml
  • Create: argocd/app-configs/mem-eval-c/ingress.yaml
  • Create: argocd/cluster-app/templates/mem-eval-c.yaml (Application, with finalizer)
  • Step 1: Qdrant Deployment + Service (qdrant.yaml)
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: qdrant, namespace: mem-eval, labels: {app.kubernetes.io/name: qdrant}}
spec:
replicas: 1
selector: {matchLabels: {app.kubernetes.io/name: qdrant}}
template:
metadata: {labels: {app.kubernetes.io/name: qdrant}}
spec:
containers:
- name: qdrant
image: qdrant/qdrant:v1.12.4
ports: [{containerPort: 6333, name: rest}, {containerPort: 6334, name: grpc}]
volumeMounts: [{name: data, mountPath: /qdrant/storage}]
resources: {requests: {memory: 512Mi, cpu: 250m}, limits: {memory: 2Gi, cpu: "1"}}
volumes: [{name: data, emptyDir: {}}] # throwaway: emptyDir, lost on pod restart by design
---
apiVersion: v1
kind: Service
metadata: {name: qdrant, namespace: mem-eval}
spec:
selector: {app.kubernetes.io/name: qdrant}
ports: [{name: rest, port: 6333, targetPort: rest}, {name: grpc, port: 6334, targetPort: grpc}]
  • Step 2: Go server Deployment (deployment.yaml) — wires the LiteLLM key + ollama-only models
apiVersion: apps/v1
kind: Deployment
metadata:
name: mem-eval-c
namespace: mem-eval
annotations: {reloader.stakater.com/auto: "true"}
spec:
replicas: 1
selector: {matchLabels: {app.kubernetes.io/name: mem-eval-c}}
template:
metadata: {labels: {app.kubernetes.io/name: mem-eval-c}}
spec:
containers:
- name: mem-eval-c
image: ghcr.io/fzymgc-house/mem-eval-c:0.1.0
ports: [{containerPort: 8080, name: http}]
env:
- {name: MEM_LISTEN_ADDR, value: ":8080"}
- {name: MEM_QDRANT_ADDR, value: "qdrant.mem-eval.svc.cluster.local:6334"}
- {name: MEM_QDRANT_COLLECTION, value: "mem_eval"}
- {name: MEM_LITELLM_URL, value: "http://litellm.litellm.svc.cluster.local:4000"}
- {name: MEM_EMBED_MODEL, value: "ollama/nomic-embed-text"}
- {name: MEM_EMBED_DIM, value: "768"}
- name: MEM_LITELLM_KEY
valueFrom: {secretKeyRef: {name: mem-eval-litellm, key: litellm_ollama_key}}
resources: {requests: {memory: 64Mi, cpu: 50m}, limits: {memory: 256Mi, cpu: 500m}}
  • Step 3: Service, Certificate, IngressRoute (service.yaml, certificate.yaml, ingress.yaml)

Service → port 8080. Certificate mem-c-tls dnsNames mem-c.fzymgc.house + mem-c.k8s.fzymgc.house (issuer vault-issuer). IngressRoute host mem-c.fzymgc.house → service mem-eval-c:8080, tls.secretName: mem-c-tls, annotation router-hosts.fzymgc.house/enabled: "true". (Mirror argocd/app-configs/litellm/{service,certificate,ingress}.yaml exactly, substituting names/ports.)

  • Step 4: kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: mem-eval
resources:
- ../mem-eval/external-secret.yaml # shared LiteLLM key (Task 3)
- qdrant.yaml
- deployment.yaml
- service.yaml
- certificate.yaml
- ingress.yaml

The ../mem-eval/external-secret.yaml cross-directory reference is the repo’s first use of that kustomize pattern. Before committing, verify it renders: kubectl kustomize argocd/app-configs/mem-eval-c (expect the ExternalSecret + all resources, no error). Same check for mem-eval-b.

  • Step 5: ArgoCD Application with its own finalizer (mem-eval-c.yaml)
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: mem-eval-c
namespace: argocd
finalizers: [resources-finalizer.argocd.argoproj.io] # per-Application; teardown cascades
spec:
project: core-services
sources:
- repoURL: https://github.com/fzymgc-house/selfhosted-cluster
targetRevision: HEAD
path: argocd/app-configs/mem-eval-c
destination: {server: https://kubernetes.default.svc, namespace: mem-eval}
syncPolicy:
automated: {prune: true, selfHeal: true}
syncOptions: [ServerSideApply=true, CreateNamespace=true, RespectIgnoreDifferences=true]
ignoreDifferences:
- kind: ExternalSecret
group: external-secrets.io
jqPathExpressions: [.spec.data[].remoteRef.conversionStrategy, .spec.data[].remoteRef.decodingStrategy, .spec.data[].remoteRef.metadataPolicy]
  • Step 6: Commit
feat(mem-eval-c): deploy Qdrant + Go MCP server via ArgoCD (hl-73s)
  • Step 7: Verify after sync (post-merge)
Terminal window
argocd app sync mem-eval-c && argocd app wait mem-eval-c --health --timeout 180
kubectl -n mem-eval rollout status deploy/mem-eval-c deploy/qdrant
# store+search roundtrip through the live MCP endpoint (use an MCP client or curl the streamable endpoint)

Expected: both Healthy; a store_memory then search_memory returns the stored item.


Phase 2 — Candidate B: Graphiti temporal graph over FalkorDB

Section titled “Phase 2 — Candidate B: Graphiti temporal graph over FalkorDB”

Task 10: Build a patched Graphiti MCP image (local-Ollama fix)

Section titled “Task 10: Build a patched Graphiti MCP image (local-Ollama fix)”

Files:

  • Create: services/graphiti-mcp/Dockerfile
  • Create: services/graphiti-mcp/config.yaml

Why: Graphiti issue #1116 — the OpenAI-provider LLM factory ignores api_url and hits api.openai.com (401). Verify whether the chosen zepai/knowledge-graph-mcp tag already includes PR #1338 (base_url passed) and #1339 (OpenAIGenericClient). If yes, skip the patch and use the published image. If no, build from source with the patch.

  • Step 1: Check whether the published image is already fixed
Terminal window
docker run --rm zepai/knowledge-graph-mcp:latest sh -c \
"grep -n 'base_url=config.providers.openai.api_url' src/services/factories.py" || echo "NEEDS PATCH"

If the grep matches → the fix is present; record the digest and skip to Task 11 using zepai/knowledge-graph-mcp@<digest>. Else continue.

  • Step 2: Dockerfile that applies the one-line fix

services/graphiti-mcp/Dockerfile:

FROM zepai/knowledge-graph-mcp:latest
# Fix #1116: pass base_url to the OpenAI LLM factory so LLM calls honor api_url (LiteLLM→Ollama)
RUN sed -i 's/model=config.model,/base_url=config.providers.openai.api_url,\n model=config.model,/' \
/app/mcp/src/services/factories.py
# Verify the patch landed (fail the build if the anchor moved)
RUN grep -q 'base_url=config.providers.openai.api_url' /app/mcp/src/services/factories.py

If sed anchors drift between versions, pin a specific upstream tag and adjust. The build-time grep guard fails loudly rather than shipping an unpatched image.

  • Step 3: config.yaml (env-expanded; LiteLLM + FalkorDB + Ollama)

services/graphiti-mcp/config.yaml:

server: {transport: "http", host: "0.0.0.0", port: 8000}
llm:
provider: "openai"
model: "ollama/qwen2.5:14b"
providers:
openai:
api_key: ${OPENAI_API_KEY}
api_url: ${OPENAI_API_URL} # LiteLLM /v1
embedder:
provider: "openai"
model: "ollama/nomic-embed-text"
dimensions: 768
providers:
openai: {api_key: ${OPENAI_API_KEY}, api_url: ${OPENAI_API_URL}}
database:
provider: "falkordb"
providers:
falkordb: {uri: ${FALKORDB_URI}, password: ${FALKORDB_PASSWORD:}, database: ${FALKORDB_DATABASE:mem_eval}}
  • Step 4: Build + push
Terminal window
docker build -t ghcr.io/fzymgc-house/graphiti-mcp:patched-0.1.0 services/graphiti-mcp
docker push ghcr.io/fzymgc-house/graphiti-mcp:patched-0.1.0
  • Step 5: Commit
feat(mem-eval-b): build patched Graphiti MCP image for local Ollama (hl-73s)

Task 11: Deploy Candidate B (FalkorDB + Graphiti) via ArgoCD

Section titled “Task 11: Deploy Candidate B (FalkorDB + Graphiti) via ArgoCD”

Files:

  • Create: argocd/app-configs/mem-eval-b/{kustomization,falkordb,deployment,configmap,service,certificate,ingress}.yaml
  • Create: argocd/cluster-app/templates/mem-eval-b.yaml
  • Step 1: FalkorDB Deployment + Service (falkordb.yaml)

falkordb/falkordb:latest, port 6379 (+ 3000 web UI), emptyDir storage (throwaway), Service falkordb:6379.

  • Step 2: Graphiti config as ConfigMap + Deployment (configmap.yaml, deployment.yaml)

ConfigMap holds config.yaml from Task 10 Step 3. Deployment uses ghcr.io/fzymgc-house/graphiti-mcp:patched-0.1.0, mounts the config at /app/mcp/config/config.yaml, env:

- {name: OPENAI_API_URL, value: "http://litellm.litellm.svc.cluster.local:4000/v1"}
- name: OPENAI_API_KEY
valueFrom: {secretKeyRef: {name: mem-eval-litellm, key: litellm_ollama_key}}
- {name: OPENAI_BASE_URL, value: "http://litellm.litellm.svc.cluster.local:4000/v1"} # belt-and-suspenders for cross_encoder
- {name: FALKORDB_URI, value: "redis://falkordb.mem-eval.svc.cluster.local:6379"}
- {name: FALKORDB_DATABASE, value: "mem_eval"}
- {name: SEMAPHORE_LIMIT, value: "3"} # keep low for local Ollama throughput
- {name: GRAPHITI_TELEMETRY_ENABLED, value: "false"}

Put reloader.stakater.com/auto: "true" on the Deployment’s metadata.annotations (exactly as in Task 9 Step 2) — not in the container spec.

  • Step 3: Service, Certificate, IngressRoute — host mem-b.fzymgc.housemem-eval-b:8000, path /mcp/. Mirror the litellm trio.

  • Step 4: kustomization.yaml — resources: ../mem-eval/external-secret.yaml, falkordb, configmap, deployment, service, certificate, ingress.

  • Step 5: ArgoCD Application mem-eval-b — identical shape to Task 9 Step 5 (its OWN resources-finalizer), path: argocd/app-configs/mem-eval-b.

  • Step 6: Commit

feat(mem-eval-b): deploy FalkorDB + patched Graphiti via ArgoCD (hl-73s)
  • Step 7: Verify the local-Ollama path end-to-end (post-merge)
Terminal window
argocd app sync mem-eval-b && argocd app wait mem-eval-b --health --timeout 240
# add an episode, wait ~30s (async), then search
kubectl -n mem-eval logs deploy/mem-eval-b | grep -i 'api.openai.com' && echo "LEAK!" || echo "no cloud calls"

Expected: an add_episode → (wait) → search_facts returns a fact; zero api.openai.com log lines (proves the #1116 patch works and egress is local). If you see 401 from api.openai.com, the patch did not apply — return to Task 10.


  • Step 1: Add both (user scope)
Terminal window
claude mcp add --transport http --scope user mem-c https://mem-c.fzymgc.house/mcp
claude mcp add --transport http --scope user mem-b https://mem-b.fzymgc.house/mcp/

Use "type": "http" semantics (the CLI sets this) — not url-type, which triggers an OAuth handshake that 404s for these servers.

  • Step 2: Verify
Terminal window
claude mcp list | grep -E 'mem-(b|c)'

Expected: both listed and reachable.

Task 13: SessionStart context-injection hook

Section titled “Task 13: SessionStart context-injection hook”

Files:

  • Create: tools/mem-eval/hooks/session-start.sh
  • Modify: ~/.claude/settings.json (SessionStart hook registration — documented, not committed to repo)
  • Step 1: Write the hook (resolves scope, queries both stores, emits additionalContext)

tools/mem-eval/hooks/session-start.sh:

#!/usr/bin/env bash
set -euo pipefail
repo="$(basename "$(git rev-parse --show-toplevel 2>/dev/null || echo unknown)")"
scope="eval-2026-05:project:${repo}"
# Query mem-c (fast) for recent + relevant; cap to keep context small.
mem="$(curl -sS --max-time 5 "https://mem-c.fzymgc.house/mcp" \
-H 'Content-Type: application/json' \
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"search_memory\",\"arguments\":{\"query\":\"project context\",\"scope\":\"${scope}\",\"k\":10}}}" \
2>/dev/null | jq -r '.result.structuredContent.memories[]?.content' 2>/dev/null | head -20 || true)"
[ -z "$mem" ] && exit 0
ctx="Relevant memories for ${repo} (mem-eval):\n${mem}"
jq -cn --arg c "$ctx" '{hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:$c}}'

Verified contract: SessionStart injects via hookSpecificOutput.additionalContext (string). No systemPrompt field exists.

  • Step 2: Register in settings.json (documented in tools/mem-eval/README.md)
{"hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": "/abs/path/tools/mem-eval/hooks/session-start.sh"}]}]}}
  • Step 3: Verify — start a fresh Claude Code session in this repo; confirm the injected memories appear in context (or the hook exits 0 silently when none).

  • Step 4: Commit the hook script (feat(mem-eval): SessionStart memory-injection hook (hl-73s)).

Task 14: Stop capture-nudge hook (gated once per session)

Section titled “Task 14: Stop capture-nudge hook (gated once per session)”

Files:

  • Create: tools/mem-eval/hooks/stop-nudge.sh

  • Step 1: Write the hook — on Stop, if a per-session sentinel file is absent, create it and emit a block-with-reason nudging Claude to call store_memory for any durable facts; otherwise exit 0.

#!/usr/bin/env bash
set -euo pipefail
sid="${CLAUDE_SESSION_ID:-default}"; flag="/tmp/mem-eval-nudge-${sid}"
[ -f "$flag" ] && exit 0
: > "$flag"
jq -cn '{decision:"block",reason:"Before stopping: if this session produced durable decisions, preferences, conventions, or gotchas worth remembering, call mem-c store_memory (scope eval-2026-05:project:<repo>, source agent-inferred). Skip transient state, secrets, timestamps. Then stop."}'
  • Step 2: Register under hooks.Stop in settings.json (same pattern as Task 13).

  • Step 3: Verify — finish a turn; confirm the nudge fires exactly once per session.

  • Step 4: Commit (feat(mem-eval): Stop capture-nudge hook (hl-73s)).

Files:

  • Create: tools/mem-eval/memory-instructions.md (paste into the test repos’ CLAUDE.md during the eval)

  • Step 1: Write the store/never-store rules — the #4573 junk taxonomy as negative rules (NEVER store: system-prompt restating, transient task state, secrets/PII, standalone timestamps, hallucinated profiles). STORE: decisions, preferences, conventions, gotchas, with source and category. Include the exact scope convention and both tool names (mem-c, mem-b).

  • Step 2: Commit (docs(mem-eval): memory capture instructions for CLAUDE.md (hl-73s)).


Task 16: Ground-truth probe sets (Signals 2 & 4)

Section titled “Task 16: Ground-truth probe sets (Signals 2 & 4)”

Files:

  • Create: tools/mem-eval/probes/should-remember.jsonl (recall set)

  • Create: tools/mem-eval/probes/contradictions.jsonl (supersession set, B)

  • Step 1: Seed should-remember.jsonl{query, expected_substring, scope} lines for ~15 facts you expect to be recalled. Append during the week whenever Claude lacked context it should have had.

  • Step 2: Seed contradictions.jsonl{fact_v1, fact_v2, query, expected_current_substring} for ~10 mutable attributes (current branch tool, chosen DB, decided approach).

  • Step 3: Commit (feat(mem-eval): seed recall + contradiction probe sets (hl-73s)).

Task 17: Harness scripts (recall, supersession, junk audit, egress, firehose)

Section titled “Task 17: Harness scripts (recall, supersession, junk audit, egress, firehose)”

Files:

  • Create: tools/mem-eval/harness/recall_score.sh — replay should-remember.jsonl against both stores; print hit-rate per store (PASS ≥ 70%).
  • Create: tools/mem-eval/harness/supersession_score.sh — for each contradiction pair: store v1 → store v2 → query; assert current returns v2 (B; PASS ≥ 90%). For C, record the manual update/delete correction-effort instead.
  • Create: tools/mem-eval/harness/junk_audit.sh — dump all memories per scope to a CSV for manual classification against the #4573 taxonomy; compute junk %.
  • Create: tools/mem-eval/harness/egress_check.sh — query LiteLLM spend logs / pod logs for any non-ollama/* model usage by the mem-eval-ollama key (PASS = 0).
  • Create: tools/mem-eval/harness/firehose_replay.shCandidate B only: replay raw session transcript turns from ~/.claude/projects/<proj>/*.jsonl as add_episode into scope eval-firehose:project:<repo>, to measure extraction junk vs. curated.
  • Step 1–5: Write each script with a concrete, runnable body and a one-line usage comment. Each prints a single summary number to stdout.
  • Step 6: Commit (feat(mem-eval): measurement harness scripts (hl-73s)).
  • Step 1: Use both memory servers in real sessions across multiple repos for ~7 days (curated ingestion).
  • Step 2: Mid-week, run firehose_replay.sh against B into eval-firehose.
  • Step 3: At week end, run all harness scripts; record every Signal in a results table appended to the spec (§7.3).
  • Step 4: Run egress_check.shmust be 0; if not, the eval is invalid until explained.

  • Step 1: Fill the §7.3 results table; apply §7.4 kill criteria. Record the verdict (B / C / both-for-different-scopes / neither) as a bd note on hl-73s and a short addendum to the spec.
  • Step 1: Delete both ArgoCD Applications (finalizers cascade)
Terminal window
argocd app delete mem-eval-c --cascade
argocd app delete mem-eval-b --cascade
kubectl get ns mem-eval -o name && kubectl delete ns mem-eval # remove the (now empty) namespace
  • Step 2: Revoke the LiteLLM key + remove the wildcard routes
Terminal window
kubectl -n litellm exec deploy/litellm -- curl -sS http://localhost:4000/key/delete \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "Content-Type: application/json" \
-d '{"keys": ["<MEM_EVAL_KEY>"]}'
vault kv delete fzymgc-house/cluster/mem-eval

Revert the Task 1 configmap.yaml addition in a follow-up commit (revert(litellm): drop mem-eval ollama routes).

  • Step 3: Remove Claude Code wiring
Terminal window
claude mcp remove mem-c; claude mcp remove mem-b
# remove the SessionStart + Stop hooks from settings.json; revert CLAUDE.md memory instructions
  • Step 4: Close or update hl-73s with the outcome.

  • Hard constraint “no cloud egress” → Tasks 1, 3 (guardrail key + negative test), 17 (egress_check), 11 Step 7 (B log check).
  • “Correctable” → C update_memory/delete_memory (Task 7); B delete_entity_edge/delete_episode (native) + correction-effort measurement (Task 17).
  • “Reuse infra; no PostgreSQL” → neither candidate uses CNPG; Qdrant + FalkorDB deployed fresh (Tasks 9, 11).
  • Namespace/provenance model → scope key + payload/group_id (Conventions; Tasks 6, 7).
  • SessionEnd read-only correction → ingestion is agent-controlled + Stop nudge (Tasks 13, 14).
  • Pass/fail + methodology → Tasks 16–18; kill criteria → Task 19.
  • Per-Application finalizers (teardown) → Tasks 9, 11, 20.