viewer/ invariants
W1 — The viewer is read-mostly#
Invariant. Only three viewer operations mutate core state:
POST /api/v1/feedback(FeedbackView)POST /api/v1/skills/retire(SkillsView, behind a confirm prompt)DELETE /api/v1/sessions//api/v1/episodes(not currently surfaced in the UI but allowed via REST)
Nothing else writes. In particular the viewer never forges a
TurnInputDTO or TurnResultDTO; those shapes are only produced by
adapters with an authentic agent event.
Why. Memory state is expensive to mutate — it carries reflection weights, crystallization signals, and audit history. An accidental turn from the viewer would poison all three.
W2 — No route preview without a route#
Invariant. The viewer never issues a prefetch for a route the
user did not explicitly navigate to. Each view's useEffect runs
strictly on mount; on unmount it aborts outstanding fetches via
AbortController.
Why. Speculative prefetch would trigger algorithm-side work (e.g.
searchMemory) the user didn't ask for, burning LLM/embedding budget
and confusing audit logs about which turns the user actually saw.
W3 — API-key propagation is uniform#
Invariant. Every call path reads the api key from one place
(localStorage["memos.apiKey"]) and attaches it as x-api-key.
There are exactly three call sites:
api.get/api.post/api.del(REST)openSse(SSE)- (no third — these two cover every network request)
Why. Scattered header construction leads to leaks (keys in URLs, sent to wrong origins) and misses (auth silently dropped on one path). Centralizing keeps the auth surface auditable and side-effect free.
W4 — SSE streams degrade gracefully, never crash the view#
Invariant. When the SSE connection drops, the viewer displays the
last-known state and attempts reconnect with exponential backoff
(500ms → 1s → … → 16s). A dropped connection never unmounts the
view, and never duplicates rows after reconnect (the live list is
prepended and capped at 500–800 rows).
Why. Browser sleep, VPN flips, or restarts of the core should show up as "temporarily disconnected" rather than a blank screen or a rendering freeze. The algorithm itself keeps emitting; the viewer's job is to catch back up silently.
W5 — Injected content is displayed verbatim#
Invariant. When the viewer shows injectedContext,
invocationGuide, userText, agentText, or any trace body, it
preserves the raw string exactly. No Markdown rendering, no HTML
unescape. Code-like content is wrapped in <pre class="code"> with
white-space: pre-wrap.
Why. The algorithm expects to reason about the exact text it saved. A prettified rendering would confuse operators trying to debug why a skill fired or a retrieval matched — the UI would show something different from what the LLM actually sees.
W6 — Retire actions require confirmation#
Invariant. retireSkill is always preceded by an affirmative
confirm("Retire skill …?"). Skills retire lazily, not eagerly.
Why. Retired skills stop being injected, which is irreversible from the viewer alone (reinstating requires a trace-level signal). A confirm step prevents one-click loss of adopted knowledge.
W7 — Theme switches never lose state#
Invariant. Toggling the theme updates data-theme on <html>
and writes memos.theme in localStorage; it does not remount the
app. Signal-backed view state (filters, selection, paused streams)
survives.
Why. Themes are cosmetic; forcing a reload for a light/dark switch is clumsy and would drop an operator's filter/cursor while they're trying to debug a live incident.
W8 — No framework lock-in on routes#
Invariant. The router is a single 40-line signal. Any viewer change that requires a third-party routing library must first delete this module and move the rest of the viewer off its API.
Why. The viewer's API surface is small enough that a real router would be a liability, not a feature. This invariant keeps that promise explicit.
W9 — Every stream has a pause + clear#
Invariant. EventsView and LogsView both expose Pause and
Clear controls, with state local to the view. Pausing does not
disconnect the stream; it only stops accumulating rows.
Why. During an incident, operators need to freeze the view while they read it and wipe noise once they've understood a pattern. This keeps those ergonomics fixed regardless of log volume.
W10 — Types match the core by construction#
Invariant. Every DTO used by the viewer is re-exported from
../agent-contract via viewer/src/api/types.ts. New types are never
declared inline in viewer/src/.
Why. When the contract evolves, the viewer fails to type-check
rather than rendering stale fields. tsconfig.viewer.json is part of
CI's typecheck matrix; drift surfaces at build time, not at demo
time.