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:
@@ -85,7 +85,7 @@ func TestCycleRecordsProvenanceAndPersists(t *testing.T) {
|
||||
t.Fatalf("echo called %d times", e.calls.Load())
|
||||
}
|
||||
st := rt.copyState()
|
||||
if st.Cycle != 1 || len(st.Workspace) != 1 || st.Workspace[0].Subject != string(obs[0].ID) || len(st.WorkingMemory) != 1 {
|
||||
if st.Cycle != 1 || len(st.Workspace) != 1 || st.Workspace[0].Subject != string(obs[0].ID) || len(st.WorkingMemory) != 1 || st.Workspace[0].EventID.IsZero() {
|
||||
t.Fatalf("workspace wrong: %+v", st)
|
||||
}
|
||||
all, _ := es.Query(ctx, evstore.Filter{MindID: m.ID, Ascending: true})
|
||||
|
||||
+12
-5
@@ -469,7 +469,7 @@ func (r *Runtime) cycle(ctx context.Context, tx pgx.Tx, trigger string, start ti
|
||||
}
|
||||
c.Emit(events.New{Type: events.ThoughtCandidate, Source: "brain." + cand.Process, Summary: fmt.Sprintf("[%s %.2f] %s", fate, sc.Breakdown.Total, cand.Summary),
|
||||
Salience: sc.Breakdown.Total, Confidence: cand.Confidence, CausationID: causation, ParentIDs: cand.SourceEvents, Evidence: cand.Evidence,
|
||||
Metadata: map[string]any{"candidate_id": cand.ID, "process": cand.Process, "kind": cand.Kind, "subject": cand.Subject, "fate": fate, "rank": o.Rank},
|
||||
Metadata: map[string]any{"candidate_id": string(cand.ID), "process": cand.Process, "kind": cand.Kind, "subject": cand.Subject, "fate": fate, "rank": o.Rank},
|
||||
Payload: map[string]any{"candidate_id": cand.ID, "kind": cand.Kind, "subject": cand.Subject, "features": cand.Features, "breakdown": sc.Breakdown, "fate": fate, "reason": reason, "rank": o.Rank, "content": cand.Content}})
|
||||
}
|
||||
if err := c.flush(ctx); err != nil {
|
||||
@@ -478,9 +478,7 @@ func (r *Runtime) cycle(ctx context.Context, tx pgx.Tx, trigger string, start ti
|
||||
candidateEvent := map[ids.ID]ids.ID{}
|
||||
for _, ev := range c.emitted {
|
||||
if ev.Type == events.ThoughtCandidate {
|
||||
if cid, ok := ev.Metadata["candidate_id"].(ids.ID); ok {
|
||||
candidateEvent[cid] = ev.ID
|
||||
} else if s, ok := ev.Metadata["candidate_id"].(string); ok {
|
||||
if s, ok := ev.Metadata["candidate_id"].(string); ok {
|
||||
candidateEvent[ids.ID(s)] = ev.ID
|
||||
}
|
||||
}
|
||||
@@ -502,7 +500,7 @@ func (r *Runtime) cycle(ctx context.Context, tx pgx.Tx, trigger string, start ti
|
||||
}
|
||||
c.Emit(events.New{Type: events.ThoughtSelected, Source: "brain.workspace", Summary: t.Summary, Salience: t.Score, Confidence: t.Confidence,
|
||||
CausationID: firstOr(parents, ids.Nil), ParentIDs: parents, Evidence: t.Evidence,
|
||||
Metadata: map[string]any{"candidate_id": t.ID, "process": t.Process, "kind": t.Kind, "subject": t.Subject, "rank": outcomeByID[t.ID].Rank},
|
||||
Metadata: map[string]any{"candidate_id": string(t.ID), "process": t.Process, "kind": t.Kind, "subject": t.Subject, "rank": outcomeByID[t.ID].Rank},
|
||||
Payload: map[string]any{"thought": t, "why": fmt.Sprintf("rank %d of %d with score %.2f; %s", outcomeByID[t.ID].Rank, len(scored), t.Score, outcomeByID[t.ID].Reason)}})
|
||||
if r.metrics != nil {
|
||||
r.metrics.Admissions.Inc()
|
||||
@@ -619,6 +617,15 @@ func (r *Runtime) observe(ctx context.Context, st *State) []Candidate {
|
||||
return all
|
||||
}
|
||||
|
||||
// Events exposes the event store to handlers.
|
||||
func (r *Runtime) Events() *evstore.Store { return r.events }
|
||||
|
||||
// Hold places an item in working memory using the mind's configured capacity.
|
||||
// It must only be called from handlers (on the loop goroutine).
|
||||
func (r *Runtime) Hold(it workspace.Item) {
|
||||
r.state.Hold(it, r.mind.Config.WorkingMemoryCapacity, r.state.Cycle, r.now())
|
||||
}
|
||||
|
||||
// defaultHandler places an admitted thought into working memory.
|
||||
func defaultHandler(_ context.Context, c *Cycle, t workspace.Thought) error {
|
||||
key := t.Kind + ":" + t.Subject
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/noema/internal/attention"
|
||||
"github.com/andydixon/noema/internal/memory"
|
||||
"github.com/andydixon/noema/internal/minds"
|
||||
"github.com/andydixon/noema/internal/workspace"
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
@@ -66,6 +67,7 @@ type State struct {
|
||||
// Services are read handles processes may use. Extended per phase.
|
||||
type Services struct {
|
||||
Events EventReader
|
||||
Memory *memory.Store
|
||||
}
|
||||
|
||||
// EventReader is the subset of the event store processes may call.
|
||||
|
||||
Reference in New Issue
Block a user