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:
@@ -130,3 +130,24 @@ label.check { flex-direction: row; align-items: center; gap: .5rem; font-weight:
|
||||
.grow { flex: 1; }
|
||||
ul.plain { list-style: none; padding: 0; margin: 0; }
|
||||
.diff-old { color: var(--danger); text-decoration: line-through; } .diff-new { color: var(--ok); }
|
||||
|
||||
/* Event stream */
|
||||
.filterbar { display: flex; flex-wrap: wrap; gap: .6rem 1rem; align-items: flex-end; margin-bottom: 1rem; }
|
||||
.filterbar label { font-size: .8rem; }
|
||||
.filterbar input, .filterbar select { padding: .35rem .5rem; font-size: .88rem; }
|
||||
.table .num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.table td.summary { max-width: 60ch; }
|
||||
.etype { display: inline-block; font-family: var(--mono); font-size: .74rem; padding: .05rem .4rem; border-radius: 4px; background: var(--surface-2); color: var(--muted); white-space: nowrap; border-left: 3px solid var(--muted); }
|
||||
.etype.cat-perception { border-left-color: #0ea5a4; } .etype.cat-cognition { border-left-color: var(--accent); } .etype.cat-memory { border-left-color: #8b5cf6; }
|
||||
.etype.cat-belief { border-left-color: #d97706; } .etype.cat-prediction { border-left-color: #db2777; } .etype.cat-goal { border-left-color: #16a34a; }
|
||||
.etype.cat-affect { border-left-color: #e11d48; } .etype.cat-action { border-left-color: #b45309; } .etype.cat-llm { border-left-color: #64748b; }
|
||||
.etype.cat-operator { border-left-color: #0f766e; } .etype.cat-system { border-left-color: var(--border); }
|
||||
tr.fresh { animation: fresh 2.5s ease-out; }
|
||||
@keyframes fresh { from { background: var(--accent-soft); } to { background: transparent; } }
|
||||
body.stream-offline [data-stream-status] { background: var(--danger-soft); color: var(--danger); }
|
||||
pre.json { background: var(--surface-2); padding: .75rem; border-radius: 8px; overflow-x: auto; font-size: .82rem; margin: 0; max-height: 28rem; }
|
||||
.chain-node { padding: .5rem .7rem; border: 1px solid var(--border); border-radius: 8px; margin: .2rem 0; }
|
||||
.chain-node.current { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.chain-arrow { text-align: center; color: var(--muted); }
|
||||
ol.corr li.current { font-weight: 600; }
|
||||
.cats { display: flex; flex-wrap: wrap; gap: .3rem; margin: .4rem 0; }
|
||||
|
||||
@@ -49,7 +49,55 @@
|
||||
return { close: () => es && es.close() };
|
||||
}
|
||||
|
||||
// Escape text for safe HTML insertion when building rows from JSON.
|
||||
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
const f2 = (n) => (typeof n === "number" ? n.toFixed(2) : "");
|
||||
const hms = (iso) => { const d = new Date(iso); return isNaN(d) ? "" : d.toTimeString().slice(0, 8); };
|
||||
|
||||
function eventRow(ev) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.dataset.seq = ev.seq; tr.dataset.id = ev.id; tr.className = "cat-" + ev.category + " fresh";
|
||||
const evid = (ev.evidence && ev.evidence.length) ? ` <span title="${ev.evidence.length} evidence refs">◉${ev.evidence.length}</span>` : "";
|
||||
const cause = ev.causation_id ? `<a href="/events/${esc(ev.causation_id)}" title="caused by">↑</a>` : "";
|
||||
tr.innerHTML = `<td class="nowrap muted small mono" title="${esc(ev.occurred_at)}">${hms(ev.occurred_at)}</td>` +
|
||||
`<td><span class="etype cat-${esc(ev.category)}">${esc(ev.type)}</span></td>` +
|
||||
`<td class="small muted">${esc(ev.source)}</td>` +
|
||||
`<td class="summary"><a href="/events/${esc(ev.id)}">${esc(ev.summary)}</a></td>` +
|
||||
`<td class="num small">${f2(ev.salience)}</td><td class="num small">${f2(ev.confidence)}</td>` +
|
||||
`<td class="small muted nowrap">${cause}${evid}</td>`;
|
||||
return tr;
|
||||
}
|
||||
|
||||
// Live event tables: <table data-stream="url" data-stream-after="seq"><tbody data-stream-target>
|
||||
function attachStreams() {
|
||||
document.querySelectorAll("table[data-stream]").forEach((table) => {
|
||||
const body = table.querySelector("[data-stream-target]");
|
||||
const status = document.querySelector("[data-stream-status]");
|
||||
const toggle = document.querySelector("[data-live-toggle]");
|
||||
let url = table.dataset.stream;
|
||||
const after = table.dataset.streamAfter;
|
||||
if (after && after !== "0") url += (url.includes("?") ? "&" : "?") + "after=" + encodeURIComponent(after);
|
||||
let handle = null;
|
||||
const start = () => {
|
||||
handle = stream(url, {
|
||||
cognitive_event: (ev) => {
|
||||
const empty = body.querySelector("[data-empty]"); if (empty) empty.remove();
|
||||
if (body.querySelector(`tr[data-seq="${ev.seq}"]`)) return;
|
||||
body.insertBefore(eventRow(ev), body.firstChild);
|
||||
while (body.children.length > 300) body.removeChild(body.lastChild);
|
||||
if (status) status.textContent = "live";
|
||||
},
|
||||
});
|
||||
if (status) status.textContent = "live";
|
||||
};
|
||||
const stop = () => { if (handle) handle.close(); handle = null; if (status) status.textContent = "paused"; };
|
||||
if (!toggle || toggle.checked) start();
|
||||
if (toggle) toggle.addEventListener("change", () => (toggle.checked ? start() : stop()));
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
attachStreams();
|
||||
const tt = document.querySelector("[data-theme-toggle]");
|
||||
if (tt) tt.addEventListener("click", cycleTheme);
|
||||
const nt = document.querySelector("[data-nav-toggle]");
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
{{define "content"}}
|
||||
<div class="pagehead"><h1>Dashboard</h1><p class="muted">System overview. Minds, cognition and approvals appear here as they come online.</p></div>
|
||||
<section class="grid cols-3">
|
||||
<div class="card stat"><div class="stat-label">Running minds</div><div class="stat-value">{{.Data.Minds}}</div><div class="muted small">No minds yet. Create one under Minds.</div></div>
|
||||
<div class="card stat"><div class="stat-label">Active minds</div><div class="stat-value">{{len .Data.Minds}}</div><div class="muted small">{{range .Data.Minds}}<a href="/minds/{{.Slug}}">{{.DisplayName}}</a> {{else}}No minds yet. Create one under Minds.{{end}}</div></div>
|
||||
<div class="card stat"><div class="stat-label">System health</div><div class="stat-value">{{if .Data.Status.Database.OK}}<span class="ok">Healthy</span>{{else}}<span class="danger">Degraded</span>{{end}}</div><div class="muted small">Database {{if .Data.Status.Database.OK}}connected{{else}}unavailable{{end}} · up {{duration .Data.Status.UptimeSeconds}}</div></div>
|
||||
<div class="card stat"><div class="stat-label">Outstanding approvals</div><div class="stat-value">0</div><div class="muted small">Capability layer arrives in a later phase.</div></div>
|
||||
</section>
|
||||
<section class="grid cols-2">
|
||||
<div class="card">
|
||||
<h2>Recent important events <span class="muted small">salience ≥ 0.6, all minds</span></h2>
|
||||
{{if .Data.Events}}<table class="table compact"><tbody>{{range .Data.Events}}<tr><td class="nowrap muted small mono">{{.OccurredAt.Local.Format "15:04:05"}}</td><td><span class="etype cat-{{.Category}}">{{.Type}}</span></td><td><a href="/events/{{.ID}}">{{.Summary}}</a></td><td class="small muted">{{index $.Data.MindNames .MindID}}</td></tr>{{end}}</tbody></table>
|
||||
{{else}}<p class="muted">Nothing yet.</p>{{end}}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Recent administrative activity</h2>
|
||||
{{if .Data.Audit}}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
{{define "content"}}
|
||||
{{$c := .Data.Ctx}}{{$e := $c.Event}}
|
||||
<div class="pagehead">
|
||||
<p class="muted small"><a href="/minds/{{.Data.Mind.Slug}}">{{.Data.Mind.DisplayName}}</a> › <a href="/minds/{{.Data.Mind.Slug}}/thoughts">thoughts</a> › event #{{$e.Seq}}</p>
|
||||
<h1><span class="etype cat-{{$e.Category}}">{{$e.Type}}</span> {{$e.Summary}}</h1>
|
||||
<p class="muted">{{$e.OccurredAt.Local.Format "2006-01-02 15:04:05.000"}} · source <strong>{{$e.Source}}</strong> · salience {{conf $e.Salience}} · confidence {{conf $e.Confidence}}{{if $e.Valence}} · valence {{conf (deref $e.Valence)}}{{end}}{{if $e.Arousal}} · arousal {{conf (deref $e.Arousal)}}{{end}}</p>
|
||||
</div>
|
||||
<section class="grid cols-3-1">
|
||||
<div class="stack">
|
||||
<div class="card">
|
||||
<h2>Causal chain</h2>
|
||||
<div class="chain">
|
||||
{{if $c.Cause}}<div class="chain-node"><span class="muted small">caused by</span><br><span class="etype cat-{{$c.Cause.Category}}">{{$c.Cause.Type}}</span> <a href="/events/{{$c.Cause.ID}}">{{$c.Cause.Summary}}</a></div><div class="chain-arrow">↓</div>{{end}}
|
||||
<div class="chain-node current"><span class="etype cat-{{$e.Category}}">{{$e.Type}}</span> {{$e.Summary}}</div>
|
||||
{{if $c.Children}}<div class="chain-arrow">↓</div>{{range $c.Children}}<div class="chain-node"><span class="etype cat-{{.Category}}">{{.Type}}</span> <a href="/events/{{.ID}}">{{.Summary}}</a> <span class="muted small">{{.Source}}</span></div>{{end}}{{else}}<div class="muted small" style="margin-top:.5rem">No downstream events yet.</div>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Evidence <span class="muted small">what this event rests on</span></h2>
|
||||
{{if $c.EvidenceAll}}<table class="table compact"><thead><tr><th>Kind</th><th>Reference</th><th>Note</th></tr></thead><tbody>
|
||||
{{range $c.EvidenceAll}}<tr><td><span class="badge">{{.Kind}}</span></td><td class="mono small">{{if eq .Kind "event"}}<a href="/events/{{.ID}}">{{.ID}}</a>{{else if eq .Kind "memory"}}<a href="/minds/{{$.Data.Mind.Slug}}/memory/{{.ID}}">{{.ID}}</a>{{else if eq .Kind "belief"}}<a href="/minds/{{$.Data.Mind.Slug}}/beliefs/{{.ID}}">{{.ID}}</a>{{else}}{{.ID}}{{end}}</td><td class="small">{{.Note}}</td></tr>{{end}}
|
||||
</tbody></table>
|
||||
{{if $c.Evidence}}<h3>Cited events</h3><ul class="plain">{{range $c.Evidence}}<li><span class="etype cat-{{.Category}}">{{.Type}}</span> <a href="/events/{{.ID}}">{{.Summary}}</a></li>{{end}}</ul>{{end}}
|
||||
{{else}}<p class="muted">No evidence references. {{if eq $e.Category "perception"}}Observations are primary evidence themselves.{{end}}</p>{{end}}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Payload</h2>
|
||||
<pre class="json">{{.Data.Payload}}</pre>
|
||||
{{if $e.Metadata}}<h3>Metadata</h3><pre class="json">{{.Data.Metadata}}</pre>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stack">
|
||||
<div class="card">
|
||||
<h2>Identifiers</h2>
|
||||
<dl class="kv small">
|
||||
<dt>Event</dt><dd class="mono">{{$e.ID}}</dd>
|
||||
<dt>Seq</dt><dd>{{$e.Seq}}</dd>
|
||||
<dt>Recorded</dt><dd>{{$e.RecordedAt.Local.Format "15:04:05.000"}}</dd>
|
||||
{{if $e.CorrelationID}}<dt>Correlation</dt><dd class="mono"><a href="/minds/{{.Data.Mind.Slug}}/thoughts?correlation={{$e.CorrelationID}}">{{$e.CorrelationID}}</a></dd>{{end}}
|
||||
{{if $e.ParentIDs}}<dt>Parents</dt><dd class="mono">{{range $e.ParentIDs}}<a href="/events/{{.}}">{{.}}</a><br>{{end}}</dd>{{end}}
|
||||
</dl>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Derived from this <span class="muted small">events citing it as evidence</span></h2>
|
||||
{{if $c.Derived}}<ul class="plain small">{{range $c.Derived}}<li><span class="etype cat-{{.Category}}">{{.Type}}</span> <a href="/events/{{.ID}}">{{.Summary}}</a></li>{{end}}</ul>{{else}}<p class="muted small">Nothing yet.</p>{{end}}
|
||||
</div>
|
||||
{{if $c.Correlated}}<div class="card">
|
||||
<h2>Same correlation <span class="muted small">{{len $c.Correlated}} events</span></h2>
|
||||
<ol class="small corr">{{range $c.Correlated}}<li {{if eq .ID $e.ID}}class="current"{{end}}><span class="etype cat-{{.Category}}">{{.Type}}</span> <a href="/events/{{.ID}}">{{.Summary}}</a></li>{{end}}</ol>
|
||||
</div>{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -5,11 +5,7 @@
|
||||
<p class="muted">{{$m.Personality.Identity.Role}}{{with $m.Personality.Identity.Description}} · {{.}}{{end}}</p></div>
|
||||
<div class="row">{{template "status_badge" $m.Status}}</div>
|
||||
</div>
|
||||
<nav class="tabs" aria-label="Mind sections">
|
||||
<a href="/minds/{{$m.Slug}}" aria-current="page">Overview</a>
|
||||
<a href="/minds/{{$m.Slug}}/personality">Personality</a>
|
||||
<a class="disabled" title="Phase 3">Thoughts</a><a class="disabled" title="Phase 4">Workspace</a><a class="disabled" title="Phase 5">Memory</a><a class="disabled" title="Phase 6">Beliefs</a><a class="disabled" title="Phase 8">Conversation</a>
|
||||
</nav>
|
||||
{{template "mind_tabs" (dict "M" $m "Active" "overview")}}
|
||||
<section class="grid cols-3">
|
||||
<div class="card">
|
||||
<h2>Identity</h2>
|
||||
@@ -24,8 +20,17 @@
|
||||
</dl>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Current focus</h2>
|
||||
<p class="muted">Working memory, workspace and narrative appear here once the cognitive runtime is online (phase 4).</p>
|
||||
<h2>Recent activity</h2>
|
||||
{{$st := .Data.Stats}}
|
||||
<div class="stat-value small">{{$st.Total}} <span class="muted small">events in 24h</span></div>
|
||||
<div class="cats small">{{range $k, $v := $st.ByCategory}}<span class="etype cat-{{$k}}">{{$k}} {{$v}}</span> {{end}}</div>
|
||||
<p class="small"><a href="/minds/{{$m.Slug}}/thoughts">Open thought stream</a></p>
|
||||
{{if .Principal.User.Role.AtLeast "operator"}}
|
||||
<form method="post" action="/minds/{{$m.Slug}}/observe" class="stack" style="margin-top:.75rem">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<label>Inject observation <span class="muted small">as operator</span> <textarea name="text" rows="2" maxlength="20000" placeholder="e.g. server-01 reported SMART CRC errors at 09:12" required></textarea></label>
|
||||
<div><button class="btn small" type="submit">Observe</button></div>
|
||||
</form>{{end}}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Cognitive configuration</h2>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div><h1>{{$m.DisplayName}} <span class="muted small">personality</span></h1><p class="muted">Version {{$m.PersonalityVersion}}{{if .Data.Template}} · based on template <strong>{{.Data.Template.Name}}</strong>{{end}}. Every save creates a new version; nothing is overwritten.</p></div>
|
||||
<div class="row"><a class="btn small" href="/minds/{{$m.Slug}}/personality.json">Export JSON</a></div>
|
||||
</div>
|
||||
<nav class="tabs" aria-label="Mind sections"><a href="/minds/{{$m.Slug}}">Overview</a><a href="/minds/{{$m.Slug}}/personality" aria-current="page">Personality</a></nav>
|
||||
{{template "mind_tabs" (dict "M" $m "Active" "personality")}}
|
||||
{{$admin := .Principal.User.Role.AtLeast "administrator"}}
|
||||
<section class="grid cols-3-1">
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
{{define "content"}}
|
||||
{{$m := .Data.Mind}}
|
||||
<div class="pagehead mindhead">
|
||||
<div><h1>{{$m.DisplayName}} <span class="muted small">thought stream</span></h1><p class="muted">Every cognitive event, newest first. <span data-stream-status class="badge">connecting…</span></p></div>
|
||||
<div class="row"><label class="check small"><input type="checkbox" data-live-toggle checked> Live</label></div>
|
||||
</div>
|
||||
{{template "mind_tabs" (dict "M" $m "Active" "thoughts")}}
|
||||
<form method="get" class="card filterbar" data-filter-form>
|
||||
<label>Category <select name="category"><option value="">all</option>{{range .Data.Categories}}<option value="{{.}}" {{if eq (index $.Data.Q "category") (print .)}}selected{{end}}>{{.}}</option>{{end}}</select></label>
|
||||
<label>Type <select name="type"><option value="">all</option>{{range .Data.Types}}<option value="{{.}}" {{if eq (index $.Data.Q "type") (print .)}}selected{{end}}>{{.}}</option>{{end}}</select></label>
|
||||
<label>Source <input name="source" value="{{index .Data.Q "source"}}" placeholder="process or integration" size="14"></label>
|
||||
<label>Min salience <input name="min_salience" type="number" min="0" max="1" step="0.1" value="{{index .Data.Q "min_salience"}}" size="4"></label>
|
||||
<label>Min confidence <input name="min_confidence" type="number" min="0" max="1" step="0.1" value="{{index .Data.Q "min_confidence"}}" size="4"></label>
|
||||
<label>Since <select name="since"><option value="">any</option>{{range $k, $v := .Data.Windows}}<option value="{{$k}}" {{if eq (index $.Data.Q "since") $k}}selected{{end}}>{{$v}}</option>{{end}}</select></label>
|
||||
<label>Search <input name="q" value="{{index .Data.Q "q"}}" placeholder="summary contains" size="18"></label>
|
||||
<button class="btn" type="submit">Apply</button> <a class="btn btn-ghost" href="/minds/{{$m.Slug}}/thoughts">Reset</a>
|
||||
</form>
|
||||
<div class="card">
|
||||
<table class="table compact stream" data-stream="/api/v1/minds/{{$m.Slug}}/events/stream{{.Data.StreamQuery}}" data-stream-after="{{.Data.TopSeq}}">
|
||||
<thead><tr><th class="nowrap">Time</th><th>Event</th><th>Source</th><th>Summary</th><th class="num">Sal</th><th class="num">Conf</th><th></th></tr></thead>
|
||||
<tbody data-stream-target>
|
||||
{{range .Data.Events}}{{template "event_row" .}}{{else}}<tr data-empty><td colspan="7" class="muted">No events yet. Operator actions, observations and cognition will appear here.</td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{with .Data.NextBefore}}<p><a class="btn" href="/minds/{{$m.Slug}}/thoughts?{{$.Data.RawQuery}}&before={{.}}">Older</a></p>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{define "event_row"}}<tr data-seq="{{.Seq}}" data-id="{{.ID}}" class="cat-{{.Category}}">
|
||||
<td class="nowrap muted small mono" title="{{.OccurredAt}}">{{.OccurredAt.Local.Format "15:04:05"}}</td>
|
||||
<td><span class="etype cat-{{.Category}}">{{.Type}}</span></td>
|
||||
<td class="small muted">{{.Source}}</td>
|
||||
<td class="summary"><a href="/events/{{.ID}}">{{.Summary}}</a></td>
|
||||
<td class="num small">{{conf .Salience}}</td>
|
||||
<td class="num small">{{conf .Confidence}}</td>
|
||||
<td class="small muted nowrap">{{if .CausationID}}<a href="/events/{{.CausationID}}" title="caused by">↑</a>{{end}}{{if .Evidence}} <span title="{{len .Evidence}} evidence refs">◉{{len .Evidence}}</span>{{end}}</td>
|
||||
</tr>{{end}}
|
||||
@@ -0,0 +1,8 @@
|
||||
{{define "mind_tabs"}}{{$m := .M}}{{$a := .Active}}
|
||||
<nav class="tabs" aria-label="Mind sections">
|
||||
<a href="/minds/{{$m.Slug}}" {{if eq $a "overview"}}aria-current="page"{{end}}>Overview</a>
|
||||
<a href="/minds/{{$m.Slug}}/thoughts" {{if eq $a "thoughts"}}aria-current="page"{{end}}>Thoughts</a>
|
||||
<a href="/minds/{{$m.Slug}}/personality" {{if eq $a "personality"}}aria-current="page"{{end}}>Personality</a>
|
||||
<a class="disabled" title="Phase 4">Workspace</a><a class="disabled" title="Phase 5">Memory</a><a class="disabled" title="Phase 6">Beliefs</a><a class="disabled" title="Phase 8">Conversation</a>
|
||||
</nav>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user