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.
121 lines
3.8 KiB
Go
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)
|
|
}
|