diff --git a/docs/contracts/web_debug_surface.contract.md b/docs/contracts/web_debug_surface.contract.md index fab8d87..4e1d39d 100644 --- a/docs/contracts/web_debug_surface.contract.md +++ b/docs/contracts/web_debug_surface.contract.md @@ -105,6 +105,16 @@ functions: - "POST-004: the Tools inventory renders tool NAMES only — a compact comma-joined summary ('what does the LLM have', the debug glance); per-tool DESCRIPTIONS are surfaced in the BifrostState pane's tools list, deliberately NOT duplicated here. (Heid panel Hulda/Regin precision finding — accepted: contract wording clarified, code unchanged; the earlier 'names/descriptions' phrasing in INV-004 refers to the SET of value types that MAY appear across the new panes and must be escaped, not a mandate that every pane render descriptions.)" flexibility: "open" + - name: "renderAffectPane + trend (v0.19.4 — relation_edge/1 render + sparkline)" + signature: "renderAffectPane(snap) ; pushAffectHistory(snap) ; sparkline(vals) ; trendDelta(vals)" + description: "Render the Tier-3 affect snapshot as PAD mood + the durable per-entity relational model, each value with a Δ-vs-previous + a session-lived sparkline." + postconditions: + - "POST-001: reads snap.relations (relation_edge/1: target_entity + trust_ability/benevolence/integrity + warmth as {value,confidence,evidence_count} + agency + relation_context + obligation_balance) — the CURRENT Worldtree emit shape; falls back to the legacy flat snap.valence for an older emitter. SUPERSEDES the #18-D2 contract's valence assumption (Worldtree's #265 Vili rework replaced valence/regard with the relation_edge/trust model; the old renderer read snap.valence and showed an empty 'valence (0)' — the bug this fixes)." + - "POST-002: each metric shows current value + Δ-vs-previous (▲/▼) + a unicode sparkline auto-scaled to its OWN observed range (flat ▄ when sub-0.01 stable — no noise amplification), drawn from AFFECT_HIST (rolling, HIST_CAP=24, session-lived)." + - "POST-003: pushAffectHistory dedupes by emitted_at so the ~4x/turn post-turn PAD poll contributes ONE sample/turn; history is CLIENT-side only (lost on reload — durable cross-session history via a provider-side snapshot log is a deferred follow-up, NOT built here)." + - "POST-004: INV-001 honesty — no fabricated Tier-1 fields (no synthesized dominant_emotion). INV-004 — head() escapes its whole argument (incl. target_entity + relation_context from the snapshot) and metric() escapes every cell; numeric values go through toFixed, never innerHTML-raw." + flexibility: "open" + invariants: - "INV-004 (untrusted-render): ALL model / tool / admin / agent-supplied text is escaped before entering the DOM (esc via textContent, or esc(JSON.stringify)). No new render path introduces an innerHTML sink for upstream content. This is the highest-value review target — the new JS render paths are NOT unit-tested." - "INV-ADMIN-KEY: the admin key exists ONLY at app.state.admin_key (from RATATOSKR_ADMIN_API_KEY). It is never serialized into any response, never sent to the browser, never logged. The browser receives only the session-filtered RESULT of admin-scoped reads." diff --git a/pyproject.toml b/pyproject.toml index e4c8a43..977dd46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.19.3" +version = "0.19.4" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" readme = "README.md" requires-python = ">=3.12" diff --git a/src/ratatoskr/web/static/index.html b/src/ratatoskr/web/static/index.html index ab27bf7..f0562dd 100644 --- a/src/ratatoskr/web/static/index.html +++ b/src/ratatoskr/web/static/index.html @@ -361,7 +361,22 @@ body { /* persona pane structured render */ #pane-persona .pk { color: var(--fg-dim); } #pane-persona .pv { color: var(--blue); } -#pane-persona .ph { color: var(--cyan); letter-spacing: 0.1em; text-transform: uppercase; font-size: 10px; } +#pane-persona .ph { color: var(--cyan); letter-spacing: 0.1em; text-transform: uppercase; font-size: 10px; margin-top: 4px; } + +/* affect metric rows: label · value · Δ · sparkline · n · descriptor */ +#pane-persona .mono-note { color: var(--fg-faint); font-size: 10px; margin: 2px 0 8px; } +#pane-persona .mrow { + display: flex; gap: 9px; align-items: baseline; padding: 1px 0; + font-size: 12px; white-space: nowrap; +} +#pane-persona .mrow .mk { color: var(--fg-dim); min-width: 118px; } +#pane-persona .mrow .mv { color: var(--blue); min-width: 46px; text-align: right; font-variant-numeric: tabular-nums; } +#pane-persona .mrow .md { min-width: 58px; font-size: 11px; color: var(--fg-faint); } +#pane-persona .mrow .md.up { color: var(--green); } +#pane-persona .mrow .md.dn { color: var(--red); } +#pane-persona .mrow .msp { color: var(--cyan); letter-spacing: 1px; min-width: 28px; } +#pane-persona .mrow .mn { color: var(--fg-faint); font-size: 10px; min-width: 34px; } +#pane-persona .mrow .mdesc { color: var(--fg-faint); font-style: italic; font-size: 10px; } /* thinking-pane per-turn dividers */ .pane-turn { @@ -807,25 +822,102 @@ async function loadPersona(agentId) { // shape only — pad + per-entity valence + emitted_at; NO fabricated Tier-1 persona // fields (dominant_emotion / mood_drift), which Tier-3 structurally lacks (INV-001). // Labelled "affect", not "persona" (INV-005). +// ---- affect trend accumulation (client-side, session-lived) ---- +// The affect pane refreshes on session-open + after each turn (the post-turn PAD poll +// fires ~4x/turn — deduped here by emitted_at so a turn contributes ONE sample). Each +// tracked value keeps a rolling, capped history so the pane can show a Δ + sparkline. +const AFFECT_HIST = { at: [], pad: {}, rel: {} }; // pad[axis]=[]; rel[target][metric]=[] +const HIST_CAP = 24; +const _relVal = (x) => (x && typeof x === "object" && "value" in x) ? x.value : x; +const _relN = (x) => (x && typeof x === "object" && "evidence_count" in x) ? x.evidence_count : undefined; +function pushAffectHistory(snap) { + const at = snap.emitted_at || ""; + if (at && AFFECT_HIST.at[AFFECT_HIST.at.length - 1] === at) return; // same snapshot — skip + AFFECT_HIST.at.push(at); + if (AFFECT_HIST.at.length > HIST_CAP) AFFECT_HIST.at.shift(); + const push = (bucket, key, v) => { + if (typeof v !== "number") return; + (bucket[key] = bucket[key] || []).push(v); + if (bucket[key].length > HIST_CAP) bucket[key].shift(); + }; + const pad = snap.pad || {}; + for (const ax of ["pleasure", "arousal", "dominance"]) push(AFFECT_HIST.pad, ax, pad[ax]); + for (const rel of (snap.relations || snap.valence || [])) { + const tgt = rel.target_entity || rel.entity_id || "?"; + const b = (AFFECT_HIST.rel[tgt] = AFFECT_HIST.rel[tgt] || {}); + push(b, "trust_ability", _relVal(rel.trust_ability)); + push(b, "trust_benevolence", _relVal(rel.trust_benevolence)); + push(b, "trust_integrity", _relVal(rel.trust_integrity)); + push(b, "warmth", _relVal(rel.warmth ?? rel.familiarity)); + } +} +// unicode sparkline auto-scaled to the value's own observed range; flat when stable +// (don't amplify sub-0.01 noise into a fake trend). +const _SPARK = "▁▂▃▄▅▆▇█"; +function sparkline(vals) { + if (!vals || vals.length < 2) return (vals && vals.length) ? "·" : ""; + const lo = Math.min(...vals), hi = Math.max(...vals); + if (hi - lo < 0.01) return "▄".repeat(vals.length); + const span = hi - lo; + return vals.map((v) => _SPARK[Math.min(7, Math.floor(((v - lo) / span) * 7.999))]).join(""); +} +function trendDelta(vals) { + if (!vals || vals.length < 2) return ""; + const d = vals[vals.length - 1] - vals[vals.length - 2]; + if (Math.abs(d) < 0.0005) return "→"; + return (d > 0 ? "▲+" : "▼") + d.toFixed(3); +} + +// Issue #18 D2 (relation_edge/1 rework): Worldtree's affect snapshot now carries +// `relations[]` (target + trust_ability/benevolence/integrity + warmth + agency + +// relation_context, each {value,confidence,evidence_count}) — NOT the old flat +// `valence[]`. Render mood (PAD) + the durable per-entity relational model, each with +// a Δ + sparkline from AFFECT_HIST. Falls back to `valence` for an older emitter. +// INV-001: no fabricated Tier-1 fields. INV-004: every dynamic value escaped (head() +// escapes its whole argument; metric() escapes each cell). function renderAffectPane(snap) { - const row = (k, v) => `