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
+48
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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]");