feat: add immutable cognitive event store, live streams and event explorer

Phase 3. Events are the append-only record of everything a mind does
(ADR-0002). pkg/events defines the closed type vocabulary, categories,
evidence references and the observation payload with explicit trust
levels. The store validates and appends inside the caller's transaction,
publishes to a bounded fan-out bus only after commit, queries by seq
cursor with type/category/source/salience/confidence/time/search
filters, and walks provenance: cause, parents, children, cited evidence,
derived events and correlation groups. Durable per-consumer cursors never
move backwards.

Server-Sent Events streams (per mind and global) subscribe before
replaying history after Last-Event-ID so nothing is missed, apply the
same filter vocabulary live, and heartbeat. Operators can inject
observations tagged with operator trust for demos and tests.

UI: live thought stream with filters and paging, forensic event page
with causal chain and evidence navigation, dashboard important events.
Personality and status changes now leave operator_action events in the
mind's own history. CLI: event inspect/list/tail, observe.
This commit is contained in:
Andy Dixon
2026-06-17 20:02:22 +01:00
parent fb34f08f40
commit 85d769b245
29 changed files with 1866 additions and 25 deletions
+120
View File
@@ -0,0 +1,120 @@
package ui
import (
"encoding/json"
"net/http"
"net/url"
"strconv"
"github.com/andydixon/noema/internal/api"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
func (u *UI) eventRoutes(m *http.ServeMux) {
m.Handle("GET /minds/{mind}/thoughts", u.require(auth.RoleObserver, u.thoughts))
m.Handle("POST /minds/{mind}/observe", u.require(auth.RoleOperator, u.observe))
m.Handle("GET /events/{id}", u.require(auth.RoleObserver, u.event))
}
var sinceWindows = map[string]string{"15m": "15 minutes", "1h": "1 hour", "24h": "24 hours", "168h": "7 days"}
func (u *UI) thoughts(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
q := r.URL.Query()
f := api.ParseEventFilter(q)
f.MindID = m.ID
f.Limit = 100
list, err := u.deps.Events.Query(r.Context(), f)
if err != nil {
u.errorPage(w, r, 500, "Could not load events.")
return
}
var topSeq, nextBefore int64
if len(list) > 0 {
topSeq = list[0].Seq
if len(list) == 100 {
nextBefore = list[len(list)-1].Seq
}
}
// The stream carries the same filter but never paging parameters.
sq := url.Values{}
for _, k := range []string{"category", "type", "source", "min_salience", "min_confidence", "q", "correlation"} {
if v := q.Get(k); v != "" {
sq.Set(k, v)
}
}
streamQuery := ""
if len(sq) > 0 {
streamQuery = "?" + sq.Encode()
}
flat := map[string]string{}
for k := range q {
flat[k] = q.Get(k)
}
cats := []events.Category{events.CatPerception, events.CatCognition, events.CatMemory, events.CatBelief, events.CatPrediction, events.CatGoal, events.CatAffect, events.CatAction, events.CatLLM, events.CatOperator, events.CatSystem}
u.render(w, r, "thoughts", m.DisplayName+" · Thoughts", map[string]any{"Mind": m, "Events": list, "Q": flat, "Types": events.AllTypes(), "Categories": cats,
"Windows": sinceWindows, "StreamQuery": streamQuery, "RawQuery": sq.Encode(), "TopSeq": topSeq, "NextBefore": nextBefore})
}
func (u *UI) observe(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
text := r.PostFormValue("text")
if len(text) == 0 || len(text) > 20000 {
u.flash(w, r, "error", "Observation text required.", "/minds/"+m.Slug)
return
}
summary := text
if len(summary) > 160 {
summary = summary[:157] + "..."
}
evs, err := u.deps.Events.Append(r.Context(), events.New{MindID: m.ID, Type: events.Observation, Source: "operator", Summary: summary, Salience: 0.6, Confidence: 1,
Metadata: map[string]any{"trust": events.TrustOperator, "actor": username(r)},
Payload: events.ObservationPayload{Text: text, ContentType: "text/plain", Source: "operator", Trust: events.TrustOperator, Method: "ui", Actor: username(r)}})
if err != nil {
u.flash(w, r, "error", "Could not record observation.", "/minds/"+m.Slug)
return
}
u.deps.Audit.Log(r.Context(), "observation.inject", "mind", m.ID.String(), map[string]any{"event_id": evs[0].ID})
u.flash(w, r, "ok", "Observation recorded.", "/minds/"+m.Slug+"/thoughts")
}
func (u *UI) event(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
u.errorPage(w, r, 404, "No such event.")
return
}
c, err := u.ctxF(r, id)
if err != nil {
u.errorPage(w, r, 404, "No such event.")
return
}
m, err := u.deps.Minds.Get(r.Context(), c.Event.MindID)
if err != nil {
u.errorPage(w, r, 500, "Mind missing for event.")
return
}
u.render(w, r, "event", "Event "+strconv.FormatInt(c.Event.Seq, 10), map[string]any{"Ctx": c, "Mind": m, "Payload": pretty(c.Event.Payload), "Metadata": prettyAny(c.Event.Metadata)})
}
func pretty(raw json.RawMessage) string {
var v any
if json.Unmarshal(raw, &v) != nil {
return string(raw)
}
b, _ := json.MarshalIndent(v, "", " ")
return string(b)
}
func prettyAny(v any) string {
b, _ := json.MarshalIndent(v, "", " ")
return string(b)
}
+8 -1
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/minds"
@@ -92,7 +93,8 @@ func (u *UI) mindOverview(w http.ResponseWriter, r *http.Request) {
return
}
sm, _ := u.deps.Minds.GetSelfModel(r.Context(), m.ID)
u.render(w, r, "mind", m.DisplayName, map[string]any{"Mind": m, "Brief": m.Personality.Brief(), "SelfModel": sm})
st, _ := u.deps.Events.Stats(r.Context(), m.ID, time.Now().Add(-24*time.Hour))
u.render(w, r, "mind", m.DisplayName, map[string]any{"Mind": m, "Brief": m.Personality.Brief(), "SelfModel": sm, "Stats": st})
}
func (u *UI) mindStatus(w http.ResponseWriter, r *http.Request) {
@@ -106,6 +108,7 @@ func (u *UI) mindStatus(w http.ResponseWriter, r *http.Request) {
return
}
u.deps.Audit.Log(r.Context(), "mind.status", "mind", m.ID.String(), map[string]any{"status": st})
u.deps.Events.OperatorAction(r.Context(), m.ID, username(r), "mind.status", "Operator "+username(r)+" set status to "+string(st), map[string]any{"status": st})
u.flash(w, r, "ok", "Mind is now "+string(st)+".", "/minds/"+m.Slug)
}
@@ -232,6 +235,7 @@ func (u *UI) personalitySave(w http.ResponseWriter, r *http.Request) {
return
}
u.deps.Audit.Log(r.Context(), "personality.update", "mind", m.ID.String(), map[string]any{"version": v.Version, "note": v.ChangeNote, "changes": minds.Diff(m.Personality, v.Personality)})
u.deps.Events.OperatorAction(r.Context(), m.ID, username(r), "personality.update", "Operator "+username(r)+" updated personality to version "+strconv.Itoa(v.Version)+": "+v.ChangeNote, map[string]any{"version": v.Version, "changes": minds.Diff(m.Personality, v.Personality)})
u.flash(w, r, "ok", "Saved as version "+strconv.Itoa(v.Version)+".", "/minds/"+m.Slug+"/personality")
}
@@ -278,6 +282,7 @@ func (u *UI) personalityImport(w http.ResponseWriter, r *http.Request) {
return
}
u.deps.Audit.Log(r.Context(), "personality.import", "mind", m.ID.String(), map[string]any{"version": v.Version})
u.deps.Events.OperatorAction(r.Context(), m.ID, username(r), "personality.import", "Operator "+username(r)+" imported personality as version "+strconv.Itoa(v.Version), map[string]any{"version": v.Version})
u.flash(w, r, "ok", "Imported as version "+strconv.Itoa(v.Version)+".", "/minds/"+m.Slug+"/personality")
}
@@ -301,6 +306,7 @@ func (u *UI) personalityTemplate(w http.ResponseWriter, r *http.Request) {
return
}
u.deps.Audit.Log(r.Context(), "personality.template", "mind", m.ID.String(), map[string]any{"template_id": tid, "version": v.Version})
u.deps.Events.OperatorAction(r.Context(), m.ID, username(r), "personality.template", "Operator "+username(r)+": "+v.ChangeNote, map[string]any{"version": v.Version})
u.flash(w, r, "ok", v.ChangeNote+" (version "+strconv.Itoa(v.Version)+").", "/minds/"+m.Slug+"/personality")
}
@@ -320,6 +326,7 @@ func (u *UI) personalityRestore(w http.ResponseWriter, r *http.Request) {
return
}
u.deps.Audit.Log(r.Context(), "personality.restore", "mind", m.ID.String(), map[string]any{"restored": n, "version": v.Version})
u.deps.Events.OperatorAction(r.Context(), m.ID, username(r), "personality.restore", "Operator "+username(r)+" restored personality version "+strconv.Itoa(n)+" as version "+strconv.Itoa(v.Version), map[string]any{"restored": n, "version": v.Version})
u.flash(w, r, "ok", "Restored version "+strconv.Itoa(n)+" as version "+strconv.Itoa(v.Version)+".", "/minds/"+m.Slug+"/personality")
}
+8 -1
View File
@@ -8,6 +8,7 @@ import (
"strings"
"github.com/andydixon/noema/internal/auth"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/pkg/ids"
)
@@ -97,7 +98,13 @@ func (u *UI) setupSubmit(w http.ResponseWriter, r *http.Request) {
func (u *UI) dashboard(w http.ResponseWriter, r *http.Request) {
entries, _ := u.deps.Audit.List(r.Context(), 0, 8, "", "")
u.render(w, r, "dashboard", "Dashboard", map[string]any{"Minds": 0, "Status": u.statusF(r), "Audit": entries})
mindsList, _ := u.deps.Minds.List(r.Context(), false)
names := map[ids.ID]string{}
for _, m := range mindsList {
names[m.ID] = m.DisplayName
}
evs, _ := u.deps.Events.Query(r.Context(), evstore.Filter{MinSalience: 0.6, Limit: 12})
u.render(w, r, "dashboard", "Dashboard", map[string]any{"Minds": mindsList, "MindNames": names, "Events": evs, "Status": u.statusF(r), "Audit": entries})
}
func (u *UI) system(w http.ResponseWriter, r *http.Request) {
+13 -3
View File
@@ -15,10 +15,12 @@ import (
"strings"
"time"
"github.com/andydixon/noema/internal/api"
"github.com/andydixon/noema/internal/app"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/version"
"github.com/andydixon/noema/pkg/ids"
"github.com/andydixon/noema/web"
)
@@ -27,7 +29,8 @@ type UI struct {
deps app.Deps
pages map[string]*template.Template
static fs.FS
statusF func(r *http.Request) any // system status provider injected by api
statusF func(r *http.Request) any // system status provider injected by api
ctxF func(r *http.Request, id ids.ID) (api.EventContext, error) // event forensic context, shared with api
}
// View is the data passed to every template.
@@ -48,7 +51,7 @@ const flashCookie = "noema_flash"
// New parses templates. In development, when ./web exists on disk, templates
// and static files are read from disk so edits show without a rebuild.
func New(deps app.Deps, statusF func(r *http.Request) any) (*UI, error) {
func New(deps app.Deps, statusF func(r *http.Request) any, ctxF func(r *http.Request, id ids.ID) (api.EventContext, error)) (*UI, error) {
tmplFS, staticFS := web.Templates(), web.Static()
if !deps.Config.Production() {
if st, err := os.Stat("web/templates"); err == nil && st.IsDir() {
@@ -56,7 +59,7 @@ func New(deps app.Deps, statusF func(r *http.Request) any) (*UI, error) {
deps.Log.Info("ui: serving templates and static files from ./web (development)")
}
}
u := &UI{deps: deps, static: staticFS, statusF: statusF}
u := &UI{deps: deps, static: staticFS, statusF: statusF, ctxF: ctxF}
if err := u.parse(tmplFS); err != nil {
return nil, err
}
@@ -74,6 +77,12 @@ func (u *UI) parse(fsys fs.FS) error {
"pct": func(f float64) string { return fmt.Sprintf("%.0f%%", f*100) },
"conf": func(f float64) string { return fmt.Sprintf("%.2f", f) },
"inc": func(i int) int { return i + 1 },
"deref": func(f *float64) float64 {
if f == nil {
return 0
}
return *f
},
"title": func(s string) string { s = strings.ReplaceAll(s, "_", " "); return strings.ToUpper(s[:1]) + s[1:] },
"valueKeys": func() []string { return minds.ValueKeys },
"traitKeys": func() []string { return minds.TraitKeys },
@@ -198,6 +207,7 @@ func (u *UI) Register(m *http.ServeMux) {
m.Handle("POST /admin/users/{id}", u.require(auth.RoleAdministrator, u.usersUpdate))
u.mindRoutes(m)
u.eventRoutes(m)
for _, p := range []struct{ path, title, note string }{
{"/approvals", "Approvals", "The approval centre arrives with the capability system."},