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,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
extraCommands = append(extraCommands, command{"memory", "search <mind> <query>|list <mind>|inspect <mind> <id>|add <mind> [-kind k] [-epistemic e] <text> – long-term memory", cmdMemory})
|
||||
}
|
||||
|
||||
func cmdMemory(args []string) int {
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: noemactl memory search <mind> <query...> | list <mind> [-kind k] | inspect <mind> <id> | add <mind> [-kind semantic] [-epistemic fact] [-confidence 0.8] <text...>")
|
||||
return exitUsage
|
||||
}
|
||||
mind := args[1]
|
||||
switch args[0] {
|
||||
case "search":
|
||||
var out struct{ Items []map[string]any }
|
||||
if code, err := apiRequest("GET", "/api/v1/minds/"+mind+"/memories?q="+url.QueryEscape(strings.Join(args[2:], " ")), nil, &out); err != nil {
|
||||
return exitFor(code, err)
|
||||
}
|
||||
for _, h := range out.Items {
|
||||
m, _ := h["memory"].(map[string]any)
|
||||
fmt.Printf("%.2f [%s/%s] %s (%s)\n", num(h["score"]), m["kind"], m["epistemic"], m["content"], m["id"])
|
||||
if comps, ok := h["components"].(map[string]any); ok {
|
||||
var parts []string
|
||||
for k, v := range comps {
|
||||
parts = append(parts, fmt.Sprintf("%s=%.2f", k, num(v)))
|
||||
}
|
||||
fmt.Printf(" %s\n", strings.Join(parts, " "))
|
||||
}
|
||||
}
|
||||
return exitOK
|
||||
case "list":
|
||||
fs := flag.NewFlagSet("memory list", flag.ContinueOnError)
|
||||
kind := fs.String("kind", "", "kind filter")
|
||||
n := fs.Int("n", 50, "count")
|
||||
if fs.Parse(args[2:]) != nil {
|
||||
return exitUsage
|
||||
}
|
||||
var out struct{ Items []map[string]any }
|
||||
if code, err := apiRequest("GET", fmt.Sprintf("/api/v1/minds/%s/memories?kind=%s&limit=%d", mind, *kind, *n), nil, &out); err != nil {
|
||||
return exitFor(code, err)
|
||||
}
|
||||
for _, m := range out.Items {
|
||||
fmt.Printf("%s [%s/%s conf %.2f imp %.2f act %.2f] %s (%s)\n", fmt.Sprint(m["occurred_at"])[:16], m["kind"], m["epistemic"], num(m["confidence"]), num(m["importance"]), num(m["activation"]), m["content"], m["id"])
|
||||
}
|
||||
return exitOK
|
||||
case "inspect":
|
||||
if len(args) < 3 {
|
||||
return exitUsage
|
||||
}
|
||||
var d map[string]any
|
||||
if code, err := apiRequest("GET", "/api/v1/minds/"+mind+"/memories/"+args[2], nil, &d); err != nil {
|
||||
return exitFor(code, err)
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
enc.Encode(d) //nolint:errcheck
|
||||
return exitOK
|
||||
case "add":
|
||||
fs := flag.NewFlagSet("memory add", flag.ContinueOnError)
|
||||
kind := fs.String("kind", "semantic", "memory kind")
|
||||
epi := fs.String("epistemic", "fact", "epistemic kind")
|
||||
conf := fs.Float64("confidence", 0.8, "confidence")
|
||||
if fs.Parse(args[2:]) != nil || fs.NArg() == 0 {
|
||||
fmt.Fprintln(os.Stderr, "usage: noemactl memory add <mind> [-kind k] [-epistemic e] [-confidence c] <text...>")
|
||||
return exitUsage
|
||||
}
|
||||
var m map[string]any
|
||||
if code, err := apiRequest("POST", "/api/v1/minds/"+mind+"/memories", map[string]any{"kind": *kind, "epistemic": *epi, "confidence": *conf, "content": strings.Join(fs.Args(), " ")}, &m); err != nil {
|
||||
return exitFor(code, err)
|
||||
}
|
||||
fmt.Printf("stored %v memory %v\n", m["kind"], m["id"])
|
||||
return exitOK
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "unknown memory subcommand", args[0])
|
||||
return exitUsage
|
||||
}
|
||||
+30
-3
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/andydixon/noema/internal/cognition"
|
||||
"github.com/andydixon/noema/internal/config"
|
||||
evstore "github.com/andydixon/noema/internal/events"
|
||||
"github.com/andydixon/noema/internal/memory"
|
||||
"github.com/andydixon/noema/internal/minds"
|
||||
"github.com/andydixon/noema/internal/observability"
|
||||
"github.com/andydixon/noema/internal/scheduler"
|
||||
@@ -28,6 +29,7 @@ import (
|
||||
"github.com/andydixon/noema/internal/ui"
|
||||
"github.com/andydixon/noema/internal/version"
|
||||
"github.com/andydixon/noema/migrations"
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
@@ -85,9 +87,13 @@ func run() error {
|
||||
}
|
||||
|
||||
group := scheduler.NewGroup(ctx, log)
|
||||
registry := brain.NewRegistry().AddProcess(cognition.Attend{})
|
||||
deps.Memory = memory.NewStore(ctx, db, nil)
|
||||
registry := brain.NewRegistry().AddProcess(cognition.Attend{}).AddProcess(cognition.Retrieve{})
|
||||
cognition.Handlers{Memory: deps.Memory}.Register(registry)
|
||||
deps.Brain = brain.NewManager(brain.Config{DB: db, Events: deps.Events, Minds: deps.Minds, Registry: registry, Pool: scheduler.NewPool(cfg.MaxWorkers),
|
||||
Log: log, Metrics: metrics, Services: brain.Services{Events: deps.Events}}, group)
|
||||
Log: log, Metrics: metrics, Services: brain.Services{Events: deps.Events, Memory: deps.Memory}}, group)
|
||||
group.Every("memory.decay", 6*time.Hour, func(ctx context.Context) error { return decayMemories(ctx, deps) })
|
||||
group.Every("memory.embed", time.Minute, func(ctx context.Context) error { _, err := deps.Memory.EmbedMissing(ctx, 50); return err })
|
||||
if err := deps.Brain.Start(ctx); err != nil {
|
||||
return fmt.Errorf("start minds: %w", err)
|
||||
}
|
||||
@@ -95,7 +101,8 @@ func run() error {
|
||||
var srv *api.Server
|
||||
uiHandler, err := ui.New(deps, func(r *http.Request) any { return srv.Status(r.Context()) },
|
||||
func(r *http.Request, id ids.ID) (api.EventContext, error) { return srv.Context(r, id) },
|
||||
func(r *http.Request, m minds.Mind) (api.WorkspaceView, error) { return srv.Workspace(r.Context(), m) })
|
||||
func(r *http.Request, m minds.Mind) (api.WorkspaceView, error) { return srv.Workspace(r.Context(), m) },
|
||||
func(r *http.Request, mind, id ids.ID) (api.MemoryDetail, error) { return srv.MemoryDetail(r, mind, id) })
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -145,3 +152,23 @@ func bootstrapAdmin(ctx context.Context, d app.Deps) error {
|
||||
d.Log.Info("created bootstrap administrator", "username", u.Username)
|
||||
return nil
|
||||
}
|
||||
|
||||
// decayMemories lowers activation of unused memories for every mind and leaves
|
||||
// one summary event per mind so the decay is visible without spamming.
|
||||
func decayMemories(ctx context.Context, d app.Deps) error {
|
||||
list, err := d.Minds.List(ctx, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range list {
|
||||
n, err := d.Memory.Decay(ctx, m.ID, 14*24*time.Hour)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
d.Events.Append(ctx, events.New{MindID: m.ID, Type: events.MemoryDecayed, Source: "memory.decay", Summary: fmt.Sprintf("activation decayed on %d memories", n), //nolint:errcheck
|
||||
Salience: 0.05, Confidence: 1, Metadata: map[string]any{"count": n}})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user