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:
@@ -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)
|
||||
}
|
||||
@@ -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,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
@@ -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."},
|
||||
|
||||
Reference in New Issue
Block a user