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:
@@ -0,0 +1,92 @@
|
||||
package cognition
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/andydixon/noema/internal/brain"
|
||||
"github.com/andydixon/noema/internal/memory"
|
||||
"github.com/andydixon/noema/internal/workspace"
|
||||
"github.com/andydixon/noema/pkg/concepts"
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
// Handlers are the executive actions for admitted thought kinds.
|
||||
type Handlers struct {
|
||||
Memory *memory.Store
|
||||
}
|
||||
|
||||
// Register installs the handlers on a registry.
|
||||
func (h Handlers) Register(r *brain.Registry) {
|
||||
r.Handle("attend", h.attend)
|
||||
r.Handle("retrieve", h.retrieve)
|
||||
}
|
||||
|
||||
// attend encodes the attended event as a memory and holds it in working
|
||||
// memory. Only attended events become episodes: attention gates encoding.
|
||||
func (h Handlers) attend(ctx context.Context, c *brain.Cycle, t workspace.Thought) error {
|
||||
evID, _ := t.Content["source_event"].(string)
|
||||
var mem memory.Memory
|
||||
if h.Memory != nil && evID != "" {
|
||||
ev, err := c.Runtime.Events().Get(ctx, ids.ID(evID))
|
||||
if err == nil {
|
||||
n := memory.New{MindID: c.State.Mind.ID, Kind: concepts.Episodic, Epistemic: concepts.Observation, Content: ev.Summary, Source: ev.Source,
|
||||
Trust: events.TrustLow, Confidence: ev.Confidence, Importance: t.Score, Salience: ev.Salience, Valence: ev.Valence, Arousal: ev.Arousal, OccurredAt: ev.OccurredAt, OriginEvent: ev.ID,
|
||||
Detail: map[string]any{"event_type": ev.Type, "participants": []string{}}}
|
||||
switch ev.Type {
|
||||
case events.Observation:
|
||||
var p events.ObservationPayload
|
||||
if json.Unmarshal(ev.Payload, &p) == nil {
|
||||
if p.Text != "" {
|
||||
n.Content = p.Text
|
||||
}
|
||||
n.Trust = p.Trust
|
||||
n.Detail["channel"], n.Detail["reference"], n.Detail["method"] = p.Channel, p.Reference, p.Method
|
||||
if p.Actor != "" {
|
||||
n.Detail["participants"] = []string{p.Actor}
|
||||
}
|
||||
}
|
||||
case events.OperatorAction:
|
||||
n.Kind, n.Trust, n.Importance = concepts.Autobiographical, events.TrustOperator, max(t.Score, 0.6)
|
||||
n.Content = "An operator changed me: " + ev.Summary
|
||||
default:
|
||||
n.Trust = events.TrustSystem
|
||||
}
|
||||
mem, err = h.Memory.Create(ctx, c.Tx, n)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode memory: %w", err)
|
||||
}
|
||||
c.Emit(events.New{Type: events.MemoryCreated, Source: "brain.memory", Summary: fmt.Sprintf("encoded %s memory: %s", mem.Kind, truncate(mem.Content, 120)),
|
||||
Salience: 0.3, Confidence: mem.Confidence, CausationID: t.EventID, ParentIDs: []ids.ID{t.EventID, ev.ID},
|
||||
Evidence: []events.EvidenceRef{{Kind: "memory", ID: string(mem.ID)}, {Kind: "event", ID: string(ev.ID), Note: "source"}},
|
||||
Metadata: map[string]any{"memory_id": mem.ID, "kind": mem.Kind}, Payload: map[string]any{"memory_id": mem.ID, "kind": mem.Kind, "importance": mem.Importance, "trust": mem.Trust}})
|
||||
}
|
||||
}
|
||||
item := workspace.Item{Key: "attend:" + t.Subject, Content: t.Summary, Kind: "observation", SourceEvent: ids.ID(evID)}
|
||||
if !mem.ID.IsZero() {
|
||||
item.Ref = string(mem.ID)
|
||||
}
|
||||
c.Runtime.Hold(item)
|
||||
return nil
|
||||
}
|
||||
|
||||
// retrieve reinforces a recalled memory, records the retrieval with its
|
||||
// scoring components, and holds it in working memory.
|
||||
func (h Handlers) retrieve(ctx context.Context, c *brain.Cycle, t workspace.Thought) error {
|
||||
ref, _ := t.Content["ref"].(string)
|
||||
if h.Memory == nil || ref == "" {
|
||||
return nil
|
||||
}
|
||||
mid := ids.ID(ref)
|
||||
if err := h.Memory.Reinforce(ctx, c.Tx, c.State.Mind.ID, mid); err != nil {
|
||||
return err
|
||||
}
|
||||
src, _ := t.Content["source_event"].(string)
|
||||
c.Emit(events.New{Type: events.MemoryRetrieved, Source: "brain.memory", Summary: t.Summary, Salience: t.Score, Confidence: t.Confidence,
|
||||
CausationID: t.EventID, ParentIDs: []ids.ID{t.EventID}, Evidence: t.Evidence,
|
||||
Metadata: map[string]any{"memory_id": mid, "score": t.Content["score"]}, Payload: map[string]any{"memory_id": mid, "score": t.Content["score"], "components": t.Content["components"], "query": t.Content["query"]}})
|
||||
c.Runtime.Hold(workspace.Item{Key: "memory:" + ref, Content: t.Summary, Kind: "retrieval", Ref: ref, SourceEvent: ids.ID(src)})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package cognition
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/andydixon/noema/internal/attention"
|
||||
"github.com/andydixon/noema/internal/brain"
|
||||
"github.com/andydixon/noema/internal/memory"
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
// Retrieve recalls memories relevant to each new external event and proposes
|
||||
// them as candidates. Retrieval scoring is deterministic and recorded on the
|
||||
// candidate so the UI can show why a memory came to mind.
|
||||
type Retrieve struct{}
|
||||
|
||||
// Name implements brain.Process.
|
||||
func (Retrieve) Name() string { return "retrieve" }
|
||||
|
||||
// minRetrievalScore filters weak matches before they compete for attention.
|
||||
const minRetrievalScore = 0.30
|
||||
|
||||
// Observe implements brain.Process.
|
||||
func (Retrieve) Observe(ctx context.Context, s *brain.State) ([]brain.Candidate, error) {
|
||||
if s.Services.Memory == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var out []brain.Candidate
|
||||
seen := map[ids.ID]bool{}
|
||||
for _, ev := range s.NewEvents {
|
||||
if strings.HasPrefix(ev.Source, "brain") || (ev.Type != events.Observation && ev.Type != events.Perception) {
|
||||
continue
|
||||
}
|
||||
text := ev.Summary
|
||||
var p events.ObservationPayload
|
||||
if json.Unmarshal(ev.Payload, &p) == nil && p.Text != "" {
|
||||
text = p.Text
|
||||
}
|
||||
hits, err := s.Services.Memory.Retrieve(ctx, s.Mind.ID, memory.Query{Text: text, Limit: 5, Now: s.Now, Semantic: true})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
for _, h := range hits {
|
||||
m := h.Memory
|
||||
if h.Score < minRetrievalScore || m.OriginEvent == ev.ID || seen[m.ID] {
|
||||
continue
|
||||
}
|
||||
seen[m.ID] = true
|
||||
out = append(out, brain.Candidate{Kind: "retrieve", Subject: string(m.ID), Summary: "recalled: " + truncate(m.Content, 160),
|
||||
Content: map[string]any{"ref": string(m.ID), "memory_kind": m.Kind, "score": h.Score, "components": h.Components, "query": truncate(text, 200), "source_event": string(ev.ID)},
|
||||
Features: attention.Features{Salience: h.Score * 0.8, ContextRelevance: h.Score, Confidence: m.Confidence, Novelty: 0.2, Consequence: 0.3 + 0.4*m.Importance, Urgency: 0.1},
|
||||
Confidence: m.Confidence, SourceEvents: []ids.ID{ev.ID},
|
||||
Evidence: []events.EvidenceRef{{Kind: "memory", ID: string(m.ID), Note: "retrieved"}, {Kind: "event", ID: string(ev.ID), Note: "cue"}}})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
Reference in New Issue
Block a user