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
+8
View File
@@ -160,3 +160,11 @@ ol.corr li.current { font-weight: 600; }
ul.wm li { padding: .45rem 0; border-bottom: 1px solid var(--border); } ul.wm li:last-child { border-bottom: 0; }
.weights { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: .5rem; }
.weights label { font-size: .8rem; }
/* Memory */
.memtext { font-size: 1.1rem; max-width: 80ch; }
.comp { display: inline-block; background: var(--surface-2); border-radius: 4px; padding: 0 .35rem; margin: .1rem 0; font-family: var(--mono); font-size: .72rem; }
tr.dayrow td { background: var(--surface-2); font-weight: 600; font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
.badge.k-episodic { background: #e0f2fe; color: #0369a1; } .badge.k-semantic { background: #ede9fe; color: #6d28d9; } .badge.k-procedural { background: #dcfce7; color: #15803d; } .badge.k-autobiographical { background: #fef3c7; color: #b45309; }
:root[data-theme="dark"] .badge.k-episodic, :root:not([data-theme="light"]) .badge.k-episodic { background: #0c2a3a; color: #7dd3fc; }
:root[data-theme="dark"] .badge.k-semantic { background: #2a1f4d; color: #c4b5fd; } :root[data-theme="dark"] .badge.k-procedural { background: #12331d; color: #86efac; } :root[data-theme="dark"] .badge.k-autobiographical { background: #3a2a0a; color: #fcd34d; }
+66
View File
@@ -0,0 +1,66 @@
{{define "content"}}
{{$m := .Data.Mind}}
<div class="pagehead mindhead">
<div><h1>{{$m.DisplayName}} <span class="muted small">memory</span></h1><p class="muted">{{.Data.Stats.Total}} active memories · {{range $k, $v := .Data.Stats.ByKind}}<span class="badge">{{$k}} {{$v}}</span> {{end}}{{if .Data.Semantic}}<span class="badge ok">semantic search on</span>{{else}}<span class="badge" title="No embedding provider configured or pgvector unavailable">text + graph retrieval</span>{{end}}</p></div>
</div>
{{template "mind_tabs" (dict "M" $m "Active" "memory")}}
<form method="get" class="card filterbar">
<label>Search <input name="q" value="{{index .Data.Q "q"}}" placeholder="what does it remember about…" size="28"></label>
<label>Kind <select name="kind"><option value="">all</option>{{range memoryKinds}}<option value="{{.}}" {{if eq (index $.Data.Q "kind") (print .)}}selected{{end}}>{{.}}</option>{{end}}</select></label>
<label>Epistemic <select name="epistemic"><option value="">all</option>{{range epistemicKinds}}<option value="{{.}}" {{if eq (index $.Data.Q "epistemic") (print .)}}selected{{end}}>{{.}}</option>{{end}}</select></label>
<label>Status <select name="status"><option value="">active</option><option value="retracted" {{if eq (index .Data.Q "status") "retracted"}}selected{{end}}>retracted</option><option value="superseded" {{if eq (index .Data.Q "status") "superseded"}}selected{{end}}>superseded</option><option value="active,retracted,superseded,archived" {{if eq (index .Data.Q "status") "active,retracted,superseded,archived"}}selected{{end}}>everything</option></select></label>
<label>Min confidence <input type="number" name="min_confidence" min="0" max="1" step="0.1" value="{{index .Data.Q "min_confidence"}}" size="4"></label>
<label>Min importance <input type="number" name="min_importance" min="0" max="1" step="0.1" value="{{index .Data.Q "min_importance"}}" size="4"></label>
<label class="check small"><input type="checkbox" name="strict" value="1" {{if eq (index .Data.Q "strict") "1"}}checked{{end}}> all terms</label>
<button class="btn" type="submit">Search</button> <a class="btn btn-ghost" href="/minds/{{$m.Slug}}/memory">Reset</a>
</form>
<section class="grid cols-3-1">
<div class="card">
{{if .Data.Hits}}
<h2>Recall for “{{index .Data.Q "q"}}” <span class="muted small">fused score: text, {{if .Data.Semantic}}vector, {{end}}recency, activation, importance, credibility</span></h2>
<table class="table compact">
<thead><tr><th class="num">Score</th><th>Memory</th><th>Why</th></tr></thead>
<tbody>{{range .Data.Hits}}{{$mm := .Memory}}<tr>
<td class="num"><strong>{{conf .Score}}</strong></td>
<td><a href="/minds/{{$m.Slug}}/memory/{{$mm.ID}}">{{trunc $mm.Content 140}}</a><div class="small muted"><span class="badge">{{$mm.Kind}}</span> <span class="badge">{{$mm.Epistemic}}</span> {{date $mm.OccurredAt}} · conf {{conf $mm.Confidence}} · imp {{conf $mm.Importance}}</div></td>
<td class="small muted">{{range $k, $v := .Components}}{{if gt $v 0.0}}<span class="comp">{{$k}} {{conf $v}}</span> {{end}}{{end}}</td>
</tr>{{end}}</tbody>
</table>
{{else if .Data.Memories}}
<h2>Timeline <span class="muted small">newest first</span></h2>
{{$day := ""}}
<table class="table compact">
<tbody>{{range .Data.Memories}}{{$d := date .OccurredAt | printf "%.10s"}}{{if ne $d $day}}{{$day = $d}}<tr class="dayrow"><td colspan="4">{{$d}}</td></tr>{{end}}<tr>
<td class="nowrap muted small mono">{{.OccurredAt.Local.Format "15:04"}}</td>
<td><span class="badge k-{{.Kind}}">{{.Kind}}</span> <span class="badge">{{.Epistemic}}</span>{{if ne .Status "active"}} <span class="badge danger">{{.Status}}</span>{{end}}</td>
<td><a href="/minds/{{$m.Slug}}/memory/{{.ID}}">{{trunc .Content 160}}</a><div class="muted small">{{.Source}} · trust {{.Trust}}</div></td>
<td class="small nowrap"><span class="conf" title="confidence"><span class="conf-bar"><i style="width:{{pct .Confidence}}"></i></span> {{conf .Confidence}}</span><br><span class="muted">imp {{conf .Importance}} · act {{conf .Activation}} · ×{{.RetrievalCount}}</span></td>
</tr>{{end}}</tbody>
</table>
{{with .Data.NextBefore}}<p><a class="btn" href="/minds/{{$m.Slug}}/memory?{{$.Data.RawQuery}}&before={{.}}">Older</a></p>{{end}}
{{else}}<p class="muted">No memories{{if index .Data.Q "q"}} match{{end}}. Memories form when the mind attends to observations and conversations.</p>{{end}}
</div>
<div class="stack">
{{if .Principal.User.Role.AtLeast "operator"}}
<div class="card">
<h2>Add knowledge <span class="muted small">as operator</span></h2>
<form method="post" action="/minds/{{$m.Slug}}/memory" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label>Content <textarea name="content" rows="3" required maxlength="20000" placeholder="e.g. Andy prefers Go for deployed services"></textarea></label>
<div class="grid cols-2 tight">
<label>Kind <select name="kind">{{range memoryKinds}}<option value="{{.}}" {{if eq . "semantic"}}selected{{end}}>{{.}}</option>{{end}}</select></label>
<label>Epistemic <select name="epistemic">{{range epistemicKinds}}<option value="{{.}}" {{if eq . "fact"}}selected{{end}}>{{.}}</option>{{end}}</select></label>
<label>Confidence <input type="number" name="confidence" min="0" max="1" step="0.05" value="0.8"></label>
<label>Importance <input type="number" name="importance" min="0" max="1" step="0.05" value="0.6"></label>
</div>
<div><button class="btn" type="submit">Store</button></div>
</form>
</div>
{{end}}
<div class="card small">
<h2>How retrieval works</h2>
<p class="muted">A cue (observation, message) is matched against memories by full-text rank{{if .Data.Semantic}}, embedding similarity{{end}}, recency, activation, importance and source credibility. The fused score competes for attention like any thought; admitted recalls are reinforced. Decay lowers activation of unused memories but never deletes them.</p>
</div>
</div>
</section>
{{end}}
+61
View File
@@ -0,0 +1,61 @@
{{define "content"}}
{{$m := .Data.Mind}}{{$d := .Data.Detail}}{{$mm := $d.Memory}}
<div class="pagehead">
<p class="muted small"><a href="/minds/{{$m.Slug}}">{{$m.DisplayName}}</a> › <a href="/minds/{{$m.Slug}}/memory">memory</a> › {{$mm.Kind}}</p>
<h1><span class="badge k-{{$mm.Kind}}">{{$mm.Kind}}</span> <span class="badge">{{$mm.Epistemic}}</span> {{if ne $mm.Status "active"}}<span class="badge danger">{{$mm.Status}}</span>{{end}}</h1>
<p class="memtext">{{$mm.Content}}</p>
</div>
<section class="grid cols-3-1">
<div class="stack">
<div class="card">
<h2>Provenance</h2>
<dl class="kv">
<dt>Source</dt><dd>{{$mm.Source}} · trust <strong>{{$mm.Trust}}</strong> (credibility {{conf $mm.Credibility}})</dd>
<dt>Occurred</dt><dd>{{$mm.OccurredAt.Local.Format "2006-01-02 15:04:05"}}</dd>
<dt>Encoded</dt><dd>{{$mm.CreatedAt.Local.Format "2006-01-02 15:04:05"}}</dd>
{{if $d.Origin}}<dt>Origin event</dt><dd><span class="etype cat-{{$d.Origin.Category}}">{{$d.Origin.Type}}</span> <a href="/events/{{$d.Origin.ID}}">{{$d.Origin.Summary}}</a></dd>{{end}}
{{if $mm.Supersedes}}<dt>Supersedes</dt><dd><a href="/minds/{{$m.Slug}}/memory/{{$mm.Supersedes}}">{{$mm.Supersedes}}</a></dd>{{end}}
{{range $k, $v := $mm.Detail}}{{if $v}}<dt>{{$k}}</dt><dd class="small">{{$v}}</dd>{{end}}{{end}}
</dl>
</div>
<div class="card">
<h2>Events referencing this memory <span class="muted small">{{len $d.Events}}</span></h2>
{{if $d.Events}}<table class="table compact"><tbody>{{range $d.Events}}<tr><td class="nowrap muted small mono">{{.OccurredAt.Local.Format "01-02 15:04:05"}}</td><td><span class="etype cat-{{.Category}}">{{.Type}}</span></td><td><a href="/events/{{.ID}}">{{.Summary}}</a></td></tr>{{end}}</tbody></table>{{else}}<p class="muted small">None yet.</p>{{end}}
</div>
<div class="card">
<h2>Relationships <span class="muted small">{{len $d.Relationships}}</span></h2>
{{if $d.Relationships}}<table class="table compact"><tbody>{{range $d.Relationships}}<tr><td class="small">{{if eq .Direction "out"}}this <strong>{{.Relation}}</strong>{{else}}<strong>{{.Relation}}</strong> this{{end}}</td><td><a href="/minds/{{$m.Slug}}/memory/{{.Other.ID}}">{{trunc .Other.Content 120}}</a></td><td class="small muted">{{conf .Confidence}} · {{.Source}}</td></tr>{{end}}</tbody></table>{{else}}<p class="muted small">None.</p>{{end}}
{{if .Principal.User.Role.AtLeast "operator"}}
<form method="post" action="/minds/{{$m.Slug}}/memory/{{$mm.ID}}/relate" class="row">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<input name="to" placeholder="other memory id" size="38" required>
<select name="relation">{{range relations}}<option value="{{.}}">{{.}}</option>{{end}}</select>
<button class="btn small" type="submit">Link</button>
</form>{{end}}
</div>
</div>
<div class="stack">
<div class="card">
<h2>Strength</h2>
<dl class="kv">
<dt>Confidence</dt><dd><span class="conf"><span class="conf-bar"><i style="width:{{pct $mm.Confidence}}"></i></span> {{conf $mm.Confidence}}</span></dd>
<dt>Importance</dt><dd><span class="conf"><span class="conf-bar"><i style="width:{{pct $mm.Importance}}"></i></span> {{conf $mm.Importance}}</span></dd>
<dt>Activation</dt><dd><span class="conf"><span class="conf-bar"><i style="width:{{pct $mm.Activation}}"></i></span> {{conf $mm.Activation}}</span></dd>
<dt>Retrieved</dt><dd>{{$mm.RetrievalCount}}× {{if $mm.LastRetrievedAt}}· last {{ago $mm.LastRetrievedAt}}{{end}}</dd>
{{if $mm.Valence}}<dt>Valence</dt><dd>{{conf (deref $mm.Valence)}}</dd>{{end}}
</dl>
</div>
{{if and (.Principal.User.Role.AtLeast "operator") (eq $mm.Status "active")}}
<div class="card">
<h2>Correct</h2>
<form method="post" action="/minds/{{$m.Slug}}/memory/{{$mm.ID}}/retract" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label>Reason <input name="reason" placeholder="why this memory is inaccurate" maxlength="500"></label>
<div><button class="btn btn-danger" type="submit" data-confirm="Mark this memory inaccurate? It stays in history but leaves normal recall.">This memory is inaccurate</button></div>
</form>
</div>
{{end}}
<div class="card small"><dt class="muted">ID</dt><dd class="mono small">{{$mm.ID}}</dd></div>
</div>
</section>
{{end}}
+1 -1
View File
@@ -24,7 +24,7 @@
{{$st := .Data.Stats}}
<div class="stat-value small">{{$st.Total}} <span class="muted small">events in 24h</span></div>
<div class="cats small">{{range $k, $v := $st.ByCategory}}<span class="etype cat-{{$k}}">{{$k}} {{$v}}</span> {{end}}</div>
<p class="small"><a href="/minds/{{$m.Slug}}/thoughts">Open thought stream</a></p>
<p class="small"><a href="/minds/{{$m.Slug}}/thoughts">Open thought stream</a> · <a href="/minds/{{$m.Slug}}/memory">{{.Data.MemStats.Total}} memories</a>{{range $k, $v := .Data.MemStats.ByKind}} <span class="badge k-{{$k}}">{{$k}} {{$v}}</span>{{end}}</p>
{{if .Principal.User.Role.AtLeast "operator"}}
<form method="post" action="/minds/{{$m.Slug}}/observe" class="stack" style="margin-top:.75rem">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
+1 -1
View File
@@ -3,6 +3,6 @@
<a href="/minds/{{$m.Slug}}" {{if eq $a "overview"}}aria-current="page"{{end}}>Overview</a>
<a href="/minds/{{$m.Slug}}/thoughts" {{if eq $a "thoughts"}}aria-current="page"{{end}}>Thoughts</a>
<a href="/minds/{{$m.Slug}}/personality" {{if eq $a "personality"}}aria-current="page"{{end}}>Personality</a>
<a href="/minds/{{$m.Slug}}/workspace" {{if eq $a "workspace"}}aria-current="page"{{end}}>Workspace</a><a class="disabled" title="Phase 5">Memory</a><a class="disabled" title="Phase 6">Beliefs</a><a class="disabled" title="Phase 8">Conversation</a>
<a href="/minds/{{$m.Slug}}/workspace" {{if eq $a "workspace"}}aria-current="page"{{end}}>Workspace</a><a href="/minds/{{$m.Slug}}/memory" {{if eq $a "memory"}}aria-current="page"{{end}}>Memory</a><a class="disabled" title="Phase 6">Beliefs</a><a class="disabled" title="Phase 8">Conversation</a>
</nav>
{{end}}