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
+108
View File
@@ -0,0 +1,108 @@
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"net/http"
"os"
"strings"
"time"
)
func init() {
extraCommands = append(extraCommands,
command{"event", "inspect <id>|list <mind> [-type t] [-n 50]|tail <mind> – cognitive events", cmdEvent},
command{"observe", "<mind> <text> – inject an operator observation", cmdObserve},
)
}
func printEvent(ev map[string]any) {
fmt.Printf("%s %-22s %-12s %s [sal %.2f conf %.2f]\n", fmt.Sprint(ev["occurred_at"])[11:19], ev["type"], ev["source"], ev["summary"], num(ev["salience"]), num(ev["confidence"]))
}
func num(v any) float64 { f, _ := v.(float64); return f }
func cmdEvent(args []string) int {
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "usage: noemactl event inspect <id> | list <mind> [-type t] [-category c] [-n 50] [-json] | tail <mind>")
return exitUsage
}
switch args[0] {
case "inspect":
var ctx map[string]any
if code, err := apiRequest("GET", "/api/v1/events/"+args[1]+"/context", nil, &ctx); err != nil {
return exitFor(code, err)
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
enc.Encode(ctx) //nolint:errcheck
return exitOK
case "list":
fs := flag.NewFlagSet("event list", flag.ContinueOnError)
typ := fs.String("type", "", "event type")
cat := fs.String("category", "", "category")
n := fs.Int("n", 50, "count")
asJSON := fs.Bool("json", false, "raw JSON")
if fs.Parse(args[2:]) != nil {
return exitUsage
}
var out struct{ Items []map[string]any }
q := fmt.Sprintf("?limit=%d&type=%s&category=%s", *n, *typ, *cat)
if code, err := apiRequest("GET", "/api/v1/minds/"+args[1]+"/events"+q, nil, &out); err != nil {
return exitFor(code, err)
}
if *asJSON {
json.NewEncoder(os.Stdout).Encode(out.Items) //nolint:errcheck
return exitOK
}
for i := len(out.Items) - 1; i >= 0; i-- {
printEvent(out.Items[i])
}
return exitOK
case "tail":
base := os.Getenv("NOEMA_URL")
if base == "" {
base = "http://127.0.0.1:8080"
}
req, _ := http.NewRequest("GET", strings.TrimRight(base, "/")+"/api/v1/minds/"+args[1]+"/events/stream", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("NOEMA_API_TOKEN"))
res, err := (&http.Client{Timeout: 0}).Do(req)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return exitFail
}
defer res.Body.Close()
if res.StatusCode != 200 {
return exitFor(res.StatusCode, fmt.Errorf("%s", res.Status))
}
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
line := sc.Text()
if strings.HasPrefix(line, "data: ") {
var ev map[string]any
if json.Unmarshal([]byte(line[6:]), &ev) == nil {
printEvent(ev)
}
}
}
return exitOK
}
fmt.Fprintln(os.Stderr, "unknown event subcommand", args[0])
return exitUsage
}
func cmdObserve(args []string) int {
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "usage: noemactl observe <mind> <text...>")
return exitUsage
}
var ev map[string]any
if code, err := apiRequest("POST", "/api/v1/minds/"+args[0]+"/observations", map[string]any{"text": strings.Join(args[1:], " "), "source": "noemactl"}, &ev); err != nil {
return exitFor(code, err)
}
fmt.Printf("recorded observation %v (seq %v) at %v\n", ev["id"], ev["seq"], time.Now().Format(time.RFC3339))
return exitOK
}
+5 -2
View File
@@ -18,6 +18,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/scheduler"
@@ -25,6 +26,7 @@ import (
"github.com/andydixon/noema/internal/ui"
"github.com/andydixon/noema/internal/version"
"github.com/andydixon/noema/migrations"
"github.com/andydixon/noema/pkg/ids"
)
func main() {
@@ -74,14 +76,15 @@ func run() error {
metrics := observability.NewMetrics()
authSvc := auth.NewService(db, cfg.SessionIdleTTL, cfg.SessionAbsoluteTTL)
auditLog := audit.New(db, log)
deps := app.Deps{Config: cfg, Log: log, DB: db, Auth: authSvc, Audit: auditLog, Metrics: metrics, Minds: minds.NewStore(db), Migrations: migrations.FS, Started: time.Now()}
deps := app.Deps{Config: cfg, Log: log, DB: db, Auth: authSvc, Audit: auditLog, Metrics: metrics, Minds: minds.NewStore(db), Events: evstore.NewStore(db, evstore.NewBus(200), metrics), Migrations: migrations.FS, Started: time.Now()}
if err := bootstrapAdmin(ctx, deps); err != nil {
return err
}
var srv *api.Server
uiHandler, err := ui.New(deps, func(r *http.Request) any { return srv.Status(r.Context()) })
uiHandler, err := ui.New(deps, func(r *http.Request) any { return srv.Status(r.Context()) },
func(r *http.Request, id ids.ID) (api.EventContext, error) { return srv.Context(r, id) })
if err != nil {
return err
}