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]");
|
||||
|
||||
Reference in New Issue
Block a user