Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca46a93171 | |||
| 85a2b95428 |
@@ -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."
|
||||
|
||||
+22
-2
@@ -41,6 +41,20 @@ upstream API key stays server-side (INV-003).
|
||||
|
||||
_As of 2026-07-01:_
|
||||
|
||||
**THE WEB SURFACE (`ratatoskr-web`, `:8765`) IS NOW THE OPERATOR'S PRIMARY DEBUG SURFACE, at full TUI
|
||||
pane parity — `v0.19.3`.** This session ported the three admin/debug panes the TUI had but the web
|
||||
lacked: **BifrostState** (`GET /admin/sessions/{id}/bifrost`), **AdminEvents** (`GET /admin/events`
|
||||
SSE, session-filtered server-side), **Tools inventory** (`GET /sessions/{id}/tools`, folded into the
|
||||
tools pane) — all via thin server proxies with the **admin key SERVER-HELD** (`app.state.admin_key`
|
||||
from `RATATOSKR_ADMIN_API_KEY`; never crosses to the browser). Plus two transcript affordances: a
|
||||
**PAD-refresh poll** (windowed `[1.5/3.5/6.5/10.5s]`, replacing a single-2s shot that raced the
|
||||
post-turn-async `affect.emit`) and a **reasoning indicator** (ephemeral non-engine "Sindra is
|
||||
pondering…" on `thinking` deltas — she emits ~253/turn). heid-code-review panel (Gróa/Hulda/Regin,
|
||||
artifact-only) found **zero server-side drift + clean INV-004**; the real catches were 2 client-side
|
||||
SSE-lifecycle bugs on the un-unit-tested SPA (fixed in `v0.19.3`). Contract: `docs/contracts/web_debug_surface.contract.md`.
|
||||
Launch recipe is now self-contained: `source env.sh && ratatoskr-web --host 0.0.0.0` comes up
|
||||
bind-ready (env.sh persists the 3 bind vars). All pushed to origin (`75dec01`).
|
||||
|
||||
**THE v1 COVERAGE-AUDIT HAS CONVERGED.** The audit that ran this session (2026-06-30 → 07-01)
|
||||
reached its scope-A done-definition: **every frozen Worldtree v1 I/O point is classified — covered
|
||||
or excluded-with-rationale, zero unaccounted.** Coverage: **REST 17/40 ✅ with ZERO in-scope gaps**
|
||||
@@ -85,8 +99,8 @@ findings, cross-model-verified); b2 + the later slices were offered but not revi
|
||||
runs dirty (auto-regen, not chased — never stage it). Contract-skip was invoked for the low-effort
|
||||
GET wrappers + `stream_admin_events`, but contract #2 / #1 / #6 were amended to stay canonical.
|
||||
|
||||
Branch: `main` — **in sync with `origin/main`** at **`v0.19.1`** (`af07a23`); the whole session's arc
|
||||
is pushed. Remote: `origin → git@gitea.phasefinal.com:vh/ratatoskr.git`.
|
||||
Branch: `main` — **in sync with `origin/main`** at **`v0.19.3`** (`75dec01`); the whole session's arc
|
||||
(web parity + review fixes) is pushed. Remote: `origin → git@gitea.phasefinal.com:vh/ratatoskr.git`.
|
||||
|
||||
## Recent decisions
|
||||
|
||||
@@ -155,6 +169,12 @@ decision. Captures rationale that won't be obvious from code alone.
|
||||
|
||||
- `[2026-07-01]` **Tier-2 SHIPPED (`v0.19.1`) — transient-characters CRUD + persona-state write; the v1 coverage-audit CONVERGES (zero in-scope gaps).** 5 wrappers in sessions.py: `list_character_models`/`create_character`/`get_character_state`/`delete_character` (#161, `character.read`/`.write` scopes) + `set_persona_state` (`POST /sessions/{id}/persona_state` — **FREEFORM body: unpinned in the frozen OpenAPI 2.2.0 + absent from the prose spec**, so the caller supplies the snapshot shape). Two one-shot CLI probes (mirror `--whoami`): `--characters` (models→create→get-state→delete lifecycle report) + `--set-persona-pad "p,a,d"` (requires `--session`; POSTs `{pad:[…]}`). New `ParsedArgs.characters`/`set_persona_pad` + probe-mode mutual-exclusion validation + `_probe_client` helper. Contract #2 amended (5 FNs, validated OK) + TDD (7 wrapper respx + 5 cli tests). Suite **573 green**; touched code ruff-clean. NOT live-proven (character scopes + the persona-write body shape unverified — the probes degrade gracefully on 403/422). **THE v1 COVERAGE-AUDIT HAS CONVERGED: REST 17/40 ✅ with ZERO in-scope gaps** (23 REST path-groups excluded-by-design + rationale), SSE 11/11, Bifrost provider planes 8/8. Scope-A "done" (every frozen I/O point classified, zero unaccounted) is **MET** — ratatoskr cuts v1 when Worldtree tags 1.0. Only not-consumed in-scope sub-method: `GET /agents/{id}` (consumer-agent lookup, manual-curl-only, on an already-✅ path group). Patch bump (Tier-2 tail; `v0.19.0` already published the core-complete milestone — a 2nd minor would be cadence-too-fast).
|
||||
|
||||
- `[2026-07-01]` **env.sh now PERSISTS the web Bifrost-bind vars (gitignored, local-only).** `ratatoskr-web`'s in-browser bind needs three server-held values; env.sh sources `provider.env` for the Heimdall key and exports `RATATOSKR_BIFROST_CONSUMER_KEY` + `RATATOSKR_PROVIDER_VISIBLE_HOST=10.100.10.50` + `RATATOSKR_AFFECT_READ_URL=:8392`. **The HS256 byte-match trap (re-hit + documented):** the bind's consumer key must equal the key the `:8392` combined provider validates against = `RATATOSKR_HEIMDALL_KEY` (provider.env, fp `45a0…`), NOT `WORLDTREE_API_KEY` (env.sh, fp `7c2f…`) — both are the SAME `ratatoskr` identity but DIFFERENT 40-char strings; signing with the wrong one → `bifrost.auth_rejected`. Single-sourced (env.sh sources provider.env) to avoid a rotation footgun; guarded with a stderr warning if provider.env is missing. [auto-memory: HS256-key-is-the-consumer-Heimdall-key-string]
|
||||
- `[2026-07-01]` **Tier-3 stores RESET (operator-directed).** `memory.db` (29 chunks + vectors + idempotency) + `affect.db` (5 PAD snapshots + idempotency) wiped to zero via a live `DELETE`+`wal_checkpoint` through the shared WAL (no provider restart — the 3 long-running providers see empty on next dispatch); consistent online-backup at `/tmp/ratatoskr-tier3-reset-<ts>/`. **Boundary for a COMPLETE Sindra wipe (mapped):** our stores = mine (done); the agent DEFINITION `ratatoskr:sindra` + its sessions = mine via the owner key (DELETE, no coordination); Worldtree's internal promotion/dedup shadow = needs worldtree-dev (no public reset API, survives our wipe → for a clean promotion smoke use a BRAND-NEW agent+end_user).
|
||||
- `[2026-07-01]` **Embedding-latency loop RESOLVED — it was WORLDTREE's, not ratatoskr (the consumer/provider thesis paid off again).** Vuong flagged dozens of embed queries/Tier-3 turn; worldtree-dev's first-pass blamed our memory_context chunk-batching. Traced CODE-SIDE that ratatoskr embeds ZERO times (provider `upsert_many` stores the given embedding, `search` takes a given vector, the conversation consumer POSTs only `{content}`, `/embed` is coverage-map-excluded — pure Bifrost/ADR-0009 path, WT does all embedding). worldtree-dev retracted + fixed on THEIR side (`v1.0.0b4`): a persona-recitation memory-gate re-embedding the stable character card sentence-by-sentence every turn (~95% of gateway traffic) → content-hash cache; re-embed ratio 15x→1.01x. **Lesson: verify your own code before accepting a peer's "it's your side" — the debug tool proving its own side clean is the whole point.**
|
||||
- `[2026-07-01]` **Web debug-surface parity SHIPPED (`v0.19.2`, `a0a9d5f`) — direct in-session TDD.** 3 proxy routes (tools/bifrost/admin-events) + admin-key wiring (entrypoint→create_app→app.state) + AdminEvents SSE proxy re-emitting under a FIXED `admin_event` name (one browser listener, no per-type drops) + session-filter `_admin_event_matches_web` (mirrors TUI §6). Frontend: 2 tabs (bifrost ⌃5, admin ⌃6) + tools-inventory folded into the tools pane. 9 respx tests (admin-bearer override, filter unit, SSE stream-filter); live-proven against sindra (bifrost connected, both caps). Contract-skip invoked (reuses already-contracted client wrappers); contract authored post-hoc as the trail (`docs/contracts/web_debug_surface.contract.md`).
|
||||
- `[2026-07-01]` **heid-code-review (`v0.19.3`, `75dec01`) — panel caught 2 real client-side SSE-lifecycle bugs TDD missed.** Contract-anchored (authored the web contract to enable it — no contract → no drift axis). Gróa/Hulda/Regin (artifact-only, Gróa under Landlock jail): ZERO functional server-side drift + INV-004 clean; 2 genuine drifts on the un-unit-tested SPA — (1) turn `es.onerror` didn't `hideThinkingNote()` (reasoning line + setInterval leak on a raw drop), (2) `openAdminEvents` never closed the EventSource on error → native auto-reconnect RETRY LOOP (fixed: close on `stream_error` + permanent `onerror`/CLOSED; transient CONNECTING still reconnects). + 2 test-gaps fixed (route-registration + admin stream_error). 1 precision → contract-clarified (tools-inventory names-only by design). **Re-confirms: the JS render/lifecycle paths are the review's highest-value target — unit tests don't reach them (same lesson as #18 D2).**
|
||||
|
||||
_41 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
|
||||
|
||||
_For per-issue TDD implementation notes, Volva findings, and contract amendments, see the git log — every per-issue commit carries a structured message capturing the trail._
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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) => `<div><span class="pk">${esc(k)}</span> <span class="pv">${esc(v)}</span></div>`;
|
||||
const head = (t) => `<div class="ph">${esc(t)}</div>`;
|
||||
const all = snap.valence || [];
|
||||
const shown = all.slice(0, 8); // bounded render — valence[] is unbounded in principle
|
||||
const valRows = shown.map((v) =>
|
||||
row(v.entity_id || "?",
|
||||
`familiarity ${JSON.stringify(v.familiarity)} · regard ${JSON.stringify(v.regard)}`
|
||||
+ ` · n=${JSON.stringify(v.interaction_count)}`)
|
||||
).join("");
|
||||
$("pane-persona").innerHTML =
|
||||
head("affect snapshot · " + (snap.agent_id || "?")) +
|
||||
`<div> </div>` + head("pad") +
|
||||
row("pleasure", JSON.stringify(snap.pad?.pleasure)) +
|
||||
row("arousal", JSON.stringify(snap.pad?.arousal)) +
|
||||
row("dominance", JSON.stringify(snap.pad?.dominance)) +
|
||||
`<div> </div>` + head("valence (" + all.length + ")") +
|
||||
(valRows || `<div class="empty">none</div>`) +
|
||||
`<div> </div>` + row("emitted_at", snap.emitted_at || "?");
|
||||
const num = (v) => (typeof v === "number") ? ((v >= 0 ? "+" : "") + v.toFixed(3)) : "—";
|
||||
const metric = (label, val, hist, desc, n) => {
|
||||
const d = trendDelta(hist);
|
||||
const dcls = d.startsWith("▲") ? "up" : d.startsWith("▼") ? "dn" : "";
|
||||
const nlab = (n !== undefined && n !== null) ? "n=" + esc(n) : "";
|
||||
return `<div class="mrow"><span class="mk">${esc(label)}</span>`
|
||||
+ `<span class="mv">${esc(num(val))}</span>`
|
||||
+ `<span class="md ${dcls}">${esc(d)}</span>`
|
||||
+ `<span class="msp">${esc(sparkline(hist))}</span>`
|
||||
+ `<span class="mn">${nlab}</span>`
|
||||
+ `<span class="mdesc">${esc(desc)}</span></div>`;
|
||||
};
|
||||
const pad = snap.pad || {}, H = AFFECT_HIST;
|
||||
let html = head("affect · " + (snap.agent_id || "?"))
|
||||
+ `<div class="mono-note">emitted ${esc((snap.emitted_at || "?").slice(11, 19))} · `
|
||||
+ `${H.at.length} sample${H.at.length === 1 ? "" : "s"} this session</div>`
|
||||
+ head("mood · PAD (transient, −1‥+1)")
|
||||
+ metric("pleasure", pad.pleasure, H.pad.pleasure, "feeling")
|
||||
+ metric("arousal", pad.arousal, H.pad.arousal, "activation")
|
||||
+ metric("dominance", pad.dominance, H.pad.dominance, "control");
|
||||
const rels = snap.relations || snap.valence || [];
|
||||
if (!rels.length) {
|
||||
html += head("relations") + `<div class="empty">no relations tracked yet — take a turn</div>`;
|
||||
}
|
||||
for (const rel of rels.slice(0, 8)) {
|
||||
const tgt = rel.target_entity || rel.entity_id || "?";
|
||||
const ctx = rel.relation_context ? " · stage: " + rel.relation_context : "";
|
||||
const b = H.rel[tgt] || {};
|
||||
html += head("relation → " + tgt + ctx)
|
||||
+ metric("trust·ability", _relVal(rel.trust_ability), b.trust_ability, "is-competent", _relN(rel.trust_ability))
|
||||
+ metric("trust·benevolence", _relVal(rel.trust_benevolence), b.trust_benevolence, "means-well", _relN(rel.trust_benevolence))
|
||||
+ metric("trust·integrity", _relVal(rel.trust_integrity), b.trust_integrity, "is-honest", _relN(rel.trust_integrity))
|
||||
+ metric("warmth", _relVal(rel.warmth ?? rel.familiarity), b.warmth, "affection", _relN(rel.warmth));
|
||||
const ag = _relVal(rel.agency), agn = _relN(rel.agency);
|
||||
if (typeof ag === "number" && agn) html += metric("agency", ag, null, "autonomy", agn);
|
||||
if (rel.obligation_balance !== null && rel.obligation_balance !== undefined) {
|
||||
html += `<div class="mrow"><span class="mk">obligation</span>`
|
||||
+ `<span class="mv">${esc(JSON.stringify(rel.obligation_balance))}</span></div>`;
|
||||
}
|
||||
}
|
||||
$("pane-persona").innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadAffect(agentId) {
|
||||
@@ -833,6 +925,7 @@ async function loadAffect(agentId) {
|
||||
const r = await fetch("/api/affect/" + encodeURIComponent(agentId));
|
||||
if (r.status === 200) {
|
||||
const snap = await r.json();
|
||||
pushAffectHistory(snap); // accumulate the per-value trend BEFORE rendering
|
||||
renderAffectPane(snap);
|
||||
setPersonaStrip(snap); // pad bars are the live signal
|
||||
state.lastAffectAt = snap.emitted_at || state.lastAffectAt; // post-turn poll stop-signal
|
||||
|
||||
Reference in New Issue
Block a user