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
+2 -1
View File
@@ -14,6 +14,7 @@ import (
"github.com/andydixon/noema/internal/audit"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/config"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/observability"
"github.com/andydixon/noema/internal/storage"
@@ -27,7 +28,7 @@ func testServer(t *testing.T) (*httptest.Server, string) {
cfg.DatabaseURL = "postgres://test"
log := slog.New(slog.DiscardHandler)
deps := app.Deps{Config: cfg, Log: log, DB: pool, Auth: auth.NewService(pool, time.Hour, 2*time.Hour),
Audit: audit.New(pool, log), Metrics: observability.NewMetrics(), Minds: minds.NewStore(pool), Migrations: migrations.FS, Started: time.Now()}
Audit: audit.New(pool, log), Metrics: observability.NewMetrics(), Minds: minds.NewStore(pool), Events: evstore.NewStore(pool, evstore.NewBus(50), nil), Migrations: migrations.FS, Started: time.Now()}
ctx := context.Background()
u, err := deps.Auth.CreateUser(ctx, "admin", "administrator-pass", auth.RoleAdministrator)
if err != nil {
+335
View File
@@ -0,0 +1,335 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/andydixon/noema/internal/auth"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
func (s *Server) eventRoutes() {
m := s.mux
m.Handle("GET /api/v1/minds/{mind}/events", require(auth.RoleObserver, s.eventsList))
m.Handle("GET /api/v1/minds/{mind}/events/stream", require(auth.RoleObserver, s.eventsStream))
m.Handle("GET /api/v1/minds/{mind}/events/stats", require(auth.RoleObserver, s.eventsStats))
m.Handle("POST /api/v1/minds/{mind}/observations", require(auth.RoleOperator, s.observationCreate))
m.Handle("GET /api/v1/events/stream", require(auth.RoleObserver, s.eventsStream))
m.Handle("GET /api/v1/events/{id}", require(auth.RoleObserver, s.eventGet))
m.Handle("GET /api/v1/events/{id}/context", require(auth.RoleObserver, s.eventContext))
m.Handle("GET /api/v1/events/types", require(auth.RoleObserver, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"types": events.AllTypes()})
}))
}
// ParseEventFilter reads the shared query-string filter vocabulary used by
// the API, SSE stream and UI pages.
func ParseEventFilter(q url.Values) evstore.Filter {
f := evstore.Filter{}
for _, t := range q["type"] {
for _, part := range strings.Split(t, ",") {
if part = strings.TrimSpace(part); part != "" {
f.Types = append(f.Types, events.Type(part))
}
}
}
for _, c := range q["category"] {
for _, part := range strings.Split(c, ",") {
if part = strings.TrimSpace(part); part != "" {
f.Categories = append(f.Categories, events.Category(part))
}
}
}
for _, src := range q["source"] {
if src = strings.TrimSpace(src); src != "" {
f.Sources = append(f.Sources, src)
}
}
f.MinSalience, _ = strconv.ParseFloat(q.Get("min_salience"), 64)
f.MinConfidence, _ = strconv.ParseFloat(q.Get("min_confidence"), 64)
f.MaxConfidence, _ = strconv.ParseFloat(q.Get("max_confidence"), 64)
if v := q.Get("since"); v != "" {
if t, err := time.Parse(time.RFC3339, v); err == nil {
f.Since = t
} else if d, err := time.ParseDuration(v); err == nil {
f.Since = time.Now().Add(-d)
}
}
if v := q.Get("until"); v != "" {
if t, err := time.Parse(time.RFC3339, v); err == nil {
f.Until = t
}
}
f.AfterSeq, _ = strconv.ParseInt(q.Get("after"), 10, 64)
f.BeforeSeq, _ = strconv.ParseInt(q.Get("before"), 10, 64)
if v := q.Get("correlation"); v != "" {
if id, err := ids.Parse(v); err == nil {
f.CorrelationID = id
}
}
f.Search = q.Get("q")
f.Limit, _ = strconv.Atoi(q.Get("limit"))
f.Ascending = q.Get("order") == "asc"
return f
}
func (s *Server) eventsList(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
f := ParseEventFilter(r.URL.Query())
f.MindID = m.ID
list, err := s.deps.Events.Query(r.Context(), f)
if err != nil {
fail(w, r, err)
return
}
if list == nil {
list = []events.Event{}
}
out := page[events.Event]{Items: list}
if len(list) > 0 && (f.Limit == 0 && len(list) == 100 || f.Limit > 0 && len(list) == f.Limit) {
out.NextCursor = strconv.FormatInt(list[len(list)-1].Seq, 10)
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) eventsStats(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
var since time.Time
if d, err := time.ParseDuration(r.URL.Query().Get("window")); err == nil {
since = time.Now().Add(-d)
}
st, err := s.deps.Events.Stats(r.Context(), m.ID, since)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, st)
}
func (s *Server) eventGet(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad id"))
return
}
ev, err := s.deps.Events.Get(r.Context(), id)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, ev)
}
// EventContext is the forensic view: the event, what caused it, what it
// caused, what it cites and what cites it.
type EventContext struct {
Event events.Event `json:"event"`
Cause *events.Event `json:"cause,omitempty"`
Parents []events.Event `json:"parents"`
Children []events.Event `json:"children"`
Evidence []events.Event `json:"evidence_events"`
Derived []events.Event `json:"derived"`
Correlated []events.Event `json:"correlated"`
EvidenceAll []events.EvidenceRef `json:"evidence"`
}
// Context assembles the forensic context for an event; shared with the UI.
func (s *Server) Context(r *http.Request, id ids.ID) (EventContext, error) {
ctx := r.Context()
ev, err := s.deps.Events.Get(ctx, id)
if err != nil {
return EventContext{}, err
}
out := EventContext{Event: ev, Parents: []events.Event{}, Children: []events.Event{}, Evidence: []events.Event{}, Derived: []events.Event{}, Correlated: []events.Event{}, EvidenceAll: ev.Evidence}
if !ev.CausationID.IsZero() {
if c, err := s.deps.Events.Get(ctx, ev.CausationID); err == nil {
out.Cause = &c
}
}
if len(ev.ParentIDs) > 0 {
out.Parents, _ = s.deps.Events.GetMany(ctx, ev.ParentIDs)
}
out.Children, _ = s.deps.Events.Children(ctx, ev.ID, 50)
var evIDs []ids.ID
for _, ref := range ev.Evidence {
if ref.Kind == "event" {
if eid, err := ids.Parse(ref.ID); err == nil {
evIDs = append(evIDs, eid)
}
}
}
if len(evIDs) > 0 {
out.Evidence, _ = s.deps.Events.GetMany(ctx, evIDs)
}
out.Derived, _ = s.deps.Events.DerivedFrom(ctx, "event", string(ev.ID), 50)
if !ev.CorrelationID.IsZero() {
out.Correlated, _ = s.deps.Events.Query(ctx, evstore.Filter{MindID: ev.MindID, CorrelationID: ev.CorrelationID, Ascending: true, Limit: 200})
}
for _, l := range []*[]events.Event{&out.Parents, &out.Children, &out.Evidence, &out.Derived, &out.Correlated} {
if *l == nil {
*l = []events.Event{}
}
}
return out, nil
}
func (s *Server) eventContext(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad id"))
return
}
out, err := s.Context(r, id)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// observationCreate lets an operator inject an observation directly. It is
// tagged with operator trust and used by demos, tests and the CLI.
func (s *Server) observationCreate(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
var in struct {
Text string `json:"text"`
Source string `json:"source"`
Reference string `json:"reference"`
Salience float64 `json:"salience"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
in.Text = strings.TrimSpace(in.Text)
if in.Text == "" || len(in.Text) > 20000 {
fail(w, r, ValidationError("text required (max 20000 chars)"))
return
}
if in.Source == "" {
in.Source = "operator"
}
if in.Salience == 0 {
in.Salience = 0.5
}
summary := in.Text
if len(summary) > 160 {
summary = summary[:157] + "..."
}
evs, err := s.deps.Events.Append(r.Context(), events.New{MindID: m.ID, Type: events.Observation, Source: in.Source, Summary: summary,
Salience: in.Salience, Confidence: 1, Metadata: map[string]any{"trust": events.TrustOperator, "actor": actor(r)},
Payload: events.ObservationPayload{Text: in.Text, ContentType: "text/plain", Source: in.Source, Trust: events.TrustOperator, Method: "api", Reference: in.Reference, Actor: actor(r)}})
if err != nil {
fail(w, r, err)
return
}
s.deps.Audit.Log(r.Context(), "observation.inject", "mind", m.ID.String(), map[string]any{"event_id": evs[0].ID, "source": in.Source})
writeJSON(w, http.StatusCreated, evs[0])
}
// eventsStream is the Server-Sent Events feed. It subscribes before replaying
// history after Last-Event-ID so no event is missed, then relays live events
// that match the filter. Heartbeats keep proxies from closing idle streams.
func (s *Server) eventsStream(w http.ResponseWriter, r *http.Request) {
f := ParseEventFilter(r.URL.Query())
if r.PathValue("mind") != "" {
m, ok := s.mind(w, r)
if !ok {
return
}
f.MindID = m.ID
}
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, r, http.StatusInternalServerError, "internal", "streaming unsupported")
return
}
ctx := r.Context()
ch, err := s.deps.Events.Bus().Subscribe(ctx, f.MindID, 256)
if err != nil {
var tooMany evstore.ErrTooManySubscribers
if errors.As(err, &tooMany) {
writeError(w, r, http.StatusServiceUnavailable, "busy", "too many live streams")
return
}
fail(w, r, err)
return
}
h := w.Header()
h.Set("Content-Type", "text/event-stream")
h.Set("Cache-Control", "no-cache")
h.Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "retry: 3000\n\n")
flusher.Flush()
var lastSeq int64
if v := r.Header.Get("Last-Event-ID"); v != "" {
lastSeq, _ = strconv.ParseInt(v, 10, 64)
} else if f.AfterSeq > 0 {
lastSeq = f.AfterSeq
}
send := func(ev events.Event) bool {
if ev.Seq <= lastSeq {
return true
}
lastSeq = ev.Seq
data, _ := json.Marshal(ev)
if _, err := fmt.Fprintf(w, "id: %d\nevent: cognitive_event\ndata: %s\n\n", ev.Seq, data); err != nil {
return false
}
flusher.Flush()
return true
}
if lastSeq > 0 {
replay := f
replay.AfterSeq, replay.Ascending, replay.Limit = lastSeq, true, 500
if hist, err := s.deps.Events.Query(ctx, replay); err == nil {
for _, ev := range hist {
if !send(ev) {
return
}
}
}
}
heartbeat := time.NewTicker(15 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-ctx.Done():
return
case <-heartbeat.C:
if _, err := fmt.Fprint(w, ": ping\n\n"); err != nil {
return
}
flusher.Flush()
case ev, ok := <-ch:
if !ok {
return
}
if !f.Matches(ev) {
continue
}
if !send(ev) {
return
}
}
}
}
+69
View File
@@ -0,0 +1,69 @@
package api
import (
"bufio"
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"time"
"github.com/andydixon/noema/pkg/events"
)
func TestObservationAndSSEStream(t *testing.T) {
srv, tok := testServer(t)
do := func(method, path, body string) (*http.Response, error) {
req, _ := http.NewRequest(method, srv.URL+path, strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Content-Type", "application/json")
return http.DefaultClient.Do(req)
}
if res, _ := do("POST", "/api/v1/minds", `{"slug":"sse"}`); res.StatusCode != 201 {
t.Fatalf("create mind: %d", res.StatusCode)
}
// Open the stream first.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", srv.URL+"/api/v1/minds/sse/events/stream?type=observation", nil)
req.Header.Set("Authorization", "Bearer "+tok)
res, err := http.DefaultClient.Do(req)
if err != nil || res.StatusCode != 200 || !strings.HasPrefix(res.Header.Get("Content-Type"), "text/event-stream") {
t.Fatalf("stream open failed: %v %v", err, res)
}
defer res.Body.Close()
if r, _ := do("POST", "/api/v1/minds/sse/observations", `{"text":"disk latency rising"}`); r.StatusCode != 201 {
t.Fatalf("observation: %d", r.StatusCode)
}
if r, _ := do("POST", "/api/v1/minds/sse/observations", `{"text":""}`); r.StatusCode != 400 {
t.Fatalf("empty observation should be 400, got %d", r.StatusCode)
}
sc := bufio.NewScanner(res.Body)
var got events.Event
for sc.Scan() {
line := sc.Text()
if strings.HasPrefix(line, "data: ") {
if err := json.Unmarshal([]byte(line[6:]), &got); err != nil {
t.Fatal(err)
}
break
}
}
if got.Type != events.Observation || got.Summary != "disk latency rising" || got.Seq == 0 {
t.Fatalf("unexpected stream event: %+v", got)
}
var payload events.ObservationPayload
json.Unmarshal(got.Payload, &payload) //nolint:errcheck
if payload.Trust != events.TrustOperator || payload.Method != "api" {
t.Fatalf("observation provenance missing: %+v", payload)
}
// Forensic context endpoint.
r, _ := do("GET", "/api/v1/events/"+string(got.ID)+"/context", "")
var c EventContext
json.NewDecoder(r.Body).Decode(&c) //nolint:errcheck
if c.Event.ID != got.ID || c.Children == nil {
t.Fatalf("context wrong: %+v", c)
}
}
+7
View File
@@ -2,6 +2,7 @@ package api
import (
"errors"
"fmt"
"net/http"
"strconv"
@@ -119,6 +120,9 @@ func (s *Server) mindPatch(w http.ResponseWriter, r *http.Request) {
return
}
s.deps.Audit.Log(r.Context(), "mind.update", "mind", m.ID.String(), in)
if in.Status != nil {
s.deps.Events.OperatorAction(r.Context(), m.ID, actor(r), "mind.status", "Operator "+actor(r)+" set status to "+string(*in.Status), map[string]any{"status": *in.Status})
}
writeJSON(w, http.StatusOK, out)
}
@@ -195,6 +199,7 @@ func (s *Server) personalityPut(w http.ResponseWriter, r *http.Request) {
return
}
s.deps.Audit.Log(r.Context(), "personality.update", "mind", m.ID.String(), map[string]any{"version": v.Version, "note": in.ChangeNote, "changes": minds.Diff(m.Personality, v.Personality)})
s.deps.Events.OperatorAction(r.Context(), m.ID, actor(r), "personality.update", fmt.Sprintf("Operator %s updated personality to version %d: %s", actor(r), v.Version, in.ChangeNote), map[string]any{"version": v.Version, "changes": minds.Diff(m.Personality, v.Personality)})
writeJSON(w, http.StatusOK, v)
}
@@ -245,6 +250,7 @@ func (s *Server) personalityRestore(w http.ResponseWriter, r *http.Request) {
return
}
s.deps.Audit.Log(r.Context(), "personality.restore", "mind", m.ID.String(), map[string]any{"restored": n, "version": v.Version})
s.deps.Events.OperatorAction(r.Context(), m.ID, actor(r), "personality.restore", fmt.Sprintf("Operator %s restored personality version %d as version %d", actor(r), n, v.Version), map[string]any{"restored": n, "version": v.Version})
writeJSON(w, http.StatusOK, v)
}
@@ -290,6 +296,7 @@ func (s *Server) personalityTemplate(w http.ResponseWriter, r *http.Request) {
return
}
s.deps.Audit.Log(r.Context(), "personality.template", "mind", m.ID.String(), map[string]any{"template_id": in.TemplateID, "version": v.Version})
s.deps.Events.OperatorAction(r.Context(), m.ID, actor(r), "personality.template", "Operator "+actor(r)+": "+v.ChangeNote, map[string]any{"version": v.Version})
writeJSON(w, http.StatusOK, v)
}
+1
View File
@@ -52,6 +52,7 @@ func (s *Server) routes() {
m.Handle("DELETE /api/v1/me/tokens/{id}", require(auth.RoleObserver, s.myTokenRevoke))
s.mindRoutes()
s.eventRoutes()
m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, r, http.StatusNotFound, "not_found", "no such endpoint")
+2
View File
@@ -13,6 +13,7 @@ import (
"github.com/andydixon/noema/internal/audit"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/config"
"github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/observability"
)
@@ -26,6 +27,7 @@ type Deps struct {
Audit *audit.Logger
Metrics *observability.Metrics
Minds *minds.Store
Events *events.Store
Migrations fs.FS
Started time.Time
}
+91
View File
@@ -0,0 +1,91 @@
// Package events stores cognitive events append-only and fans them out to
// live subscribers. Durable consumers read by sequence cursor; the in-memory
// bus is only a wake-up and a feed for the UI.
package events
import (
"context"
"sync"
"sync/atomic"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
// Bus fans events out to subscribers with bounded buffers. A slow subscriber
// loses events (counted in Dropped) rather than slowing the publisher; SSE
// clients recover via Last-Event-ID and durable consumers via cursors.
type Bus struct {
mu sync.RWMutex
subs map[uint64]*subscriber
nextID uint64
dropped atomic.Int64
max int
}
type subscriber struct {
mind ids.ID // Nil = all minds
ch chan events.Event
}
// NewBus creates a bus allowing at most max subscribers (T18).
func NewBus(max int) *Bus {
return &Bus{subs: map[uint64]*subscriber{}, max: max}
}
// ErrTooManySubscribers is returned when the subscriber cap is reached.
type ErrTooManySubscribers struct{}
func (ErrTooManySubscribers) Error() string { return "too many event subscribers" }
// Subscribe returns a channel of events for one mind (or all when mind is
// Nil). The subscription ends when ctx is cancelled.
func (b *Bus) Subscribe(ctx context.Context, mind ids.ID, buffer int) (<-chan events.Event, error) {
if buffer < 1 {
buffer = 64
}
b.mu.Lock()
if len(b.subs) >= b.max {
b.mu.Unlock()
return nil, ErrTooManySubscribers{}
}
id := b.nextID
b.nextID++
s := &subscriber{mind: mind, ch: make(chan events.Event, buffer)}
b.subs[id] = s
b.mu.Unlock()
context.AfterFunc(ctx, func() {
b.mu.Lock()
delete(b.subs, id)
b.mu.Unlock()
})
return s.ch, nil
}
// Publish delivers events to matching subscribers without blocking.
func (b *Bus) Publish(evs ...events.Event) {
b.mu.RLock()
defer b.mu.RUnlock()
for _, ev := range evs {
for _, s := range b.subs {
if !s.mind.IsZero() && s.mind != ev.MindID {
continue
}
select {
case s.ch <- ev:
default:
b.dropped.Add(1)
}
}
}
}
// Subscribers returns the current subscriber count.
func (b *Bus) Subscribers() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.subs)
}
// Dropped returns the number of events dropped for slow subscribers.
func (b *Bus) Dropped() int64 { return b.dropped.Load() }
+114
View File
@@ -0,0 +1,114 @@
package events
import (
"context"
"errors"
"testing"
"time"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/migrations"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
func TestBusBoundedFanout(t *testing.T) {
b := NewBus(2)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
m1, m2 := ids.New(), ids.New()
all, _ := b.Subscribe(ctx, ids.Nil, 1)
only1, _ := b.Subscribe(ctx, m1, 8)
if _, err := b.Subscribe(ctx, ids.Nil, 1); err == nil {
t.Fatal("subscriber cap not enforced")
}
b.Publish(events.Event{MindID: m1, Type: events.Observation}, events.Event{MindID: m2, Type: events.Observation}, events.Event{MindID: m1, Type: events.Reflection})
if len(only1) != 2 {
t.Fatalf("mind filter wrong: %d", len(only1))
}
if len(all) != 1 || b.Dropped() != 2 {
t.Fatalf("expected 1 buffered and 2 dropped, got %d / %d", len(all), b.Dropped())
}
cancel()
time.Sleep(5 * time.Millisecond)
if b.Subscribers() != 0 {
t.Fatal("subscribers not removed on cancel")
}
}
func TestStoreAppendQueryImmutableAndCursors(t *testing.T) {
pool := storage.TestPool(t, migrations.FS)
ctx := context.Background()
m, err := minds.NewStore(pool).Create(ctx, minds.CreateParams{Slug: "ev"})
if err != nil {
t.Fatal(err)
}
other, _ := minds.NewStore(pool).Create(ctx, minds.CreateParams{Slug: "other"})
s := NewStore(pool, NewBus(10), nil)
sub, _ := s.Bus().Subscribe(ctx, m.ID, 16)
if _, err := s.Append(ctx, events.New{MindID: m.ID, Type: "bogus", Summary: "x"}); !errors.Is(err, ErrInvalid) {
t.Fatalf("unknown type accepted: %v", err)
}
obs, err := s.Append(ctx, events.New{MindID: m.ID, Type: events.Observation, Source: "test", Summary: "load spike observed", Salience: 0.9, Confidence: 1.2, Payload: map[string]any{"cpu": 97}})
if err != nil {
t.Fatal(err)
}
if obs[0].Confidence != 1 || obs[0].Seq == 0 || obs[0].Category != events.CatPerception {
t.Fatalf("normalisation or seq wrong: %+v", obs[0])
}
hyp, _ := s.Append(ctx, events.New{MindID: m.ID, Type: events.HypothesisCreated, Source: "reasoning", Summary: "disk contention hypothesis",
CausationID: obs[0].ID, ParentIDs: []ids.ID{obs[0].ID}, CorrelationID: obs[0].ID, Confidence: 0.4, Salience: 0.7,
Evidence: []events.EvidenceRef{{Kind: "event", ID: string(obs[0].ID)}}})
s.Append(ctx, events.New{MindID: other.ID, Type: events.Observation, Source: "test", Summary: "other mind sees something else"}) //nolint:errcheck
// Live delivery.
if len(sub) != 2 {
t.Fatalf("expected 2 live events for mind, got %d", len(sub))
}
// Mind scoping and filters.
got, _ := s.Query(ctx, Filter{MindID: m.ID})
if len(got) != 2 || got[0].Type != events.HypothesisCreated {
t.Fatalf("query wrong: %+v", got)
}
got, _ = s.Query(ctx, Filter{MindID: m.ID, Types: []events.Type{events.Observation}, MinSalience: 0.5})
if len(got) != 1 {
t.Fatalf("type filter wrong: %d", len(got))
}
got, _ = s.Query(ctx, Filter{MindID: m.ID, Search: "contention"})
if len(got) != 1 {
t.Fatalf("search wrong: %d", len(got))
}
got, _ = s.Query(ctx, Filter{MindID: m.ID, AfterSeq: obs[0].Seq, Ascending: true})
if len(got) != 1 || got[0].ID != hyp[0].ID {
t.Fatalf("cursor paging wrong: %+v", got)
}
// Causal navigation.
kids, _ := s.Children(ctx, obs[0].ID, 10)
if len(kids) != 1 || kids[0].ID != hyp[0].ID {
t.Fatalf("children wrong: %+v", kids)
}
derived, _ := s.DerivedFrom(ctx, "event", string(obs[0].ID), 10)
if len(derived) != 1 {
t.Fatalf("evidence lookup wrong: %d", len(derived))
}
// Immutability.
if _, err := pool.Exec(ctx, `UPDATE cognitive_events SET summary='rewritten' WHERE id=$1`, obs[0].ID); err == nil {
t.Fatal("events must be append-only")
}
if _, err := pool.Exec(ctx, `DELETE FROM cognitive_events`); err == nil {
t.Fatal("events must not be deletable")
}
// Cursors never move backwards.
s.SetCursor(ctx, pool, m.ID, "brain", hyp[0].Seq) //nolint:errcheck
s.SetCursor(ctx, pool, m.ID, "brain", obs[0].Seq) //nolint:errcheck
cur, _ := s.Cursor(ctx, pool, m.ID, "brain")
if cur != hyp[0].Seq {
t.Fatalf("cursor regressed: %d", cur)
}
st, _ := s.Stats(ctx, m.ID, time.Time{})
if st.Total != 2 || st.ByCategory["belief"] != 1 {
t.Fatalf("stats wrong: %+v", st)
}
}
+77
View File
@@ -0,0 +1,77 @@
package events
import (
"context"
"strings"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
// OperatorAction records an administrative change as a cognitive event so the
// mind's history shows when and why it was altered from outside.
func (s *Store) OperatorAction(ctx context.Context, mind ids.ID, actor, action, summary string, details map[string]any) {
if details == nil {
details = map[string]any{}
}
details["action"], details["actor"] = action, actor
s.Append(ctx, events.New{MindID: mind, Type: events.OperatorAction, Source: "operator", Summary: summary, Salience: 0.6, Confidence: 1, //nolint:errcheck
Metadata: map[string]any{"action": action, "actor": actor}, Payload: details})
}
// Matches applies the parts of a Filter that can be evaluated in memory, for
// live streams.
func (f Filter) Matches(ev events.Event) bool {
if !f.MindID.IsZero() && ev.MindID != f.MindID {
return false
}
if len(f.Types) > 0 && !containsType(f.Types, ev.Type) {
return false
}
if len(f.Categories) > 0 && !containsCat(f.Categories, ev.Category) {
return false
}
if len(f.Sources) > 0 && !containsStr(f.Sources, ev.Source) {
return false
}
if ev.Salience < f.MinSalience || ev.Confidence < f.MinConfidence {
return false
}
if f.MaxConfidence > 0 && ev.Confidence > f.MaxConfidence {
return false
}
if !f.CorrelationID.IsZero() && ev.CorrelationID != f.CorrelationID {
return false
}
if q := strings.TrimSpace(f.Search); q != "" && !strings.Contains(strings.ToLower(ev.Summary), strings.ToLower(q)) {
return false
}
return true
}
func containsType(xs []events.Type, x events.Type) bool {
for _, y := range xs {
if y == x {
return true
}
}
return false
}
func containsCat(xs []events.Category, x events.Category) bool {
for _, y := range xs {
if y == x {
return true
}
}
return false
}
func containsStr(xs []string, x string) bool {
for _, y := range xs {
if y == x {
return true
}
}
return false
}
+434
View File
@@ -0,0 +1,434 @@
package events
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/andydixon/noema/internal/observability"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
// Store appends and queries cognitive events.
type Store struct {
db *pgxpool.Pool
bus *Bus
metrics *observability.Metrics
}
// NewStore creates the store. bus and metrics may be nil in tests.
func NewStore(db *pgxpool.Pool, bus *Bus, metrics *observability.Metrics) *Store {
if bus == nil {
bus = NewBus(1000)
}
return &Store{db: db, bus: bus, metrics: metrics}
}
// Bus exposes the fan-out bus for subscribers.
func (s *Store) Bus() *Bus { return s.bus }
// ErrInvalid marks malformed events.
var ErrInvalid = errors.New("invalid event")
func validate(n *events.New) error {
if n.MindID.IsZero() {
return fmt.Errorf("%w: mind id required", ErrInvalid)
}
if !n.Type.Valid() {
return fmt.Errorf("%w: unknown type %q", ErrInvalid, n.Type)
}
if strings.TrimSpace(n.Summary) == "" {
return fmt.Errorf("%w: summary required", ErrInvalid)
}
if n.Source == "" {
n.Source = "unknown"
}
if len(n.Summary) > 2000 {
n.Summary = n.Summary[:2000]
}
n.Salience = clamp(n.Salience, 0, 1)
n.Confidence = clamp(n.Confidence, 0, 1)
if n.Valence != nil {
v := clamp(*n.Valence, -1, 1)
n.Valence = &v
}
if n.Arousal != nil {
a := clamp(*n.Arousal, 0, 1)
n.Arousal = &a
}
if n.OccurredAt.IsZero() {
n.OccurredAt = time.Now()
}
return nil
}
func clamp(v, lo, hi float64) float64 {
if v != v { // NaN
return lo
}
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
// Append writes events in one transaction and publishes them after commit.
func (s *Store) Append(ctx context.Context, news ...events.New) ([]events.Event, error) {
var out []events.Event
err := storage.Tx(ctx, s.db, func(tx pgx.Tx) error {
var err error
out, err = s.AppendIn(ctx, tx, news...)
return err
})
if err != nil {
return nil, err
}
s.Publish(out...)
return out, nil
}
// AppendIn writes events inside the caller's transaction. The caller must call
// Publish after committing so subscribers never see uncommitted events.
func (s *Store) AppendIn(ctx context.Context, q storage.Querier, news ...events.New) ([]events.Event, error) {
out := make([]events.Event, 0, len(news))
for i := range news {
n := &news[i]
if err := validate(n); err != nil {
return nil, err
}
payload, err := json.Marshal(n.Payload)
if err != nil {
return nil, fmt.Errorf("%w: payload: %v", ErrInvalid, err)
}
if n.Payload == nil {
payload = []byte("{}")
}
if n.Evidence == nil {
n.Evidence = []events.EvidenceRef{}
}
if n.Metadata == nil {
n.Metadata = map[string]any{}
}
evidence, _ := json.Marshal(n.Evidence)
metadata, _ := json.Marshal(n.Metadata)
if n.ParentIDs == nil {
n.ParentIDs = []ids.ID{}
}
ev := events.Event{ID: ids.New(), MindID: n.MindID, Type: n.Type, Category: events.CategoryOf(n.Type), Source: n.Source,
OccurredAt: n.OccurredAt, ParentIDs: n.ParentIDs, CausationID: n.CausationID, CorrelationID: n.CorrelationID,
Salience: n.Salience, Confidence: n.Confidence, Valence: n.Valence, Arousal: n.Arousal, Evidence: n.Evidence,
Summary: n.Summary, Metadata: n.Metadata, Payload: payload}
err = q.QueryRow(ctx, `INSERT INTO cognitive_events (id, mind_id, type, category, source, occurred_at, parent_ids, causation_id, correlation_id,
salience, confidence, valence, arousal, evidence, summary, metadata, payload)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) RETURNING seq, recorded_at`,
ev.ID, ev.MindID, string(ev.Type), string(ev.Category), ev.Source, ev.OccurredAt, uuidArray(ev.ParentIDs), nullID(ev.CausationID), nullID(ev.CorrelationID),
ev.Salience, ev.Confidence, ev.Valence, ev.Arousal, evidence, ev.Summary, metadata, payload).Scan(&ev.Seq, &ev.RecordedAt)
if err != nil {
return nil, fmt.Errorf("append event: %w", err)
}
out = append(out, ev)
}
return out, nil
}
// Publish fans committed events out to live subscribers and metrics.
func (s *Store) Publish(evs ...events.Event) {
if s.metrics != nil {
for _, ev := range evs {
s.metrics.Events.WithLabelValues(string(ev.Type)).Inc()
}
}
s.bus.Publish(evs...)
}
func uuidArray(in []ids.ID) []string {
out := make([]string, len(in))
for i, id := range in {
out[i] = string(id)
}
return out
}
func nullID(id ids.ID) *string {
if id.IsZero() {
return nil
}
s := string(id)
return &s
}
// Filter selects events. Zero values mean "no constraint".
type Filter struct {
MindID ids.ID
Types []events.Type
Categories []events.Category
Sources []string
MinSalience float64
MinConfidence float64
MaxConfidence float64 // 0 = unset
Since, Until time.Time
AfterSeq int64 // exclusive lower bound (forward paging / cursors)
BeforeSeq int64 // exclusive upper bound (backward paging)
CorrelationID ids.ID
Search string // substring of summary, case-insensitive
Limit int
Ascending bool
}
const cols = `id, seq, mind_id, type, category, source, occurred_at, recorded_at, parent_ids, causation_id, correlation_id, salience, confidence, valence, arousal, evidence, summary, metadata, payload`
// Query returns events matching f ordered by seq (descending unless Ascending).
func (s *Store) Query(ctx context.Context, f Filter) ([]events.Event, error) {
var where []string
var args []any
arg := func(v any) string { args = append(args, v); return fmt.Sprintf("$%d", len(args)) }
if !f.MindID.IsZero() {
where = append(where, "mind_id = "+arg(f.MindID))
}
if len(f.Types) > 0 {
ts := make([]string, len(f.Types))
for i, t := range f.Types {
ts[i] = string(t)
}
where = append(where, "type = ANY("+arg(ts)+")")
}
if len(f.Categories) > 0 {
cs := make([]string, len(f.Categories))
for i, c := range f.Categories {
cs[i] = string(c)
}
where = append(where, "category = ANY("+arg(cs)+")")
}
if len(f.Sources) > 0 {
where = append(where, "source = ANY("+arg(f.Sources)+")")
}
if f.MinSalience > 0 {
where = append(where, "salience >= "+arg(f.MinSalience))
}
if f.MinConfidence > 0 {
where = append(where, "confidence >= "+arg(f.MinConfidence))
}
if f.MaxConfidence > 0 {
where = append(where, "confidence <= "+arg(f.MaxConfidence))
}
if !f.Since.IsZero() {
where = append(where, "occurred_at >= "+arg(f.Since))
}
if !f.Until.IsZero() {
where = append(where, "occurred_at <= "+arg(f.Until))
}
if f.AfterSeq > 0 {
where = append(where, "seq > "+arg(f.AfterSeq))
}
if f.BeforeSeq > 0 {
where = append(where, "seq < "+arg(f.BeforeSeq))
}
if !f.CorrelationID.IsZero() {
where = append(where, "correlation_id = "+arg(f.CorrelationID))
}
if q := strings.TrimSpace(f.Search); q != "" {
where = append(where, "summary ILIKE "+arg("%"+escapeLike(q)+"%"))
}
limit := f.Limit
if limit <= 0 || limit > 1000 {
limit = 100
}
order := "DESC"
if f.Ascending {
order = "ASC"
}
sql := "SELECT " + cols + " FROM cognitive_events"
if len(where) > 0 {
sql += " WHERE " + strings.Join(where, " AND ")
}
sql += " ORDER BY seq " + order + " LIMIT " + arg(limit)
rows, err := s.db.Query(ctx, sql, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []events.Event
for rows.Next() {
ev, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, ev)
}
return out, rows.Err()
}
func escapeLike(s string) string {
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return r.Replace(s)
}
// Get fetches one event by id.
func (s *Store) Get(ctx context.Context, id ids.ID) (events.Event, error) {
ev, err := scan(s.db.QueryRow(ctx, "SELECT "+cols+" FROM cognitive_events WHERE id=$1", id))
return ev, storage.Translate(err)
}
// GetMany fetches events by id, preserving no particular order.
func (s *Store) GetMany(ctx context.Context, idList []ids.ID) ([]events.Event, error) {
if len(idList) == 0 {
return nil, nil
}
rows, err := s.db.Query(ctx, "SELECT "+cols+" FROM cognitive_events WHERE id = ANY($1) ORDER BY seq", uuidArray(idList))
if err != nil {
return nil, err
}
defer rows.Close()
var out []events.Event
for rows.Next() {
ev, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, ev)
}
return out, rows.Err()
}
// Children returns events directly caused by, or listing as parent, the given event.
func (s *Store) Children(ctx context.Context, id ids.ID, limit int) ([]events.Event, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := s.db.Query(ctx, "SELECT "+cols+" FROM cognitive_events WHERE causation_id=$1 OR $1 = ANY(parent_ids) ORDER BY seq LIMIT $2", id, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []events.Event
for rows.Next() {
ev, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, ev)
}
return out, rows.Err()
}
// DerivedFrom returns events that cite id as evidence.
func (s *Store) DerivedFrom(ctx context.Context, kind string, id string, limit int) ([]events.Event, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
ref, _ := json.Marshal([]map[string]string{{"kind": kind, "id": id}})
rows, err := s.db.Query(ctx, "SELECT "+cols+" FROM cognitive_events WHERE evidence @> $1::jsonb ORDER BY seq LIMIT $2", ref, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []events.Event
for rows.Next() {
ev, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, ev)
}
return out, rows.Err()
}
func scan(row pgx.Row) (events.Event, error) {
var ev events.Event
var typ, cat string
var parents []string
var causation, correlation *string
var evidence, metadata []byte
err := row.Scan(&ev.ID, &ev.Seq, &ev.MindID, &typ, &cat, &ev.Source, &ev.OccurredAt, &ev.RecordedAt, &parents, &causation, &correlation,
&ev.Salience, &ev.Confidence, &ev.Valence, &ev.Arousal, &evidence, &ev.Summary, &metadata, &ev.Payload)
if err != nil {
return ev, err
}
ev.Type, ev.Category = events.Type(typ), events.Category(cat)
ev.ParentIDs = make([]ids.ID, len(parents))
for i, p := range parents {
ev.ParentIDs[i] = ids.ID(p)
}
if causation != nil {
ev.CausationID = ids.ID(*causation)
}
if correlation != nil {
ev.CorrelationID = ids.ID(*correlation)
}
json.Unmarshal(evidence, &ev.Evidence) //nolint:errcheck
json.Unmarshal(metadata, &ev.Metadata) //nolint:errcheck
if ev.Evidence == nil {
ev.Evidence = []events.EvidenceRef{}
}
if ev.Metadata == nil {
ev.Metadata = map[string]any{}
}
return ev, nil
}
// Cursor returns the last sequence consumed by consumer for a mind.
func (s *Store) Cursor(ctx context.Context, q storage.Querier, mind ids.ID, consumer string) (int64, error) {
var seq int64
err := q.QueryRow(ctx, `SELECT last_seq FROM mind_cursors WHERE mind_id=$1 AND consumer=$2`, mind, consumer).Scan(&seq)
if errors.Is(err, pgx.ErrNoRows) {
return 0, nil
}
return seq, err
}
// SetCursor advances a consumer cursor (never backwards).
func (s *Store) SetCursor(ctx context.Context, q storage.Querier, mind ids.ID, consumer string, seq int64) error {
_, err := q.Exec(ctx, `INSERT INTO mind_cursors (mind_id, consumer, last_seq) VALUES ($1,$2,$3)
ON CONFLICT (mind_id, consumer) DO UPDATE SET last_seq = GREATEST(mind_cursors.last_seq, excluded.last_seq), updated_at = now()`, mind, consumer, seq)
return err
}
// Stats summarises a mind's events for overview pages.
type Stats struct {
Total int64 `json:"total"`
ByCategory map[string]int64 `json:"by_category"`
LastSeq int64 `json:"last_seq"`
LastAt *time.Time `json:"last_at,omitempty"`
}
// Stats counts events for a mind (optionally since a time).
func (s *Store) Stats(ctx context.Context, mind ids.ID, since time.Time) (Stats, error) {
st := Stats{ByCategory: map[string]int64{}}
rows, err := s.db.Query(ctx, `SELECT category, count(*), max(seq), max(occurred_at) FROM cognitive_events WHERE mind_id=$1 AND ($2::timestamptz IS NULL OR occurred_at >= $2) GROUP BY category`, mind, nullTime(since))
if err != nil {
return st, err
}
defer rows.Close()
for rows.Next() {
var cat string
var n, maxSeq int64
var at *time.Time
if err := rows.Scan(&cat, &n, &maxSeq, &at); err != nil {
return st, err
}
st.ByCategory[cat] = n
st.Total += n
if maxSeq > st.LastSeq {
st.LastSeq, st.LastAt = maxSeq, at
}
}
return st, rows.Err()
}
func nullTime(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
return &t
}
+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."},