Files
noema/internal/ui/events.go
T
Andy Dixon 85d769b245 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.
2026-06-17 20:02:22 +01:00

121 lines
3.8 KiB
Go

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)
}