Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
829a08f
docs(spec): memory eval & optimization program design (uplift x2, inj…
fasdwcx Jul 24, 2026
3ed009b
docs(plan): memory eval foundation implementation plan (Phase 0 + Pha…
fasdwcx Jul 24, 2026
f49c59e
feat(eval): metrics package (recall@k, MRR, approx tokens, keyword-gr…
fasdwcx Jul 24, 2026
0af7829
feat(eval): retrieval QA dataset schema and JSONL loader
fasdwcx Jul 24, 2026
1b6df65
feat(eval): scorecard JSON persistence and markdown comparison
fasdwcx Jul 24, 2026
01ac6fa
feat(eval): L1 retrieval runner and evalrun CLI skeleton
fasdwcx Jul 24, 2026
6f10d50
docs(research): memory SOTA survey with engram-mapped priority matrix
fasdwcx Jul 24, 2026
fa5bb04
feat(eval): freeze phoenix-retrieval-v1 dataset (dump-facts tooling +…
fasdwcx Jul 24, 2026
de214d6
fix(eval): de-parrot retrieval dataset keyword groups (pre-freeze con…
fasdwcx Jul 24, 2026
14d8933
feat(eval): L2 injected-token meter (static protocol + context + sear…
fasdwcx Jul 24, 2026
b1c113c
feat(eval): L3 task schema and frozen phoenix-e2e-v1 task set
fasdwcx Jul 24, 2026
95872a4
fix(eval): de-leak small-fix task prompts and correct fix-002 provena…
fasdwcx Jul 24, 2026
91ef1ba
fix(cli-tests): force sqlite routing in tests so live MemoryLake enab…
fasdwcx Jul 24, 2026
d019221
fix(cli-tests): isolate HOME so tests can never touch the real ~/.eng…
fasdwcx Jul 24, 2026
3f3b506
Revert "fix(cli-tests): isolate HOME so tests can never touch the rea…
fasdwcx Jul 24, 2026
36d9870
Revert "fix(cli-tests): force sqlite routing in tests so live MemoryL…
fasdwcx Jul 24, 2026
2adc24c
feat(eval): L3 runner with isolated arms, claude driver, and calibrat…
fasdwcx Jul 24, 2026
6a40a5a
fix(eval): L3 runner resilience — judge retry with stderr, non-fatal …
fasdwcx Jul 24, 2026
2f076c7
style(eval): gofmt evalrun main.go
fasdwcx Jul 24, 2026
eab9e3a
fix(eval): disable tools for judge calls so single-turn judging canno…
fasdwcx Jul 27, 2026
8a55ff4
feat(eval): baseline scorecards and report (Phase 2 acceptance anchors)
fasdwcx Jul 27, 2026
5e44d66
docs(eval): correct tokenizer comment and L3 isolation/arm-protocol c…
fasdwcx Jul 27, 2026
1e880c7
fix(docs): restore cloud upgrade command examples and local-first wor…
fasdwcx Jul 27, 2026
437f8c4
fix(cli-tests): update MemoryLake routing fixtures for the direct fac…
fasdwcx Jul 27, 2026
9b451dc
chore: retrigger CI checks
fasdwcx Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ On a MemoryLake-backed project, memories are stored as **V3 facts** under the `e

## Cloud (Opt-In Replication)

Cloud is optional and always project-scoped (`--project` required; `engram sync --cloud --all` is blocked). Local SQLite stays authoritative; cloud is replication / shared access only.
Cloud is optional and always project-scoped (`--project` required; `engram sync --cloud --all` is blocked). Local SQLite stays authoritative; cloud is replication/shared access only.

```bash
docker compose -f docker-compose.cloud.yml up -d
Expand All @@ -202,6 +202,15 @@ engram cloud enroll smoke-project
engram sync --cloud --project smoke-project
```

Upgrading a project whose legacy cloud state predates the current sync contract follows the guided flow `doctor -> repair -> bootstrap -> status/rollback` (local SQLite remains source of truth throughout):

```bash
engram cloud upgrade doctor --project my-project # diagnose legacy state
engram cloud upgrade repair --project my-project # fix repairable mutations
engram cloud upgrade bootstrap --project my-project # re-baseline cloud from local
engram cloud upgrade status --project my-project # verify (rollback available)
```

Authenticated mode, upgrade flow, dashboard, and env details → [Engram Cloud docs](docs/engram-cloud/README.md) · [DOCS.md — Cloud CLI](DOCS.md#cloud-cli-opt-in).

## CLI & Environment Reference
Expand Down
107 changes: 82 additions & 25 deletions cmd/engram/routing_cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ package main

import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"sync"
"testing"

"github.com/Gentleman-Programming/engram/internal/memorylake"
Expand Down Expand Up @@ -36,54 +37,93 @@ func setupMemoryLakeEnv(t *testing.T, srvURL, project, projID string) {
}
}

// fakeMemoryLakeRecorder records what newFakeMemoryLakeServer's fake API was
// asked to do, so tests can assert the CLI really went through MemoryLake's
// direct fact-add write path rather than inferring it from the absence of a
// local sqlite row.
type fakeMemoryLakeRecorder struct {
mu sync.Mutex
// factPosts counts POST .../projects/proj-1/memories/facts calls (the
// direct fact-add endpoint AddObservation writes through).
factPosts int
// factTexts collects every verbatim fact string posted to that endpoint,
// in order.
factTexts []string
}

func (r *fakeMemoryLakeRecorder) snapshot() (int, []string) {
r.mu.Lock()
defer r.mu.Unlock()
return r.factPosts, append([]string(nil), r.factTexts...)
}

// newFakeMemoryLakeServer stands up a minimal MemoryLake V3 API covering
// exactly what MemoryLakeBackend.AddObservation / Search / NewBackend need:
// actor provisioning, conversation+message append, fact snapshot/backfill
// polling, and search. Mirrors internal/memorylake/backend_test.go's fixture
// servers (TestBackend_AddObservation_ReturnsInt64ViaBackfill /
// TestBackend_Search_PassesThrough).
func newFakeMemoryLakeServer(t *testing.T, searchHits []map[string]any) (*httptest.Server, *int32) {
// actor provisioning, the direct fact-add endpoint, and search.
//
// AddObservation writes verbatim through MemoryLake's direct fact-add
// endpoint — POST /api/v3/workspaces/<ws>/projects/<projID>/memories/facts
// with body {"facts": ["<text>"]}, answered with
// {"success":true,"data":{"facts":[{"id":...,"fact":...}]}} (see
// internal/memorylake/writequeue.go's Client.AddFacts and backend.go's
// AddObservation). It does NOT go through conversations/messages plus async
// fact extraction anymore, so this fixture deliberately serves no
// conversation, message, or fact-backfill/PATCH routes: an attempt to use
// them is a real regression and must fail the test.
//
// Mirrors internal/memorylake/backend_test.go's
// TestBackend_AddObservation_WritesVerbatimFactDirectly fixture.
func newFakeMemoryLakeServer(t *testing.T, searchHits []map[string]any) (*httptest.Server, *fakeMemoryLakeRecorder) {
t.Helper()
var appended int32
rec := &fakeMemoryLakeRecorder{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "POST" && r.URL.Path == "/api/v3/actors":
json.NewEncoder(w).Encode(map[string]any{"success": true, "data": map[string]any{"id": "actor-x"}})
case r.Method == "POST" && r.URL.Path == "/api/v3/workspaces/ws-1/actors":
json.NewEncoder(w).Encode(map[string]any{"success": true})
case r.Method == "POST" && r.URL.Path == "/api/v3/workspaces/ws-1/memories/conversations":
// Still live, but only for session lifecycle: CreateSession
// ensures a conversation keyed by the session id (backend.go).
// Observations no longer flow through it.
json.NewEncoder(w).Encode(map[string]any{"success": true, "data": map[string]any{"id": "conv-1"}})
case r.Method == "POST" && r.URL.Path == "/api/v3/conversations/conv-1/messages":
atomic.StoreInt32(&appended, 1)
json.NewEncoder(w).Encode(map[string]any{"success": true, "data": map[string]any{"id": "msg-1"}})
case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/api/v3/workspaces/ws-1/projects/proj-1/memories/facts"):
var items []map[string]any
if atomic.LoadInt32(&appended) == 1 {
items = []map[string]any{
{"id": "fact-1", "fact": "extracted", "metadata": map[string]any{}},
}
case r.Method == "POST" && r.URL.Path == "/api/v3/workspaces/ws-1/projects/proj-1/memories/facts":
var body struct {
Facts []string `json:"facts"`
}
json.NewDecoder(r.Body).Decode(&body)
rec.mu.Lock()
rec.factPosts++
rec.factTexts = append(rec.factTexts, body.Facts...)
rec.mu.Unlock()
created := make([]map[string]any, 0, len(body.Facts))
for i, f := range body.Facts {
created = append(created, map[string]any{
"id": fmt.Sprintf("fact-%d", i+1), "fact": f, "metadata": map[string]any{},
})
}
json.NewEncoder(w).Encode(map[string]any{"success": true, "data": map[string]any{"items": items}})
case r.Method == "PATCH" && r.URL.Path == "/api/v3/workspaces/ws-1/projects/proj-1/memories/facts/fact-1":
json.NewEncoder(w).Encode(map[string]any{"success": true, "data": map[string]any{
"id": "fact-1", "fact": "extracted", "metadata": map[string]any{},
}})
json.NewEncoder(w).Encode(map[string]any{"success": true, "data": map[string]any{"facts": created}})
case r.Method == "POST" && r.URL.Path == "/api/v3/workspaces/ws-1/memories/search":
json.NewEncoder(w).Encode(map[string]any{"success": true, "data": map[string]any{"facts": searchHits}})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
// Must NOT be t.Fatalf: this runs on the http handler goroutine,
// where runtime.Goexit kills the whole test binary (the package
// then fails with no named test in CI logs). Fail loudly but let
// the request — and the test — finish.
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
http.Error(w, "unexpected request", http.StatusInternalServerError)
}
}))
t.Cleanup(srv.Close)
return srv, &appended
return srv, rec
}

// TestCmdSave_EnabledProjectRoutesToMemoryLake is the RED/GREEN case for
// Task 15: `engram save --project <enabled>` must route through the same
// NewRoutingSelector `engram mcp` uses, landing the observation in
// MemoryLake instead of local sqlite.
func TestCmdSave_EnabledProjectRoutesToMemoryLake(t *testing.T) {
srv, _ := newFakeMemoryLakeServer(t, nil)
srv, rec := newFakeMemoryLakeServer(t, nil)
setupMemoryLakeEnv(t, srv.URL, "myproj", "proj-1")

cfg := testConfig(t)
Expand All @@ -96,6 +136,20 @@ func TestCmdSave_EnabledProjectRoutesToMemoryLake(t *testing.T) {
if !strings.Contains(stdout, "Memory saved:") {
t.Fatalf("unexpected save output: %q", stdout)
}
// The printed id is the sync id the backend returned, i.e. the real fact
// id the direct fact-add endpoint handed back — not a local sqlite rowid.
if !strings.Contains(stdout, "#fact-1") {
t.Fatalf("expected save output to report the MemoryLake fact id, got: %q", stdout)
}

// The write went through the direct fact-add endpoint, verbatim.
posts, texts := rec.snapshot()
if posts != 1 {
t.Fatalf("fact-add posts=%d, want exactly 1", posts)
}
if len(texts) != 1 || !strings.Contains(texts[0], "ml-content") || !strings.Contains(texts[0], "ml-title") {
t.Fatalf("posted fact texts=%q, want one text carrying the title and content verbatim", texts)
}

// Verify the local sqlite store received NOTHING for this project — the
// save went to MemoryLake, not sqlite (split-brain would show it in both
Expand Down Expand Up @@ -142,7 +196,10 @@ func TestCmdSearch_EnabledProjectRoutesToMemoryLake(t *testing.T) {
// if it receives any request.
func TestCmdSaveAndSearch_NotEnabledProjectNeverHitsMemoryLakeNetwork(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("unexpected MemoryLake network call for a non-enabled project: %s %s", r.Method, r.URL.Path)
// t.Errorf, not t.Fatalf: this is the http handler goroutine, where
// a Fatal's runtime.Goexit would tear down the whole test binary.
t.Errorf("unexpected MemoryLake network call for a non-enabled project: %s %s", r.Method, r.URL.Path)
http.Error(w, "unexpected request", http.StatusInternalServerError)
}))
t.Cleanup(srv.Close)
setupMemoryLakeEnv(t, srv.URL, "some-other-enabled-project", "proj-1")
Expand Down
Loading