feat: add long-term memory systems with fused retrieval and memory explorer

Phase 5 (ADR-0003). pkg/concepts fixes the epistemic vocabulary
(observation, fact, memory, claim, hypothesis, belief, prediction,
assumption, inference, opinion), memory kinds and relation names.

internal/memory stores episodic, semantic, procedural and
autobiographical memories in one mind-scoped table with trust-derived
credibility, importance, salience and decaying activation. Retrieval
fuses full-text rank (any-term by default), optional pgvector similarity,
recency, activation, importance and credibility, and returns the
component scores so every recall can be explained. Decay lowers
activation and never deletes; retraction and supersession keep rows for
provenance. Relationships are constrained to one mind at insert time.
Embeddings live in a conditional pgvector table keyed by model so vector
spaces are never mixed; without an embedder the system degrades to text
and graph retrieval.

Attention gates encoding: the attend handler turns admitted observations
into episodic memories (operator changes become autobiographical ones)
with memory_created events; the retrieve process proposes recalled
memories as candidates and admitted recalls are reinforced with
memory_retrieved events carrying their scoring components.

Memory explorer UI (search with component breakdown, timeline, filters,
operator knowledge entry), memory detail with provenance, relationships,
citing events and an inaccurate-memory correction; API; noemactl memory
commands; decay and embed-missing background jobs.
This commit is contained in:
Andy Dixon
2026-06-20 23:47:50 +01:00
parent e9c1b25c80
commit 03631e5685
30 changed files with 1957 additions and 25 deletions
+108
View File
@@ -0,0 +1,108 @@
package memory
import (
"context"
"testing"
"time"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/migrations"
"github.com/andydixon/noema/pkg/concepts"
"github.com/andydixon/noema/pkg/events"
)
func TestMemoryLifecycleRetrievalAndIsolation(t *testing.T) {
pool := storage.TestPool(t, migrations.FS)
ctx := context.Background()
ms := minds.NewStore(pool)
a, _ := ms.Create(ctx, minds.CreateParams{Slug: "a"})
b, _ := ms.Create(ctx, minds.CreateParams{Slug: "b"})
s := NewStore(ctx, pool, nil)
if !s.VectorsEnabled() {
t.Log("pgvector not available in this database; vector path skipped")
}
mk := func(mind minds.Mind, kind concepts.MemoryKind, content string, importance float64, when time.Time) Memory {
m, err := s.Create(ctx, pool, New{MindID: mind.ID, Kind: kind, Epistemic: concepts.Observation, Content: content, Source: "test", Trust: events.TrustUser, Confidence: 0.9, Importance: importance, OccurredAt: when})
if err != nil {
t.Fatal(err)
}
return m
}
now := time.Now()
e1 := mk(a, concepts.Episodic, "Andy asked for the atlas server to be checked after a latency spike", 0.7, now.Add(-48*time.Hour))
e2 := mk(a, concepts.Episodic, "The atlas disk showed SMART CRC errors during the incident", 0.8, now.Add(-24*time.Hour))
mk(a, concepts.Semantic, "Andy prefers Go for deployed services", 0.6, now.Add(-30*24*time.Hour))
mk(b, concepts.Episodic, "atlas latency spike discussed with a different operator", 0.9, now)
if _, err := s.Create(ctx, pool, New{MindID: a.ID, Kind: "weird", Epistemic: concepts.Claim, Content: "x"}); err == nil {
t.Fatal("invalid kind accepted")
}
got, err := s.Retrieve(ctx, a.ID, Query{Text: "atlas latency", Limit: 5})
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0].Memory.ID != e1.ID {
t.Fatalf("retrieval wrong: %+v", got)
}
if got[0].Components["text"] == 0 || got[0].Components["recency"] == 0 || got[0].Score <= 0 || got[0].Score > 1 {
t.Fatalf("components missing: %+v", got[0])
}
for _, r := range got {
if r.Memory.MindID != a.ID {
t.Fatal("cross-mind leakage in retrieval")
}
}
// Mind b sees only its own memory, and cannot Get a's memory.
gotB, _ := s.Retrieve(ctx, b.ID, Query{Text: "atlas", Limit: 5})
if len(gotB) != 1 || gotB[0].Memory.MindID != b.ID {
t.Fatalf("isolation broken: %+v", gotB)
}
if _, err := s.Get(ctx, b.ID, e1.ID); err == nil {
t.Fatal("cross-mind Get succeeded")
}
// Reinforcement and decay never delete.
if err := s.Reinforce(ctx, pool, a.ID, e2.ID); err != nil {
t.Fatal(err)
}
m, _ := s.Get(ctx, a.ID, e2.ID)
if m.RetrievalCount != 1 || m.LastRetrievedAt == nil {
t.Fatalf("reinforce wrong: %+v", m)
}
pool.Exec(ctx, `UPDATE memories SET created_at = now() - interval '60 days', last_retrieved_at = NULL WHERE id=$1`, e1.ID) //nolint:errcheck
if _, err := s.Decay(ctx, a.ID, 14*24*time.Hour); err != nil {
t.Fatal(err)
}
m1, _ := s.Get(ctx, a.ID, e1.ID)
if m1.Activation >= 1 || m1.Activation < 0.01 || m1.Status != StatusActive {
t.Fatalf("decay wrong: %+v", m1)
}
// Relationships are mind-scoped.
if _, err := s.Relate(ctx, pool, a.ID, e1.ID, e2.ID, concepts.RelatedTo, 0.8, "test"); err != nil {
t.Fatal(err)
}
if _, err := s.Relate(ctx, pool, a.ID, e1.ID, gotB[0].Memory.ID, concepts.RelatedTo, 0.8, "test"); err == nil {
t.Fatal("cross-mind relationship accepted")
}
rel, _ := s.Relationships(ctx, a.ID, e2.ID)
if len(rel) != 1 || rel[0].Direction != "in" || rel[0].Other.ID != e1.ID {
t.Fatalf("relationships wrong: %+v", rel)
}
// Retraction keeps the row.
if err := s.SetStatus(ctx, a.ID, e2.ID, StatusRetracted); err != nil {
t.Fatal(err)
}
list, _ := s.List(ctx, a.ID, Filter{})
if len(list) != 2 {
t.Fatalf("retracted memory still listed as active: %d", len(list))
}
if _, err := s.Get(ctx, a.ID, e2.ID); err != nil {
t.Fatal("retracted memory must remain readable")
}
st, _ := s.Stats(ctx, a.ID)
if st.Total != 2 || st.ByKind["semantic"] != 1 {
t.Fatalf("stats wrong: %+v", st)
}
}