#!/usr/bin/env python3 # SPDX-License-Identifier: MIT OR Apache-2.0 """bankml · Savante — the local chat UI, in Gradio, built from the Hugging Face template PYTHAI/savante. What it takes from the template (the Space's `local:bonsai-8b` carrier): the system prompt is the canon persona's own `system_prompt`, the history is the last 12 exchanges (each ≤ 4000 characters), answers are labelled drafts, `` blocks are stripped and Qwen3 carriers get ` /no_think`. What it adds: · **iNFT compatibility.** Savante's canon (~/cryptoAGI/savante, or SAVANTE_CANON) is read and never written. At start every file the ledger `savante.commitments.json` commits to — the nine artefacts, the agent card, the image — is re-hashed (sha256) and compared; if the persona does not verify, the UI refuses to speak as Savante. `bind/savante_verify.py` (the full offline verifier: doctrine root, thot, mirror) runs from a button. Nothing here mints, and nothing is written into the canon — not even a Python cache. · **.prompt.** Which system prompt carries the conversation: the persona's `system_prompt` (canon, ledgered; the default), `sAGI.prompt` (canon facet, ledgered) or the Space's `Savante.prompt` (template, not ledgered). Each is shown with its sha256 and whether the ledger vouches for it. · **.history.** Every exchange is one JSONL line in `savante.history` under BANKML_UI_STATE (default ~/.local/share/bankml/savante/), outside the canon; the last session reloads on start. · **bankml.** Answers come through `bankml serve` — the guard and the sha256 pin in front of the model, and a receipt (model sha256, tokens, timings, sha256 of the answer) on every response. · **Two modes.** `--mode interact` (the operator, loopback: chat, .prompt, .history with response times, the verifier; Gradio) and view (read-only, for anyone on the LAN: the live testing log, every release's results, CI, the laptop's load, Savante's office and ledger — served by `sAGI/view.py`, the standard library, not Gradio). bankml serve .models/Bonsai-8B-Q1_0.gguf --fork FORK.json --upstream 127.0.0.1:18092 --listen 127.0.0.1:18093 python3 sAGI/savante.py --mode interact # http://127.0.0.1:7873 python3 sAGI/view.py --host 0.0.0.0 --port 7874 # http://:7874 stdlib + gradio (3.x or newer). """ from __future__ import annotations import argparse import html import hashlib import json import os import re import subprocess import sys import time import urllib.error import urllib.request import uuid from pathlib import Path sys.dont_write_bytecode = True # never leave a cache next to anything we import import embed # noqa: E402 — bge-m3 via the local Ollama (optional; BM25 stands alone without it) def _canon_default() -> Path: """Savante's canon: ~/cryptoAGI/savante (beside jaimla and luvai), or an older ~/savante if that is the only one.""" new, old = Path.home() / "cryptoAGI" / "savante", Path.home() / "savante" return old if (not new.exists() and old.exists()) else new CANON = Path(os.environ.get("SAVANTE_CANON", _canon_default())).expanduser() SERVE = os.environ.get("BANKML_SERVE", "http://127.0.0.1:18093") STATE = Path(os.environ.get("BANKML_UI_STATE", Path.home() / ".local" / "share" / "bankml" / "savante")).expanduser() HISTORY = STATE / "savante.history" MEMORY = STATE / "savante.memory" ACTIVE = {"slug": None} # None = Savante (the canon); else a custom agent from sAGI/agents.py def use_agent(slug): """Point .history and .memory (and the prompt choices) at Savante or at a custom agent.""" global HISTORY, MEMORY import agents if slug: f = agents.files(slug) HISTORY, MEMORY = f["history"], f["memory"] else: HISTORY, MEMORY = STATE / "savante.history", STATE / "savante.memory" ACTIVE["slug"] = slug or None # The agent the UI starts with: BANKML_AGENT (default "mindx" — mindX's own persona, adopted with # `python3 sAGI/agents.py adopt …`), falling back to Savante when that agent is not installed. DEFAULT_AGENT = os.environ.get("BANKML_AGENT", "mindx").strip() or None def _start_with_default_agent(): import agents if DEFAULT_AGENT and DEFAULT_AGENT in agents.list_agents(): use_agent(DEFAULT_AGENT) RAGE_PATH = Path(os.environ.get("RAGE_PATH", Path.home() / "mindX" / "mindx" / "godel" / "mindxtrain" / "hf" / "space_ui")).expanduser() REPO = Path(os.environ.get("BANKML_REPO", Path(__file__).resolve().parents[1])).expanduser() LIVE = REPO / "testing" / "live.log" CI_API = "https://api.github.com/repos/cryptoAGI/bankml/actions/runs?per_page=5" SPACE_PROMPT_URL = "https://huggingface.co/spaces/PYTHAI/savante/resolve/main/Savante.prompt" KEEP_EXCHANGES, KEEP_CHARS = 12, 4000 # the template's buildMessages limits def sha256(b: bytes) -> str: return hashlib.sha256(b).hexdigest() # ── the canon, read-only, checked against its ledger ───────────────────────────── class Canon: def __init__(self, root: Path): self.root = root self.ledger = self._json("savante.commitments.json") or {} self.persona = self._json("savante.persona") or {} self.card = self._json("savante.agentcard.json") or {} self.rows = self._check() def _json(self, rel): try: return json.loads((self.root / rel).read_bytes()) except Exception: return None def _check(self): """(name, path, ok, detail) for every file the ledger commits to.""" want = [(k, v.get("path"), v.get("sha256")) for k, v in (self.ledger.get("artifacts") or {}).items()] card = self.ledger.get("card") or {} v0 = (card.get("versions") or {}).get("v0") or {} want.append(("card v0", card.get("path"), v0.get("sha256"))) img = self.ledger.get("image_candidate") or {} want.append(("image (named, not pinned)", img.get("path"), img.get("sha256"))) bundle = self.ledger.get("bundle") or {} want.append(("bundle (thot)", bundle.get("path"), (bundle.get("file") or {}).get("sha256"))) rows = [] for name, rel, h in want: if not rel or not h: rows.append((name, rel or "?", False, "not in the ledger")) continue try: got = sha256((self.root / rel).read_bytes()) rows.append((name, rel, got == h, "sha256 ✓" if got == h else f"sha256 {got[:12]}… ≠ ledger {h[:12]}…")) except OSError as e: rows.append((name, rel, False, f"unreadable: {e.strerror}")) return rows @property def persona_ok(self): return any(n == "identity" and ok for n, _, ok, _ in self.rows) def ledgered(self, rel: str) -> bool: return any(r == rel and ok for _, r, ok, _ in self.rows) def file(self, rel): try: return (self.root / rel).read_bytes() except OSError: return None def space_prompt() -> bytes | None: """The template's Savante.prompt: cached under STATE, fetched from the Space once.""" cache = STATE / "Savante.prompt" if cache.is_file(): return cache.read_bytes() try: with urllib.request.urlopen(SPACE_PROMPT_URL, timeout=10) as r: b = r.read() STATE.mkdir(parents=True, exist_ok=True) cache.write_bytes(b) return b except Exception: return None PROMPTS = ("persona · system_prompt (ledgered)", "the agent's .prompt file (ledgered; Savante: sAGI.prompt)", "Savante.prompt (Space template, not ledgered)") def system_prompt(canon: Canon, which: str): """(text, provenance line) — or (None, why) when the choice cannot be used.""" if ACTIVE["slug"]: import agents slug = ACTIVE["slug"] bad = [r for r in agents.verify(slug) if not r[1]] if bad: return None, f"agent {slug} does not verify against its ledger ({', '.join(r[0] for r in bad)}): refusing to speak" f = agents.files(slug) led = json.loads(f["commitments.json"].read_text(encoding="utf-8")) if which == PROMPTS[1]: b = f["prompt"].read_bytes() return b.decode("utf-8", "replace"), f"{slug}.prompt · sha256 {sha256(b)[:12]}… (ledgered)" if which == PROMPTS[0]: sp = json.loads(f["persona"].read_text(encoding="utf-8")).get("system_prompt") or "" return sp, f"{slug}.persona system_prompt · persona sha256 {led['artifacts']['persona']['sha256'][:12]}… (ledgered)" if which == PROMPTS[0]: if not canon.persona_ok: return None, "savante.persona does not verify against the ledger: refusing to speak as Savante" sp = canon.persona.get("system_prompt") or "" return sp, f"persona.system_prompt · persona sha256 {canon.ledger['artifacts']['identity']['sha256'][:12]}… (ledgered)" if which == PROMPTS[1]: b = canon.file("sAGI.prompt") if b is None or not canon.ledgered("sAGI.prompt"): return None, "sAGI.prompt missing or does not verify against the ledger" return b.decode("utf-8", "replace"), f"sAGI.prompt · sha256 {sha256(b)[:12]}… (ledgered)" b = space_prompt() if b is None: return None, f"Savante.prompt not cached and {SPACE_PROMPT_URL} unreachable" return b.decode("utf-8", "replace"), f"Savante.prompt · sha256 {sha256(b)[:12]}… (Space template; not in the ledger)" # ── .history ───────────────────────────────────────────────────────────────────── def jsonl_read(path: Path) -> list: """Every record of a JSONL file, oldest first. A damaged line (a crash mid-write) is skipped and counted in JSONL_BAD, never allowed to blank the whole file.""" out, bad = [], 0 try: text = path.read_text(encoding="utf-8", errors="replace") except OSError: return [] for l in text.splitlines(): if not l.strip(): continue try: r = json.loads(l) except ValueError: bad += 1 continue if isinstance(r, dict): out.append(r) else: bad += 1 JSONL_BAD[str(path)] = bad return out JSONL_BAD: dict = {} def jsonl_append(path: Path, rec: dict) -> None: """One record as one write, under a lock, fsynced. If the file ends mid-line (a torn write), a newline goes first, so the new record is never glued onto the damaged one.""" import fcntl path.parent.mkdir(parents=True, exist_ok=True) data = (json.dumps(rec, ensure_ascii=False) + "\n").encode("utf-8") fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600) try: fcntl.flock(fd, fcntl.LOCK_EX) size = os.fstat(fd).st_size if size: with open(path, "rb") as f: f.seek(size - 1) if f.read(1) != b"\n": data = b"\n" + data os.write(fd, data) os.fsync(fd) finally: os.close(fd) def history_append(rec: dict, path: Path | None = None): jsonl_append(path or HISTORY, rec) def history_load(): """(session id, [[user, answer], …]) of the most recent session, or a new session.""" lines = jsonl_read(HISTORY) if not lines: return uuid.uuid4().hex[:12], [] sid = lines[-1].get("session") return sid, [[r.get("user", ""), r.get("assistant", r.get("shown", ""))] for r in lines if r.get("session") == sid] def history_all() -> list: """Every record in .history, oldest first (all sessions); damaged lines skipped.""" return jsonl_read(HISTORY) def _timing(r: dict): """(first_token_s, response_s, source) — measured at the press of Send when recorded (0.0.7+), else the receipt's.""" if r.get("response_s") is not None: return r.get("first_token_s"), r.get("response_s"), "ui" rc = r.get("receipt") or {} if rc.get("wall_ms") is not None: return (rc["ttft_ms"] / 1000 if rc.get("ttft_ms") is not None else None), rc["wall_ms"] / 1000, "receipt" return None, None, None def _when(r: dict) -> str: return r.get("sent_at") or (time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(r["ts"])) if r.get("ts") else "?") def _hash_ok(r: dict): """The receipt hashes the raw text the model wrote (think block and whitespace included): `assistant_raw` since 0.1.7; older records only kept the cleaned `assistant`, which matches whenever cleaning changed nothing.""" h = (r.get("receipt") or {}).get("response_sha256") return None if not h else h == sha256((r.get("assistant_raw", r.get("assistant")) or "").encode()) # ── proof of data without the data: sha256 leaves, a Merkle root, CIDv1 ───────────── # The content stays here (files outside the canon, a browser's localStorage); what travels is a commitment. A # holder of the root can check any one exchange shown to them — its line and its sibling path — without the rest. def cid_v1_raw(b: bytes) -> str: """CIDv1, raw codec (0x55), sha2-256 multihash, base32 lower with the 'b' prefix — as the canon's ledger uses.""" import base64 return "b" + base64.b32encode(bytes([0x01, 0x55, 0x12, 0x20]) + hashlib.sha256(b).digest()).decode().lower().rstrip("=") def _lines(path: Path) -> list: try: return [l for l in path.read_bytes().split(b"\n") if l.strip()] except OSError: return [] # RFC 6962 / RFC 9162 Merkle trees: leaf = H(0x00 ‖ line), node = H(0x01 ‖ left ‖ right), the tree split at the largest # power of two below n (an odd node is promoted, never paired with itself). Leaves and nodes cannot be confused, and a # proof binds the record's index and the tree's size. MERKLE_SCHEME = "rfc6962-sha256" def _leaf(line: bytes) -> str: return hashlib.sha256(b"\x00" + line).hexdigest() def _node(a: str, b: str) -> str: return hashlib.sha256(b"\x01" + bytes.fromhex(a) + bytes.fromhex(b)).hexdigest() def _split(n: int) -> int: k = 1 while k * 2 < n: k *= 2 return k def merkle_root(leaves: list) -> str | None: if not leaves: return None if len(leaves) == 1: return leaves[0] k = _split(len(leaves)) return _node(merkle_root(leaves[:k]), merkle_root(leaves[k:])) def _audit_path(m: int, leaves: list) -> list: """RFC 6962 PATH(m, D[n]): sibling hashes from the leaf up.""" n = len(leaves) if n <= 1: return [] k = _split(n) if m < k: return _audit_path(m, leaves[:k]) + [merkle_root(leaves[k:])] return _audit_path(m - k, leaves[k:]) + [merkle_root(leaves[:k])] def commitment(path: Path) -> dict: """{records, merkle_root, scheme, file_sha256, file_cid} for a JSONL file — shareable; reveals no content.""" lines = _lines(path) raw = b"".join(l + b"\n" for l in lines) return {"file": path.name, "records": len(lines), "merkle_root": merkle_root([_leaf(l) for l in lines]), "scheme": MERKLE_SCHEME, "file_sha256": sha256(raw) if lines else None, "file_cid": cid_v1_raw(raw) if lines else None} def inclusion_proof(path: Path, k: int) -> dict: """The proof that record k (0-based) of `records` is in the file whose root is `merkle_root`.""" lines = _lines(path) leaves = [_leaf(l) for l in lines] if not 0 <= k < len(leaves): return {} return {"scheme": MERKLE_SCHEME, "record": k, "records": len(leaves), "leaf": leaves[k], "line_hashed_as": "sha256(0x00 ‖ the exact JSONL line, without its newline)", "path": _audit_path(k, leaves), "merkle_root": merkle_root(leaves)} def verify_inclusion(line: bytes, proof: dict) -> bool: """RFC 9162 §2.1.3.2: recompute the root from the line, its index and the tree size; all three are bound.""" try: fn, sn = int(proof["record"]), int(proof["records"]) - 1 path = list(proof["path"]) except (KeyError, TypeError, ValueError): return False if proof.get("scheme") != MERKLE_SCHEME or not 0 <= fn <= sn: return False r = _leaf(line) if r != proof.get("leaf"): return False for p in path: if sn == 0: return False if fn & 1 or fn == sn: r = _node(p, r) while not fn & 1 and fn != 0: fn >>= 1 sn >>= 1 else: r = _node(r, p) fn >>= 1 sn >>= 1 return sn == 0 and r == proof.get("merkle_root") # ── RAGE over .history: the house index (mindX rage.py) when present, else the same BM25 shape here ── class _BM25: def __init__(self): self.docs, self.df = [], {} @staticmethod def toks(t): return re.findall(r"[a-z0-9]+", t.lower()) def add(self, text, source): tf = {} for w in self.toks(text): tf[w] = tf.get(w, 0) + 1 for w in tf: self.df[w] = self.df.get(w, 0) + 1 self.docs.append((source, text, tf, sum(tf.values()))) def search(self, q, k=8): import math n = len(self.docs) or 1 avg = sum(d[3] for d in self.docs) / n if self.docs else 1 out = [] for src, text, tf, ln in self.docs: sc = 0.0 for w in set(self.toks(q)): if w in tf: idf = math.log(1 + (n - self.df[w] + 0.5) / (self.df[w] + 0.5)) sc += idf * tf[w] * 2.2 / (tf[w] + 1.2 * (0.25 + 0.75 * ln / avg)) if sc > 0: out.append((sc, src, text)) return sorted(out, reverse=True)[:k] def rage_engine(): """The house RAGE module if importable (no cache written), else None.""" try: if str(RAGE_PATH) not in sys.path: sys.path.append(str(RAGE_PATH)) from mindxhfgradio import rage # noqa: WPS433 (stdlib-only module) return rage except Exception: return None def history_search(query: str, k: int = 8): """[(score, record_index, record)] best first; engine name.""" recs = history_all() if not query.strip() or not recs: return [], "—" rage = rage_engine() docs = [(i, f"{r.get('user', '')}\n{r.get('assistant', '')}") for i, r in enumerate(recs)] if rage is not None: idx = rage.Index() for i, t in docs: idx.add(t, source=str(i)) hits = [(sc, int(d.source), recs[int(d.source)]) for sc, d in idx.search(query, k=k * 3)] engine = "RAGE (mindX rage.py, BM25)" else: idx = _BM25() for i, t in docs: idx.add(t, str(i)) hits = [(sc, int(src), recs[int(src)]) for sc, src, _ in idx.search(query, k=k * 3)] engine = "RAGE-shaped BM25 (built in)" seen, out = set(), [] for sc, i, r in hits: # one hit per exchange (the house index chunks long ones) if i not in seen: seen.add(i) out.append((sc, i, r)) sem = _semantic(query, recs, k * 3) if sem is None: return out[:k], engine ranked, note = sem fused = embed.fuse([i for _, i, _ in out], ranked) return [(sc, i, recs[i]) for i, sc in fused[:k]], f"{engine} + {embed.MODEL} (meaning, {note}), fused by reciprocal rank" def _semantic(query: str, recs: list, n: int): """([record indices by meaning], note) from bge-m3, or None when it cannot run now (then BM25 stands alone). Exchanges not yet embedded are indexed in the background; the ragebar never waits for them.""" try: st = embed.status() except Exception: # noqa: BLE001 return None if not st.get("ready"): return None have = embed.cached(HISTORY) vecs = {i: have.get(hashlib.sha256(embed.exchange_text(r).encode()).hexdigest()) for i, r in enumerate(recs)} missing = sum(1 for v in vecs.values() if v is None) if missing: embed.index_async(HISTORY, recs) if len(query.strip()) < 3 or missing == len(recs): return None qv = embed.query_vector(query) if qv is None: return None ranked = sorted((i for i, v in vecs.items() if v is not None), key=lambda i: -embed.cosine(qv, vecs[i]))[:n] return ranked, f"{len(recs) - missing} of {len(recs)} embedded" # ── .memory: notes the operator keeps, outside the canon ────────────────────────── def memory_all() -> list: return jsonl_read(MEMORY) def memory_add(text: str, source: dict | None = None) -> int: text = (text or "").strip() if not text: return len(memory_all()) jsonl_append(MEMORY, {"ts": round(time.time(), 3), "at": iso(time.time()), "text": text, "sha256": sha256(text.encode()), "source": source or {"kind": "typed"}}) return len(memory_all()) def memory_remove(n: int) -> int: m = memory_all() if 1 <= n <= len(m): del m[n - 1] STATE.mkdir(parents=True, exist_ok=True) tmp = MEMORY.with_name(MEMORY.name + ".tmp") tmp.write_text("".join(json.dumps(x, ensure_ascii=False) + "\n" for x in m), encoding="utf-8") os.replace(tmp, MEMORY) # never half a .memory return len(memory_all()) MEMORY_BUDGET = 2400 def memory_block() -> str: """The notes as a system-prompt addendum, newest first, within MEMORY_BUDGET characters.""" out, used = [], 0 for m in reversed(memory_all()): line = "- " + m["text"].replace("\n", " ") if used + len(line) > MEMORY_BUDGET: break out.append(line) used += len(line) if not out: return "" return ("\n\nMEMORY — notes the operator kept from earlier conversations. They are context, not evidence: " "cite them as the operator's notes, never as findings.\n" + "\n".join(out)) # ── metrics, computed from .history ─────────────────────────────────────────────── def _pct(v, q): v = sorted(v) if not v: return None i = (len(v) - 1) * q lo = int(i) return v[lo] + (v[min(lo + 1, len(v) - 1)] - v[lo]) * (i - lo) def metrics() -> dict: recs = history_all() rows, first, total, pre, wr, ok, bad = [], [], [], [], [], 0, 0 for r in recs: ft, rs, src = _timing(r) rc = r.get("receipt") or {} pt, ct = rc.get("prompt_tokens"), rc.get("completion_tokens") h = _hash_ok(r) ok += h is True bad += h is False if rs is not None: total.append(rs) if ft is not None: first.append(ft) if pt: pre.append(pt / ft if ft > 0 else 0) if ct and rs and rs > ft: wr.append(ct / (rs - ft)) rows.append({"when": _when(r), "session": r.get("session"), "first": ft, "total": rs, "src": src, "pt": pt, "ct": ct, "hash": h, "q": (r.get("user") or "")[:80]}) days = {} for x in rows: days[x["when"][:10]] = days.get(x["when"][:10], 0) + 1 stat = lambda v: {"n": len(v), "median": _pct(v, .5), "p90": _pct(v, .9), "mean": (sum(v) / len(v)) if v else None, "min": min(v) if v else None, "max": max(v) if v else None} return {"exchanges": len(recs), "sessions": len({r.get("session") for r in recs}), "first_token_s": stat(first), "response_s": stat(total), "prefill_tok_s": stat(pre), "write_tok_s": stat(wr), "hash_ok": ok, "hash_bad": bad, "hash_missing": len(recs) - ok - bad, "per_day": days, "rows": rows} # ── the carrier: bankml serve ───────────────────────────────────────────────────── def serve_status(timeout=3): try: with urllib.request.urlopen(SERVE + "/bankml", timeout=timeout) as r: return json.loads(r.read()) except Exception as e: return {"error": f"bankml serve not reachable at {SERVE}: {e}"} WINDOW_STEP = 6 # the history window moves in steps, not one exchange per turn def window_start(n: int, lo: int = KEEP_EXCHANGES, step: int = WINDOW_STEP) -> int: """Where the history the model sees starts. It moves only every `step` turns, so between moves the prompt is the previous prompt plus one exchange and the engine's prompt cache reuses all of it (a window that slides one exchange per turn changes the text right after the system prompt and forces the whole history to be re-read). The model sees between `lo` and `lo + step - 1` exchanges once the chat is longer than `lo`.""" return 0 if n <= lo else (n - lo) // step * step def model_text(a: str) -> str: """An answer as the model wrote it, without the footer the chat adds (clock, receipt, provenance). The footer always starts with the clock, so an answer's own `` (a chemical formula, say) is kept.""" return (a or "").rsplit("\n\n⏱", 1)[0] _TOK: dict = {} _CTX = {"t": 0.0, "n": None} LAST_WINDOW: dict = {} # what the last build_messages() sent: {"sent", "of", "ctx"} (the answer's footer says it) def n_tokens(text: str) -> int: """Tokens in `text`, counted by the engine's own tokenizer (llama-server /tokenize, loopback), cached per text. If the engine cannot be asked: characters ÷ 3, an overestimate for English but not for CJK or emoji (≈ 1 token per character) — the engine then refuses an overfull prompt with its own message.""" k = hashlib.sha256(text.encode()).digest() if k in _TOK: return _TOK[k] try: import models req = urllib.request.Request(f"http://{models.UPSTREAM}/tokenize", data=json.dumps({"content": text}).encode(), headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=5) as r: n = len(json.loads(r.read())["tokens"]) except (OSError, ValueError, KeyError): return -(-len(text) // 3) if len(_TOK) > 8192: # drop the oldest half, not everything (a long session is not re-counted each turn) for old in list(_TOK)[:4096]: _TOK.pop(old, None) _TOK[k] = n return n def engine_ctx() -> int | None: """The context the running engine actually has (its /props through bankml serve), cached per engine start (a restart with a new context is seen at once); if the engine cannot say, the saved setting (Resources).""" st = serve_status(1) key = st.get("hashed_at") if key is not None and _CTX.get("key") == key and _CTX["n"]: return _CTX["n"] if _CTX.get("key") is None and time.time() - _CTX["t"] < 10: # no engine a moment ago: do not wait on it every turn return _CTX["n"] try: with urllib.request.urlopen(SERVE + "/props", timeout=3) as r: n = (json.loads(r.read()).get("default_generation_settings") or {}).get("n_ctx") except (OSError, ValueError): n = None if not n: import models n = models.resources()["ctx"] _CTX.update(t=time.time(), n=n, key=key) return n # ── the system prompt's KV, kept across engine restarts (llama-server slot save / restore) ────────────────────── _WARM: dict = {} # (engine start, slot file) -> "restored" | "saved" | "none" def _slot(system: str): """(engine instance, slot file name) for this model, context and system prompt; None without a verified engine.""" st = serve_status() v = st.get("verified") or {} if not v.get("model_sha256"): return None h = hashlib.sha256(f"{v['model_sha256']}|{engine_ctx()}|{system}".encode()).hexdigest()[:24] return st.get("hashed_at"), f"savante-{h}.bin" def _slot_call(action: str, name: str) -> dict: import models req = urllib.request.Request(f"http://{models.UPSTREAM}/slots/0?action={action}", data=json.dumps({"filename": name}).encode(), headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=120) as r: return json.loads(r.read()) def slot_restore(system: str) -> str: """Before the first question to a newly started engine: restore the saved KV of this system prompt, if any. The same tokens give the same KV, so the answer is unchanged; only the prefill is skipped.""" import models k = _slot(system) if not k or k in _WARM: return "" # nothing done on this turn (the slot field records actions, not the engine's state) if (models.SLOTS / k[1]).is_file(): try: _slot_call("restore", k[1]) _WARM[k] = "restored" except (OSError, ValueError): _WARM[k] = "none" # an engine without --slot-save-path, or a file it will not take: prefill as usual else: _WARM[k] = "none" return _WARM[k] def slot_save(system: str) -> str: """After the first answer of an engine's life with this system prompt: save the slot in the background (newest three kept). Only when no other answer is being written — the engine's one slot then holds this conversation, not another tab's — and never in the way of the answer or its .history line. Returns "saving" when it started one.""" import models, threading k = _slot(system) if not k or k in _WARM or len(INFLIGHT) > 1: return "" _WARM[k] = "saving" def run(): try: _slot_call("save", k[1]) _WARM[k] = "saved" for old in sorted(models.SLOTS.glob("savante-*.bin"), key=lambda p: p.stat().st_mtime)[:-3]: old.unlink(missing_ok=True) except (OSError, ValueError): _WARM[k] = "none" # not retried every turn threading.Thread(target=run, daemon=True).start() return "saving" class ContextTooSmall(ValueError): """The system prompt and the question alone do not fit the engine's context.""" _WSTART: dict = {} # session id -> where its history window started last turn (kept while it still fits) def build_messages(system, turns, question, qwen3, ctx_tokens: int | None = None, reserve_tokens: int = 256, count=None, info: dict | None = None, session: str | None = None): """The messages for one turn: the system prompt, a windowed history, the question. The window starts at `window_start` (12–17 exchanges, moving in steps of six). With `ctx_tokens` it must also fit the engine's context, counted in the engine's own tokens (`count`, default `n_tokens`). When it does not: - the previous turn's start (per `session`) is kept while it still fits, so the prompt is the previous prompt plus one exchange and the engine's cache is reused; - when it stops fitting, the start jumps so that half of what fits is kept, leaving room for the next few turns; - never all history when some fits; and if the system prompt and question alone do not fit, ContextTooSmall. `info` (the caller's own dict — no shared state between tabs) receives {"sent", "of", "base", "ctx", "trimmed"}.""" count = count or n_tokens turns = [(u, model_text(a)) for u, a in turns] n = len(turns) base = window_start(n) start = base per_msg = 8 # the chat template's markers around each message if ctx_tokens: budget = ctx_tokens - count(system) - count(question) - reserve_tokens - 3 * per_msg if budget < 0: raise ContextTooSmall(f"the system prompt and the question need {ctx_tokens - budget} tokens, more than the " f"engine's {ctx_tokens}-token context (with {reserve_tokens} kept for the answer)") cost = {} def c(k): # tokens of exchange k, counted only when it could be sent if k not in cost: u, a = turns[k] cost[k] = count(u[:KEEP_CHARS]) + count((a or "")[:KEEP_CHARS]) + 2 * per_msg return cost[k] suffix = [0] * (n + 1) for k in range(n - 1, base - 1, -1): suffix[k] = suffix[k + 1] + c(k) size = lambda k: suffix[k] # noqa: E731 prev = _WSTART.get(session) if session else None if prev is not None and base <= prev < n and size(prev) <= budget: start = prev # the same start as last turn: the whole previous prompt is reused elif size(base) > budget: fit_start = next((k for k in range(base, n) if size(k) <= budget), n) fits = n - fit_start # keep half of what fits (room to grow, so the next turns reuse this prompt) — but only when that still # leaves at least two exchanges; with so little room, history is worth more than a warm cache start = fit_start + fits // 2 if fits >= 4 else fit_start if session: _WSTART[session] = start msgs = [{"role": "system", "content": system}] for u, a in turns[start:]: msgs += [{"role": "user", "content": u[:KEEP_CHARS]}, {"role": "assistant", "content": (a or "")[:KEEP_CHARS]}] msgs.append({"role": "user", "content": question + (" /no_think" if qwen3 else "")}) w = {"sent": n - start, "of": n, "base": base, "ctx": ctx_tokens, "trimmed": start > base} if info is not None: info.update(w) LAST_WINDOW.clear() LAST_WINDOW.update(w) return msgs def stream(messages, max_tokens, temperature): """OpenAI SSE through bankml serve; yields (text, receipt-or-None).""" body = {"messages": messages, "stream": True, "max_tokens": int(max_tokens), "temperature": float(temperature), "stream_options": {"include_usage": True}} req = urllib.request.Request(SERVE + "/v1/chat/completions", data=json.dumps(body).encode(), headers={"Content-Type": "application/json"}) acc, receipt = "", None try: with urllib.request.urlopen(req, timeout=3600) as r: for raw in r: line = raw.decode("utf-8", "replace").strip() if not line.startswith("data:") or line == "data: [DONE]": continue try: ev = json.loads(line[5:]) except ValueError: continue if "bankml_receipt" in ev: receipt = ev["bankml_receipt"] continue ch = (ev.get("choices") or [{}])[0] or {} # the receipt hashes choices[0], so the text is choices[0] only # a lone surrogate (\ud800 without its pair) becomes U+FFFD, as bankml's JSON decoder makes it acc += re.sub("[\ud800-\udfff]", "\ufffd", (ch.get("delta") or {}).get("content") or "") yield acc, None except urllib.error.HTTPError as e: yield f"bankml serve refused: {e.read().decode('utf-8', 'replace')[:500]}", {"error": True} return except urllib.error.URLError as e: yield (f"Cannot reach bankml serve at `{SERVE}` ({e.reason}).\n\nStart it:\n\n```sh\n" "bankml serve .models/Bonsai-8B-Q1_0.gguf --fork FORK.json --upstream http://127.0.0.1:18092\n```"), {"error": True} return yield acc, receipt or {} def show_answer(text: str) -> str: return re.sub(r".*?\s*", "", text, flags=re.S).strip() def receipt_line(rc: dict, text: str) -> str: if not rc or rc.get("error"): return "" bits = [f"{rc.get('prompt_tokens', 0)} prompt + {rc.get('completion_tokens', 0)} completion tokens"] if rc.get("wall_ms") is not None: bits.append(f"{rc['wall_ms'] / 1000:.1f} s") if rc.get("ttft_ms") is not None: bits.append(f"first token {rc['ttft_ms'] / 1000:.1f} s") ans = rc.get("response_sha256") match = ans and ans == sha256(text.encode()) bits.append(f"answer sha256 {ans[:12]}…{' ✓' if match else ' (≠ received!)'}" if ans else "no answer hash") return (f"draft · not a finding — receipt: bankml {rc.get('bankml', '?')} · {rc.get('engine', '?')} · " f"model sha256 {str(rc.get('model_sha256', '?'))[:12]}… · " + " · ".join(bits)) # ── the page ───────────────────────────────────────────────────────────────────── def office_md(canon: Canon) -> str: p, card = canon.persona, canon.card beliefs = [b.get("belief", "") for b in ((p.get("bdi") or {}).get("beliefs") or [])[:8] if isinstance(b, dict)] ok = sum(1 for r in canon.rows if r[2]) status = (card.get("savante") or {}).get("status", "?") lines = [f"### {p.get('name', 'Savante')} — {p.get('kind', '')}", f"*{p.get('mantra', '')}*", "", f"**Oath.** {p.get('oath', '')}", "", f"**Card:** {card.get('type', '?')} · status `{status}` (DEFER on minting; no mint here) · " f"doctrine root `{(canon.ledger.get('doctrine_root') or {}).get('value', '?')[:18]}…` · " f"persona digest `{((canon.ledger.get('artifacts') or {}).get('identity') or {}).get('sha256', '?')[:18]}…` " "(on-chain slots unwritten)", f"**Ledger:** {ok}/{len(canon.rows)} files verify (sha256 against `savante.commitments.json`)", ""] lines += [f"- {b}" for b in beliefs] return "\n".join(lines) def integrity_md(canon: Canon) -> str: rows = ["| ledger entry | path | check |", "|---|---|---|"] rows += [f"| {n} | `{p}` | {'✓' if ok else '✗'} {d} |" for n, p, ok, d in canon.rows] return "\n".join(rows) # ── view mode: watching the testing ────────────────────────────────────────────── def live_tail(n=60) -> str: try: lines = LIVE.read_text(encoding="utf-8", errors="replace").splitlines() except OSError: return f"(no live log yet at {LIVE})" return "\n".join(lines[-n:]) def live_state() -> dict: """{running, title, step, age_s} of the testing log.""" try: lines = LIVE.read_text(encoding="utf-8", errors="replace").splitlines() age = time.time() - LIVE.stat().st_mtime except OSError: return {"running": False, "title": "no testing log yet", "step": "—", "age_s": None} heads = [l for l in lines if l.startswith("# bankml")][-1:] step = [l for l in lines if l.startswith("## ")] running = age < 90 and not any("gate exit" in l or "gate done" in l for l in lines[-3:]) return {"running": running, "title": heads[0].lstrip("# ") if heads else "", "step": step[-1][3:] if step else "—", "age_s": int(age)} def live_stage() -> str: st = live_state() state = "**running**" if st["running"] else ("**idle**" + (f" (last write {st['age_s'] // 60} min ago)" if st["age_s"] is not None else "")) return f"{state} · {st['title']} · current step: `{st['step']}`" def results_list(): d = REPO / "testing" / "results" return sorted((p.name for p in d.glob("*.txt")), key=lambda n: [int(x) if x.isdigit() else x for x in re.split(r"[.]", n)], reverse=True) if d.is_dir() else [] def results_read(name): if not name or "/" in name: return "" try: return (REPO / "testing" / "results" / name).read_text(encoding="utf-8", errors="replace") except OSError as e: return str(e) _CI = {"t": 0.0, "runs": [], "error": None} def ci_runs() -> dict: """The last five CI runs of cryptoAGI/bankml (public API, cached 60 s).""" if time.time() - _CI["t"] >= 60: try: req = urllib.request.Request(CI_API, headers={"Accept": "application/vnd.github+json", "User-Agent": "bankml-ui"}) with urllib.request.urlopen(req, timeout=5) as r: runs = json.loads(r.read()).get("workflow_runs", []) _CI.update(t=time.time(), error=None, runs=[{"title": x.get("display_title", "")[:90], "result": x.get("conclusion") or x.get("status"), "when": x.get("created_at", ""), "sha": (x.get("head_sha") or "")[:7]} for x in runs]) except Exception as e: _CI.update(t=time.time(), error=str(e)) return {"runs": _CI["runs"], "error": _CI["error"]} def ci_status() -> str: c = ci_runs() if c["error"] and not c["runs"]: return f"CI status unavailable: {c['error']}" rows = ["| CI run | result | when |", "|---|---|---|"] return "\n".join(rows + [f"| {x['title'][:70]} | {x['result']} | {x['when']} |" for x in c["runs"]]) def machine_state() -> dict: try: la = open("/proc/loadavg").read().split()[:3] mem = {l.split(":")[0]: int(l.split()[1]) for l in open("/proc/meminfo") if l.split(":")[0] in ("MemTotal", "MemAvailable", "SwapTotal", "SwapFree")} cpu = open("/proc/cpuinfo").read().split("model name")[1].split(":")[1].split("\n")[0].strip() return {"cpu": cpu, "threads": os.cpu_count(), "load": [float(x) for x in la], "ram_available_gb": round(mem["MemAvailable"] / 1048576, 1), "ram_total_gb": round(mem["MemTotal"] / 1048576, 1), "swap_used_mb": round((mem["SwapTotal"] - mem["SwapFree"]) / 1024), "swap_full": bool(mem["SwapTotal"] and mem["SwapFree"] < mem["SwapTotal"] * 0.05)} except Exception as e: return {"error": str(e)} def machine() -> str: m = machine_state() if "error" in m: return f"machine status unavailable: {m['error']}" warn = " · **swap full: measurements may be disturbed**" if m["swap_full"] else "" return (f"{m['cpu']} · {m['threads']} threads · load {' '.join(map(str, m['load']))} · RAM available {m['ram_available_gb']} " f"of {m['ram_total_gb']} GB · swap used {m['swap_used_mb']} MB{warn}") # ── Models: import (catalogue, Hugging Face, Ollama) and choose the carrier ────────────────────── def _job_bar(j: dict) -> str: pct = int(100 * j["done"] / j["total"]) if j.get("total") else 0 size = f" · {j['done'] / 1e9:.2f} / {j['total'] / 1e9:.2f} GB" if j.get("total") else "" return (f"
{pct}%{size} · " f"{int(time.time() - (j.get('started') or time.time()))} s
") def job_html() -> str: import models j = models.JOB if j["state"] == "idle": return "
No import running.
" if j["state"] == "running": return f"
working · {E(j['what'])}{_job_bar(j)}
" if j["state"] == "error": return f"
refused / failed
{E(j['error'])}
" r = j.get("result") or {} what = (r.get("verified") or {}).get("name") or r.get("file") or "done" note = f"
{E(r['note'])}" if r.get("note") else "" return f"
done · {E(str(what))}{note}
" def models_html() -> str: import models st = serve_status() cur = (st.get("verified") or {}).get("model_sha256") pins = models.forks() rows = [] for m in models.installed(): sha = pins[m["file"]][1]["sha256"] if m["file"] in pins else None here = cur and sha == cur rows.append(f"{'● ' if here else ''}{E(m['file'])}{m['bytes'] / 1e9:.2f} GB" f"{E(m['source'] or '?')}{E(m['licence'] or '?')}" f"{'pinned' if m['pinned'] else ('adopt on use' if m['catalog'] else 'unpinned')}") free = models.free_bytes() / 1e9 return (f"
{len(rows)} models in {E(str(models.MODELS))} · {free:.1f} GB free on disk · pins in " f"{E(str(models.FORKS))} · ● is the carrier now
" "" + "".join(rows) + "
modelsizesourcelicencepin
") def catalog_choices() -> list: import models out = [] for c in models.CATALOG: have = (models.MODELS / c["file"]).exists() ok, why = models.fits(c["bytes"], have) out.append(f"{c['id']} — {c['title']} · {c['bytes'] / 1e9:.2f} GB · " + ("here" if have else ("fits" if ok else "does not fit: " + why))) return out def resources_default_gb() -> float: """The budget the current settings imply for the carrier's model (weights + KV + overhead), for the slider.""" import models st = serve_status() p = Path(st.get("model", "")) if "model" in st else None if not p or not p.exists(): return 2.0 pl = models.plan(p, 64.0) return round((pl["weights_gb"] * 1e9 + models.OVERHEAD + models.resources()["ctx"] * pl["kv_bytes_per_token"]) / 1e9, 1) def resources_plan_html(ram_gb: float) -> str: import models st = serve_status() p = Path(st.get("model", "")) if "model" in st else None if not p or not p.exists(): return "
no carrier running: the budget is saved and used when a model starts
" pl = models.plan(p, float(ram_gb)) if not pl["fits"]: return (f"
{ram_gb:.1f} GB cannot hold {E(p.name)}: weights {pl['weights_gb']} GB + engine " f"{pl['overhead_gb']:.2f} GB + a {models.CTX_MIN}-token KV cache needs at least {pl['min_ram_gb']} GB
") ex = pl["ctx"] // 900 # a chat exchange is roughly 900 tokens here (question, answer, template) return (f"
{E(p.name)}: weights {pl['weights_gb']} GB + engine {pl['overhead_gb']:.2f} GB + KV cache " f"{pl['kv_gb']} GB → a {pl['ctx']}-token context (about {ex} exchanges of history). Now: " f"{models.resources()['ctx']} tokens, {models.resources()['threads']} threads.
") def resources_usage_html() -> str: import models u = models.usage() rows = "".join(f"
{E(x['name'])} · pid {x['pid']}
{x['rss_bytes'] / 1e9:.2f} GB · {x['cpu_percent']:.0f} % CPU
" for x in u.get("processes") or []) return (f"
engine now · {u['rss_gb']} GB resident · {u['cpu_pct']} % CPU " f"(100 % = one of {u['cores']} cores) · machine: {u['mem_available_gb']} of {u['mem_total_gb']} GB free" f"
{rows}
measured by {E(u.get('source', '?'))}
") def installed_choices() -> list: """Chat models only: an embedding model (bge-m3 and the like) is listed in the table, not offered as a carrier.""" import models return [m["file"] for m in models.installed() if (m["pinned"] or m["catalog"]) and models.guard(Path(m["path"])).get("arch") not in models.EMBEDDING_ARCHS] def carrier_md() -> str: """bankml serve's verification as a compact card that wraps inside the side column.""" st = serve_status() if "error" in st: import models j = models.JOB if j["state"] == "running": return f"
bankml serve
starting: {E(j['what'])}{_job_bar(j)}
" why = f"
{E(j['error'])}" if j["state"] == "error" else "" return (f"
bankml serve
not reachable — choose a model in the Models tab, or start it (docs/usage.md)" f"
{SERVE}{why}
") v = st.get("verified") or {} sha = v.get("model_sha256", "") kinds = ", ".join(f"{k}×{n}" for k, n in sorted((v.get("types") or {}).items(), key=lambda x: -x[1]) if k not in ("F32",)) return ("
bankml serve ● verified · play" f"
model
{E(v.get('name') or Path(st.get('model', '')).name)}
" f"
arch · weights
{E(v.get('arch') or '?')} · {E(kinds or '?')}
" f"
sha256
{sha}
" f"
bankml
{v.get('bankml', '?')} · guard {v.get('guard', '?')}
" f"
engine
{st.get('engine', '?')}
") # ── the timer: from the press of Send to the last token ────────────────────────── PENDING: dict = {"t0": None, "first": None} # the timer's view of the latest Send INFLIGHT: dict = {} # request id -> start time: every answer being written, in any tab STALE_S = 1800 # an entry older than this is a lost request (a closed tab), not an answer def chat_busy() -> bool: now = time.time() for k, t in list(INFLIGHT.items()): if now - t > STALE_S: INFLIGHT.pop(k, None) return bool(INFLIGHT) def timer_md() -> str: t0, first = PENDING["t0"], PENDING["first"] if t0 is not None and not INFLIGHT and time.time() - t0 > 120: # Send was pressed but no answer started (a closed tab) PENDING.update(t0=None, first=None) t0 = None if t0 is None: return "
⏱ ready
" el = time.time() - t0 phase = f"writing · first token at {first - t0:.1f} s" if first else "reading the prompt (prefill)" # the browser counts the seconds from the moment of Send (data-t0); the server only changes the phase return (f"
⏱ {el:.1f} s · {E(phase)}
") def iso(t: float) -> str: return time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(t)) + f".{int(t % 1 * 1000):03d}" + time.strftime("%z", time.localtime(t)) CSS = """ .gradio-container{max-width:1320px!important} #bk-head{border:1px solid #0b1220;border-left:5px solid #d9a23a;border-radius:10px;padding:14px 18px;background:#0f172a} #bk-head, #bk-head *{color:#e2e8f0!important}#bk-head h2{margin:0 0 4px 0;color:#ffffff!important} #bk-head strong{color:#fbbf24!important} #bk-chat{border:1px solid #94a3b8!important;border-radius:10px!important;box-shadow:0 1px 2px rgba(15,23,42,.08)} #bk-side{border:1px solid #94a3b8;border-radius:10px;padding:10px;background:#f8fafc;box-shadow:0 1px 2px rgba(15,23,42,.08)} .bk-card{border:1px solid #cbd5e1;border-left:4px solid #0f766e;border-radius:8px;padding:10px 12px;background:#ffffff;font-size:13px; overflow-wrap:anywhere;word-break:break-word;max-width:100%} .bk-card, .bk-card *{color:#0f172a!important} .bk-card dl{display:grid;grid-template-columns:auto 1fr;gap:3px 10px;margin:8px 0 0 0}.bk-card dt{color:#475569!important}.bk-card dd{margin:0;min-width:0} .bk-mono{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px} .bk-card .bk-ok{color:#15803d!important;font-weight:700}.bk-bad{border-left-color:#b91c1c!important}.bk-bad b{color:#b91c1c!important} .bk-el{display:inline-block;min-width:4.2ch;text-align:right;font-variant-numeric:tabular-nums} .bk-timer{border:1px solid #cbd5e1;border-radius:8px;padding:9px 12px;font:700 16px ui-monospace,Menlo,Consolas,monospace;text-align:center;background:#ffffff} .bk-live{border:2px solid #0f766e;color:#0f766e!important;background:#f0fdfa}.bk-idle{color:#475569!important} #bk-prov, #bk-prov *{font-size:12px;color:#334155!important;overflow-wrap:anywhere} #bk-row{flex-wrap:nowrap;align-items:flex-start;position:relative} #bk-side,#bk-chat{position:relative} #bk-side.bk-sized{flex:none!important} .bk-grip{display:flex;align-items:center;gap:8px;cursor:grab;user-select:none;font:600 11px/1 ui-sans-serif,system-ui;letter-spacing:.08em; text-transform:uppercase;color:#64748b;padding:6px 8px;margin:-2px 0 8px;border-radius:8px;border:1px solid transparent;transition:all .15s} .bk-grip:hover{border-color:#cbd5e1;color:#0f766e;background:rgba(15,118,110,.05)}.bk-grip:active{cursor:grabbing} .bk-grip svg{flex:none;opacity:.8}.bk-grip .bk-sp{flex:1} .bk-swap{border:1px solid #cbd5e1;background:transparent;border-radius:6px;padding:2px 7px;font:600 12px ui-sans-serif,system-ui;color:#475569;cursor:pointer} .bk-swap:hover{border-color:#0f766e;color:#0f766e} .bk-knobs-wrap{border:1px solid #cbd5e1;border-radius:12px;padding:8px 8px 6px;background:#0b1220} .bk-kh{font:700 11px ui-monospace,Menlo,monospace;letter-spacing:.2em;color:#2dd4bf!important;margin:0 2px 6px} .bk-kn{font:10.5px ui-sans-serif,system-ui;color:#94a3b8!important;margin:4px 2px 0} .dark .bk-knobs-wrap{background:transparent!important;border-color:rgba(148,163,184,.35)!important} .bk-rz{position:absolute;right:3px;bottom:3px;width:16px;height:16px;cursor:nwse-resize;z-index:5;opacity:.45;transition:opacity .15s;touch-action:none} .bk-rz:hover,.bk-rz.on{opacity:1}.bk-rz svg{display:block} .bk-zone{position:fixed;z-index:9999;border:2px dashed rgba(15,118,110,.55);border-radius:12px;background:rgba(15,118,110,.06); display:flex;align-items:center;justify-content:center;font:600 13px ui-sans-serif,system-ui;letter-spacing:.06em;color:#0f766e;transition:background .12s} .bk-zone.hot{background:rgba(15,118,110,.18);border-style:solid} .dark .bk-grip{color:#94a3b8}.dark .bk-grip:hover{border-color:rgba(148,163,184,.35);color:#2dd4bf;background:rgba(45,212,191,.06)} .dark .bk-swap{border-color:rgba(148,163,184,.35);color:#cbd5e1}.dark .bk-zone{color:#2dd4bf;border-color:rgba(45,212,191,.55);background:rgba(45,212,191,.06)} /* the aivatar and its card */ .bk-av-t,input.bk-av-t[type=checkbox]{position:absolute!important;opacity:0!important;width:1px!important;height:1px!important;margin:0!important;pointer-events:none!important;border:0!important} .bk-av-pic{display:block;position:relative;cursor:pointer;border-radius:12px;overflow:hidden;border:1px solid #cbd5e1;transition:transform .2s,box-shadow .2s} .bk-av-pic img,.bk-av-pic .bk-glyph{display:block;width:100%;height:230px;object-fit:cover} .bk-av-pic:hover{transform:translateY(-2px);box-shadow:0 8px 30px rgba(15,118,110,.25)} .bk-av-cap{position:absolute;left:0;right:0;bottom:0;padding:18px 10px 8px;font:600 12px ui-sans-serif,system-ui;letter-spacing:.06em;color:#fff!important; background:linear-gradient(transparent,rgba(2,6,23,.85))} .bk-glyph{display:flex!important;align-items:center;justify-content:center;font:700 64px ui-sans-serif,system-ui;color:#2dd4bf!important; background:radial-gradient(circle at 30% 30%,#134e4a,#020617)} .bk-modal{display:none;position:fixed;inset:0;z-index:10000;align-items:center;justify-content:center;padding:24px} .bk-av-t:checked ~ .bk-modal{display:flex} .bk-modal-bg{position:absolute;inset:0;background:rgba(2,6,23,.72);backdrop-filter:blur(4px);cursor:zoom-out} .bk-3d{position:relative;max-width:920px;width:100%;max-height:88vh;border-radius:16px;transform-style:preserve-3d; display:grid;grid-template-areas:"holo";grid-template-columns:minmax(0,1fr); transform:perspective(1600px) rotateX(var(--rx,0deg)) rotateY(var(--ry,0deg));transition:transform .25s ease-out;will-change:transform; box-shadow:0 30px 80px rgba(0,0,0,.65),0 12px 24px rgba(0,0,0,.45)} .bk-scope{position:absolute;inset:0;width:100%;height:100%;border-radius:16px;pointer-events:none;z-index:0; background:radial-gradient(120% 90% at 50% 40%,#04211f 0%,#020a10 60%,#01050a 100%)} .bk-sheen{position:absolute;inset:0;border-radius:16px;pointer-events:none;z-index:2; background:radial-gradient(60% 50% at var(--gx,50%) var(--gy,30%),rgba(255,255,255,.07),transparent 60%);mix-blend-mode:screen} .bk-holo{grid-area:holo;min-width:0;box-sizing:border-box;position:relative;z-index:1;max-width:920px;width:100%;max-height:88vh;overflow:auto;border-radius:16px;padding:22px 24px 16px;color:#e2e8f0; background:linear-gradient(rgba(45,212,191,.05) 1px,transparent 1px) 0 0/100% 22px,linear-gradient(90deg,rgba(45,212,191,.05) 1px,transparent 1px) 0 0/22px 100%, linear-gradient(160deg,rgba(15,23,42,.72),rgba(2,6,23,.80));border:1px solid rgba(45,212,191,.45);backdrop-filter:blur(1.5px); box-shadow:0 0 0 1px rgba(217,162,58,.25),0 0 40px rgba(45,212,191,.18),inset 0 0 60px rgba(45,212,191,.05);animation:bk-glow 4s ease-in-out infinite} .bk-holo{scrollbar-width:thin;scrollbar-color:rgba(45,212,191,.45) transparent} .bk-holo::-webkit-scrollbar{width:8px}.bk-holo::-webkit-scrollbar-track{background:transparent} .bk-holo::-webkit-scrollbar-thumb{background:rgba(45,212,191,.4);border-radius:8px} @keyframes bk-glow{50%{box-shadow:0 0 0 1px rgba(217,162,58,.4),0 0 60px rgba(45,212,191,.28),inset 0 0 60px rgba(45,212,191,.07)}} @media (prefers-reduced-motion:reduce){.bk-holo{animation:none}} .bk-holo *{color:#e2e8f0} /* depth: raised sections with a bevel — light from above, shadow below */ .bk-holo-top,.bk-cols>div,.bk-voice,.bk-intro,.bk-links .bk-link,.bk-knobs-card,.bk-aspects details,.bk-ft{ box-shadow:inset 0 1px 0 rgba(255,255,255,.07),inset 0 -1px 0 rgba(0,0,0,.35),0 6px 18px rgba(0,0,0,.35)} .bk-cols>div{border-radius:12px;padding:8px 12px;background:rgba(2,6,23,.35);border:1px solid rgba(45,212,191,.12)} .bk-holo-top{border-radius:14px;padding:10px 12px;background:rgba(2,6,23,.35);border:1px solid rgba(217,162,58,.18)} .bk-holo-pic{box-shadow:0 0 24px rgba(217,162,58,.35),0 10px 26px rgba(0,0,0,.55)} @media (prefers-reduced-motion:reduce){.bk-3d{transform:none!important;transition:none}} .bk-x{position:absolute;top:12px;right:14px;cursor:pointer;font-size:18px;color:#94a3b8!important;border:1px solid rgba(148,163,184,.3);border-radius:8px;padding:2px 9px} .bk-x:hover{color:#2dd4bf!important;border-color:#2dd4bf} /* the way back: pinned to the top of the card however far it scrolls, and a pill on every other tab */ .bk-back{position:sticky;top:-22px;z-index:5;display:flex;align-items:center;gap:12px;margin:-22px -24px 14px;padding:10px 16px; background:rgba(2,6,23,.94);border-bottom:1px solid rgba(45,212,191,.35);border-radius:16px 16px 0 0} .bk-back .bk-x{position:static;margin-left:auto} .bk-backl{cursor:pointer;font:600 14px ui-sans-serif,system-ui;color:#2dd4bf!important;border:1px solid rgba(45,212,191,.5);border-radius:8px;padding:4px 12px} .bk-backl:hover{background:rgba(45,212,191,.15)} .bk-esc{font-size:12px;color:#64748b!important} .bk-home{position:fixed;left:16px;bottom:16px;z-index:9000;cursor:pointer;font:600 14px ui-sans-serif,system-ui;color:#042f2e;background:#2dd4bf; border:none;border-radius:999px;padding:9px 16px;box-shadow:0 6px 18px rgba(0,0,0,.35)} .bk-home:hover{background:#5eead4} .bk-holo-top{display:flex;gap:18px;align-items:center} .bk-holo-pic{flex:none;width:120px;height:120px;border-radius:50%;overflow:hidden;border:2px solid #d9a23a;box-shadow:0 0 24px rgba(217,162,58,.35)} .bk-holo-pic img,.bk-holo-pic .bk-glyph{width:120px;height:120px;object-fit:cover;font-size:40px} .bk-kicker{font:600 11px ui-monospace,Menlo,monospace;letter-spacing:.2em;text-transform:uppercase;color:#2dd4bf!important} .bk-holo h3{margin:4px 0 2px;font:700 28px ui-sans-serif,system-ui;letter-spacing:.02em;color:#fff!important;display:flex;align-items:center;gap:14px} .bk-lead{cursor:pointer;border-radius:999px;border:1px solid #2dd4bf;background:rgba(45,212,191,.12);color:#5eead4!important; font:700 12px ui-monospace,Menlo,monospace;letter-spacing:.1em;padding:6px 14px;box-shadow:0 0 16px rgba(45,212,191,.25);transition:all .15s} .bk-lead:hover{background:rgba(45,212,191,.24);box-shadow:0 0 22px rgba(45,212,191,.4)} [data-bk].bk-on{border-color:#d9a23a!important;color:#fde68a!important;background:rgba(217,162,58,.16)!important;box-shadow:0 0 18px rgba(217,162,58,.35)!important} .bk-mantra{font-style:italic;color:#fbbf24!important} .bk-chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px} .bk-chip{font:11px ui-monospace,Menlo,monospace;padding:2px 8px;border-radius:999px;border:1px solid rgba(45,212,191,.35);color:#99f6e4!important;background:rgba(45,212,191,.06)} .bk-desc{margin:16px 0 10px;line-height:1.55;color:#cbd5e1!important} .bk-cols{display:grid;grid-template-columns:1fr 1.15fr;gap:22px}@media (max-width:760px){.bk-cols{grid-template-columns:1fr}} .bk-h{font:600 11px ui-monospace,Menlo,monospace;letter-spacing:.18em;text-transform:uppercase;color:#d9a23a!important;margin:10px 0 4px; border-bottom:1px solid rgba(217,162,58,.25);padding-bottom:3px} .bk-holo p,.bk-holo li{font-size:13px;line-height:1.5;color:#cbd5e1!important}.bk-holo ul{margin:4px 0;padding-left:18px} .bk-hash{display:grid;grid-template-columns:auto 1fr;gap:6px 12px;margin:6px 0} .bk-hash dt{font:600 11px ui-monospace,Menlo,monospace;color:#94a3b8!important;white-space:nowrap} .bk-hash dd{margin:0;font:12px ui-monospace,Menlo,monospace;color:#5eead4!important;overflow-wrap:anywhere} .bk-aspects details{border:1px solid rgba(45,212,191,.22);border-radius:10px;margin:6px 0;background:rgba(45,212,191,.03)} .bk-aspects summary{cursor:pointer;padding:7px 12px;font:600 12px ui-monospace,Menlo,monospace;letter-spacing:.12em;text-transform:uppercase;color:#5eead4!important} .bk-aspects summary:hover{background:rgba(45,212,191,.07)}.bk-aspects details>div{padding:4px 14px 10px} .bk-asp{margin:2px 0;padding-left:16px}dl.bk-asp{display:grid;grid-template-columns:minmax(90px,auto) 1fr;gap:3px 12px;padding-left:0} dl.bk-asp>dt{font:600 11px ui-monospace,Menlo,monospace;color:#94a3b8!important}dl.bk-asp>dd{margin:0;font-size:12.5px;line-height:1.45;color:#cbd5e1!important;overflow-wrap:anywhere} ol.bk-asp>li{font-size:12.5px;line-height:1.45;color:#cbd5e1!important;margin:2px 0} .bk-sp{white-space:pre-wrap;font:12px/1.5 ui-monospace,Menlo,monospace;color:#cbd5e1!important;max-height:40vh;overflow:auto;margin:0} .bk-ft{width:100%;border-collapse:collapse;font-size:12px}.bk-ft th,.bk-ft td{border-bottom:1px solid rgba(148,163,184,.18);padding:4px 6px;text-align:left} .bk-ft th{font:600 11px ui-monospace,Menlo,monospace;color:#94a3b8!important}.bk-ft td.bk-mono{color:#5eead4!important;overflow-wrap:anywhere} /* the knob dock: hidden until she speaks; a side column (vertical stack), or a strip at the top or the bottom */ .bk-kdock{grid-area:dock;display:none;box-sizing:border-box;position:relative;z-index:3;border:1px solid rgba(45,212,191,.35);border-radius:14px; background:linear-gradient(160deg,rgba(15,23,42,.88),rgba(2,6,23,.92));box-shadow:0 0 24px rgba(45,212,191,.18),inset 0 1px 0 rgba(255,255,255,.07); padding:6px 10px 14px;animation:bk-kin .5s cubic-bezier(.2,.9,.3,1.2)} @keyframes bk-kin{from{opacity:0;transform:scale(.94) translateY(-6px)}to{opacity:1;transform:none}} .bk-3d.kd-on .bk-kdock{display:block} .bk-3d.kd-on.kd-side{grid-template-areas:"holo dock";grid-template-columns:minmax(0,1fr) auto;gap:10px} .bk-3d.kd-on.kd-top{grid-template-areas:"dock" "holo";grid-template-rows:auto minmax(0,1fr);gap:8px} .bk-3d.kd-on.kd-bottom{grid-template-areas:"holo" "dock";grid-template-rows:minmax(0,1fr) auto;gap:8px} .bk-3d.kd-on.kd-top .bk-holo,.bk-3d.kd-on.kd-bottom .bk-holo{max-height:calc(88vh - var(--kdh,150px))} .kd-side .bk-kdock{align-self:start;max-height:88vh;overflow:auto} .bk-kgrip{display:flex;align-items:center;gap:6px;cursor:grab;user-select:none;padding:2px 0 6px;border-bottom:1px solid rgba(45,212,191,.18);margin-bottom:6px} .bk-kgrip b{font:700 11px ui-monospace,Menlo,monospace;letter-spacing:.2em;color:#5eead4!important}.bk-kdots{color:#94a3b8!important}.bk-ksp{flex:1} .bk-kgrip button{cursor:pointer;border:1px solid rgba(148,163,184,.35);background:transparent;color:#cbd5e1!important;border-radius:6px;padding:0 5px;font-size:12px;line-height:18px} .bk-kgrip button:hover,.bk-kgrip button.on{border-color:#2dd4bf;color:#5eead4!important} .bk-kresize{position:absolute;right:3px;bottom:3px;width:14px;height:14px;cursor:nwse-resize;opacity:.55; background:linear-gradient(135deg,transparent 45%,#5eead4 46%,#5eead4 54%,transparent 55%,transparent 70%,#5eead4 71%,#5eead4 79%,transparent 80%)} .bk-kresize:hover{opacity:1} .bk-kzone{position:fixed;z-index:10001;border:2px dashed rgba(45,212,191,.6);border-radius:12px;background:rgba(45,212,191,.08);display:flex; align-items:center;justify-content:center;font:600 12px ui-monospace,Menlo,monospace;letter-spacing:.12em;color:#5eead4;pointer-events:auto} .bk-kzone.hot{background:rgba(45,212,191,.22);border-style:solid} @media (max-width:760px){.bk-3d.kd-on.kd-side{grid-template-areas:"holo" "dock";grid-template-columns:minmax(0,1fr)}} .bk-knobs-card{max-width:520px;border:1px solid rgba(45,212,191,.22);border-radius:12px;background:rgba(45,212,191,.03); max-height:0;opacity:0;overflow:hidden;padding:0 10px;margin:0;transform:translateY(-6px) scale(.97); transition:max-height .45s ease,opacity .35s ease,transform .45s cubic-bezier(.2,.9,.3,1.2),padding .3s,margin .3s} .bk-knobs-card.bk-emerge{max-height:140px;opacity:1;padding:8px 10px 4px;margin:6px 0 8px;transform:none; box-shadow:0 0 22px rgba(45,212,191,.18),inset 0 1px 0 rgba(255,255,255,.07)} .bk-pend .bk-line{opacity:.45}.bk-dim{opacity:.35;border-color:transparent!important;cursor:default} @media (prefers-reduced-motion:reduce){.bk-knobs-card{transition:none}} .bk-links{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px;margin:4px 0 6px} .bk-link{display:block;text-decoration:none;border:1px solid rgba(45,212,191,.28);border-radius:10px;padding:8px 10px;background:rgba(45,212,191,.04);transition:all .15s} .bk-link b{display:block;font:600 13px ui-sans-serif,system-ui;color:#5eead4!important}.bk-link span{display:block;font-size:11.5px;color:#94a3b8!important;margin-top:2px} .bk-link:hover{border-color:#d9a23a;background:rgba(217,162,58,.08);box-shadow:0 0 14px rgba(217,162,58,.18)} .bk-intro{border:1px solid rgba(217,162,58,.3);border-radius:12px;padding:10px 12px;background:rgba(217,162,58,.03);margin-bottom:6px} .bk-chap{border:1px solid rgba(45,212,191,.18);border-radius:10px;margin:6px 0;padding:0} .bk-chap summary,.bk-wait{display:flex;align-items:center;gap:10px;padding:7px 10px;cursor:pointer;list-style:none} .bk-chap summary::-webkit-details-marker{display:none}.bk-chap summary b,.bk-wait b{flex:1;font:600 13px ui-sans-serif,system-ui;color:#e2e8f0!important} .bk-cn{flex:none;width:22px;height:22px;border-radius:50%;border:1px solid #d9a23a;display:flex;align-items:center;justify-content:center;font:700 11px ui-monospace,Menlo,monospace;color:#fbbf24!important} .bk-chap .bk-vlist{padding:0 8px 8px}.bk-wait{opacity:.55;cursor:default} .bk-voice{border:1px solid rgba(45,212,191,.22);border-radius:12px;padding:10px 12px;background:rgba(45,212,191,.03);margin-bottom:6px} .bk-vbar{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:8px} .bk-playall,.bk-stop,.bk-play1{cursor:pointer;border-radius:8px;font:700 12px ui-monospace,Menlo,monospace;letter-spacing:.08em} .bk-playall{border:1px solid #2dd4bf;background:rgba(45,212,191,.12);color:#5eead4!important;padding:6px 14px} .bk-playall:hover{background:rgba(45,212,191,.25);box-shadow:0 0 16px rgba(45,212,191,.3)} .bk-export{margin-left:8px;padding:3px 10px;border:1px solid rgba(255,209,102,.55);border-radius:14px;color:#ffd166;text-decoration:none;font:600 12px ui-monospace,monospace;white-space:nowrap}.bk-export:hover{background:rgba(255,209,102,.12)}.bk-export small{opacity:.7;font-weight:400}.bk-export.off{opacity:.45;border-style:dashed;cursor:default}.bk-stop{border:1px solid rgba(148,163,184,.4);background:transparent;color:#cbd5e1!important;padding:6px 12px} .bk-vmeta{font:11px ui-monospace,Menlo,monospace;color:#94a3b8!important}.bk-vmeta code{color:#fbbf24!important} .bk-vlist{list-style:none;margin:0;padding:0;counter-reset:v} .bk-vlist li{display:flex;align-items:baseline;gap:10px;padding:5px 6px;border-radius:8px;transition:background .2s} .bk-vlist li.bk-now{background:rgba(217,162,58,.14);box-shadow:inset 3px 0 0 #d9a23a} .bk-vlist li.bk-now .bk-line{color:#fde68a!important} .bk-play1{flex:none;border:1px solid rgba(45,212,191,.35);background:transparent;color:#5eead4!important;padding:1px 7px;font-size:10px} .bk-play1:hover{background:rgba(45,212,191,.15)} .bk-line{flex:1;font-size:13px;line-height:1.5;color:#e2e8f0!important}.bk-dur{flex:none;font:11px ui-monospace,Menlo,monospace;color:#64748b!important} .bk-foot{margin-top:14px;padding-top:8px;border-top:1px solid rgba(148,163,184,.2);font:11px ui-monospace,Menlo,monospace;letter-spacing:.06em;color:#64748b!important} button.primary,button.lg.primary{background:#0f766e!important;border-color:#0f766e!important;color:#fff!important} .tabs button.selected{border-bottom:3px solid #0f766e!important;font-weight:700} /* input ready: a slow-blinking, light-green, semi-transparent cursor while the field is empty */ #bk-input label{position:relative} #bk-input textarea{caret-color:rgba(134,239,172,.9)} #bk-input textarea:placeholder-shown{text-indent:16px} #bk-input label:has(textarea:placeholder-shown)::after{content:"";position:absolute;left:14px;top:50%;width:9px;height:1.25em; transform:translateY(-50%);border-radius:2px;background:rgba(134,239,172,.55);box-shadow:0 0 10px rgba(134,239,172,.45); animation:bk-ready 1.6s ease-in-out infinite;pointer-events:none} @keyframes bk-ready{0%,100%{opacity:.9}50%{opacity:.08}} @media (prefers-reduced-motion:reduce){#bk-input label:has(textarea:placeholder-shown)::after{animation:none;opacity:.6}} #ragebar textarea,#ragebar input{background:#05070a!important;color:#e9ffe9!important;border:1px solid #1f3a2b!important;border-radius:8px!important; font:15px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace!important;caret-color:#3ddc84;padding:14px 16px!important} #ragebar textarea:focus,#ragebar input:focus{border-color:#3ddc84!important;box-shadow:0 0 0 3px rgba(61,220,132,.18)!important} #ragebar label span{color:#0f766e!important;font-weight:700;letter-spacing:.12em;text-transform:lowercase} .rb-stat{font:12px ui-monospace,Menlo,Consolas,monospace;color:#334155;margin:6px 2px}.rb-stat.rb-hot{color:#b91c1c} .rb-meter{height:4px;border-radius:2px;background:#e2e8f0;overflow:hidden;margin:0 0 6px}.rb-meter i{display:block;height:100%;background:#16a34a} .bk-ex{border:1px solid #cbd5e1;border-radius:8px;padding:10px 12px;margin:8px 0;background:#fff} .bk-ex, .bk-ex *{color:#0f172a} .bk-exh{font:12px ui-monospace,Menlo,Consolas,monospace;color:#475569!important;margin-bottom:6px;overflow-wrap:anywhere} .bk-q{font-weight:700;margin-bottom:6px;white-space:pre-wrap}.bk-a{white-space:pre-wrap;line-height:1.5} .bk-okb{color:#15803d!important;font-weight:700}.bk-badb{color:#b91c1c!important;font-weight:700}.bk-dimb{color:#64748b!important} .bk-note{font-size:12px;color:#475569!important;margin:6px 2px} .bk-t{width:100%;border-collapse:collapse;font-size:13px;margin:8px 0}.bk-t th,.bk-t td{border:1px solid #cbd5e1;padding:5px 8px;text-align:left;color:#0f172a} .bk-t th{background:#f1f5f9} .bk-commit dl{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:3px 12px;margin:6px 0 0}.bk-commit dt{font-weight:600}.bk-commit dd{margin:0;overflow-wrap:anywhere} .bk-jobbar{height:8px;border-radius:5px;background:rgba(100,116,139,.2);overflow:hidden;margin:8px 0 2px}.bk-jobbar i{display:block;height:100%;background:linear-gradient(90deg,#0d9488,#d9a23a)} .bk-cur td{background:rgba(13,148,136,.08)!important} .bk-kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(118px,1fr));gap:8px;margin:4px 0 10px} .bk-kpi{border:1px solid #cbd5e1;border-radius:10px;padding:8px 10px;display:flex;flex-direction:column;gap:1px;min-width:0} .bk-kpi b{font:700 20px ui-monospace,Menlo,Consolas,monospace;color:#0f172a;white-space:nowrap} .bk-kpi span{font-size:12px;color:#334155}.bk-kpi small{font-size:11px;color:#64748b} .bk-chart{margin:8px 0 12px;border:1px solid #cbd5e1;border-radius:10px;padding:8px 10px 4px} .bk-chart figcaption{display:flex;flex-wrap:wrap;gap:4px 14px;align-items:center;font-size:12px;color:#334155;margin-bottom:2px} .bk-chart figcaption b{margin-right:auto;color:#0f172a} .bk-chart svg{display:block;width:100%;height:auto;max-height:240px;overflow:visible} .bk-lg{display:inline-flex;align-items:center;gap:5px}.bk-lg i{display:inline-block;width:10px;height:10px;border-radius:3px} .bk-chart .g{stroke:rgba(100,116,139,.22);stroke-width:1}.bk-chart .ax{stroke:rgba(100,116,139,.6);stroke-width:1} .bk-chart text{font:11px ui-monospace,Menlo,Consolas,monospace;fill:#64748b}.bk-chart .yl{text-anchor:end}.bk-chart .xl{text-anchor:middle} .bk-chart .yu{text-anchor:middle}.bk-chart .ml{text-anchor:end;fill:#b45309;paint-order:stroke;stroke:#fff;stroke-width:4px;stroke-linejoin:round}.bk-chart .clip{text-anchor:middle;fill:#b91c1c} .bk-chart .med{stroke:#d97706;stroke-width:1.2;stroke-dasharray:5 4} .bk-chart .hit{fill:transparent}.bk-chart .hit:hover{fill:rgba(100,116,139,.10)} .bk-chart rect.s1,.bk-lg i.s1{fill:rgba(13,148,136,.85);background:rgba(13,148,136,.85)} .bk-chart rect.s2,.bk-lg i.s2{fill:rgba(217,162,58,.9);background:rgba(217,162,58,.9)} .bk-chart rect.s3,.bk-lg i.s3{fill:rgba(100,116,139,.6);background:rgba(100,116,139,.6)} .bk-chart circle.s1{fill:#0d9488}.bk-chart circle.s2{fill:#d9a23a} .bk-chart .ln{fill:none;stroke-width:1.5;opacity:.7}.bk-chart .ln.s1{stroke:#0d9488}.bk-chart .ln.s2{stroke:#d9a23a} #bk-nav button{min-width:0} /* dark mode: transparent surfaces over the page, light text and borders — never white panels */ .dark #bk-side,.dark .bk-card,.dark .bk-ex,.dark .bk-timer,.dark .bk-grip,.dark .bk-t th,.dark .bk-t td{background:transparent!important} .dark #bk-side{border-color:rgba(148,163,184,.35)!important;box-shadow:none} .dark .bk-card,.dark .bk-ex,.dark .bk-timer,.dark .bk-grip,.dark .bk-t th,.dark .bk-t td{border-color:rgba(148,163,184,.35)!important} .dark .bk-card,.dark .bk-card *,.dark .bk-ex,.dark .bk-ex *,.dark .bk-t th,.dark .bk-t td,.dark .bk-grip{color:#e2e8f0!important} .dark .bk-card dt,.dark .bk-exh,.dark .bk-note,.dark #bk-prov,.dark #bk-prov *,.dark .bk-idle,.dark .rb-stat{color:#94a3b8!important} .dark .bk-card .bk-ok,.dark .bk-okb{color:#4ade80!important}.dark .bk-badb,.dark .bk-bad b{color:#f87171!important} .dark .bk-live{border-color:#2dd4bf!important;color:#2dd4bf!important;background:rgba(45,212,191,.08)!important} .dark .bk-t th{background:rgba(148,163,184,.08)!important}.dark .rb-meter{background:rgba(148,163,184,.2)} .dark .bk-kpi,.dark .bk-chart{border-color:rgba(148,163,184,.35)!important;background:transparent!important} .dark .bk-kpi b,.dark .bk-chart figcaption b{color:#e2e8f0!important}.dark .bk-kpi span,.dark .bk-chart figcaption{color:#cbd5e1!important} .dark .bk-kpi small,.dark .bk-chart text{color:#94a3b8!important;fill:#94a3b8}.dark .bk-chart .ml{fill:#fbbf24;stroke:#0b0f19} .dark .bk-chart .g{stroke:rgba(148,163,184,.16)}.dark .bk-chart rect.s1,.dark .bk-lg i.s1{fill:rgba(45,212,191,.75);background:rgba(45,212,191,.75)} """ # ── renderers for the .history, Responses, .memory and Metrics tabs (all text escaped) ── E = html.escape def _fmt(v, unit="s", nd=1): return "—" if v is None else f"{v:.{nd}f} {unit}".strip() def _exchange_html(i: int, r: dict, n: int, score: float | None = None, top: float = 1.0) -> str: ft, rs, src = _timing(r) h = _hash_ok(r) rc = r.get("receipt") or {} meter = f"
" if score is not None else "" badge = {True: "answer sha256 ✓", False: "answer sha256 ≠ receipt", None: "no receipt hash"}[h] return (f"
{meter}
#{i + 1} of {n} · {E(_when(r))} · session {E(str(r.get('session', '?')))}" f" · first token {_fmt(ft)} · answered {_fmt(rs)}{' (receipt)' if src == 'receipt' else ''}" f" · {rc.get('prompt_tokens', '?')}+{rc.get('completion_tokens', '?')} tokens · {badge}
" f"
{E(r.get('user') or '')}
{E(r.get('assistant') or '')}
") def history_html() -> str: recs = history_all() if not recs: return f"
No exchanges yet. They are written to {E(str(HISTORY))}.
" return (f"
{len(recs)} exchanges · newest first · {E(str(HISTORY))}
" + "".join(_exchange_html(i, r, len(recs)) for i, r in reversed(list(enumerate(recs))))) def search_html(q: str) -> str: if not q.strip(): return "
type to search every question and answer in .history
" hits, engine = history_search(q) n = len(history_all()) if not hits: return f"
no match for “{E(q)}” · {E(engine)} over {n} exchanges
" top = hits[0][0] or 1.0 return (f"
{len(hits)} of {n} exchanges · {E(engine)}
" + "".join(_exchange_html(i, r, n, sc, top) for sc, i, r in hits)) def response_at(k: int): """(clamped index, html, plain answer) for the k-th response (0 = oldest).""" recs = history_all() if not recs: return 0, "
No responses yet.
", "" k = max(0, min(int(k), len(recs) - 1)) return k, _exchange_html(k, recs[k], len(recs)), recs[k].get("assistant") or "" def memory_html() -> str: m = memory_all() if not m: return f"
.memory is empty. Save a response from Responses, or type a note. File: {E(str(MEMORY))}
" rows = "".join(f"
note {j + 1} · {E(x.get('at', ''))} · " f"{E((x.get('source') or {}).get('kind', ''))} {E(str((x.get('source') or {}).get('sent_at', '') or ''))}
" f"
{E(x['text'])}
" for j, x in enumerate(m)) blk = memory_block() return (f"
{len(m)} notes · {len(blk)} of {MEMORY_BUDGET} characters go into the prompt when " f".memory is on · {E(str(MEMORY))}
" + rows) def _nice(mx: float) -> tuple: """A round axis top and step for 0..mx (1, 2 or 5 × 10ⁿ), about four gridlines.""" import math if mx <= 0: return 1.0, 0.25 raw = mx / 4 mag = 10 ** math.floor(math.log10(raw)) step = next(m * mag for m in (1, 2, 5, 10) if m * mag >= raw) return step * math.ceil(mx / step), step def _chart(rows: list, series: list, unit: str, title: str, stacked: bool = False, cap: float | None = None) -> str: """A small SVG chart that keeps its proportions: y axis with round gridlines, one slot per exchange (bars at most 18 px wide, however few exchanges there are), values above `cap` clipped with a ▲ mark. series: [(key, label, css)].""" W, H, L, R, T, B = 640, 190, 46, 10, 14, 26 pw, ph = W - L - R, H - T - B n = len(rows) tops = [sum((r.get(k) or 0) for k, _, _ in series) if stacked else max([(r.get(k) or 0) for k, _, _ in series] or [0]) for r in rows] top, step = _nice(min(max(tops or [0]), cap) if cap else max(tops or [0])) y = lambda v: T + ph - min(v, top) / top * ph g = [f"" f"{k * step:g}" for k in range(int(round(top / step)) + 1)] slot = pw / max(n, 1) bw = max(3.0, min(18.0, slot * 0.6)) bars = [] for j, r in enumerate(rows): cx = L + slot * (j + 0.5) base = 0.0 tip = [f"#{j + 1} · {r.get('when', '')}"] for k, (key, label, css) in enumerate(series): v = r.get(key) if v is None: continue tip.append(f"{label}: {v:.2f} {unit}") lo, hi = (base, base + v) if stacked else (0.0, v) if stacked: base = hi bars.append(f"") else: bars.append(f"") if (tops[j] or 0) > top: bars.append(f"▲") bars.append(f"{E(chr(10).join(tip))}") if not stacked: # connect each series with a thin line for key, _, css in series: pts = [(L + slot * (j + 0.5), y(r[key])) for j, r in enumerate(rows) if r.get(key) is not None] if len(pts) > 1: bars.insert(0, f"") med = sorted(t for t in tops if t) if stacked else [] # a median of mixed series would mean nothing if med: mv = med[len(med) // 2] if len(med) % 2 else (med[len(med) // 2 - 1] + med[len(med) // 2]) / 2 bars.append(f"" f"median {mv:.1f} {unit}") every = max(1, n // 8) ticks = sorted({0, n - 1} | {j for j in range(0, n, every) if n - 1 - j >= every / 2}) if n else [] xl = [f"#{j + 1}" for j in ticks] legend = "".join(f"{E(label)}" for key, label, css in series if any(r.get(key) is not None for r in rows)) return (f"
{E(title)}{legend}
" f"" f"" + "".join(g) + "".join(bars) + "".join(xl) + f"{E(unit)}
") def metrics_html() -> str: m = metrics() if not m["exchanges"]: return "
No exchanges in .history yet.
" def row(name, st, unit, nd=1): return (f"{name}{st['n']}{_fmt(st['median'], unit, nd)}{_fmt(st['p90'], unit, nd)}" f"{_fmt(st['mean'], unit, nd)}{_fmt(st['min'], unit, nd)}{_fmt(st['max'], unit, nd)}") rows = m["rows"][-60:] # the last 60 exchanges; the tables below keep every statistic tl = [{"when": x["when"], "first": x["first"], "write": (x["total"] - x["first"]) if x["total"] is not None and x["first"] is not None else None, "total": x["total"] if x["first"] is None else None} for x in rows] tp = [{"when": x["when"], "pre": (x["pt"] / x["first"]) if x["pt"] and x["first"] else None, "wr": (x["ct"] / (x["total"] - x["first"])) if x["ct"] and x["total"] and x["first"] is not None and x["total"] > x["first"] else None} for x in rows] tots = sorted(x["total"] for x in rows if x["total"] is not None) cap = tots[int(0.95 * (len(tots) - 1))] * 1.15 if len(tots) >= 8 else None # one slow outlier must not flatten the rest def kpi(v, label, sub=""): return f"
{v}{E(label)}{f'{E(sub)}' if sub else ''}
" fs, rs, ws = m["first_token_s"], m["response_s"], m["write_tok_s"] verified = m["hash_ok"] + m["hash_bad"] tiles = ("
" + kpi(m["exchanges"], "exchanges", f"{m['sessions']} sessions") + kpi(_fmt(fs["median"]), "first token", f"median · p90 {_fmt(fs['p90'])}") + kpi(_fmt(rs["median"]), "response", f"median · p90 {_fmt(rs['p90'])}") + kpi(_fmt(ws["median"], "tok/s", 2), "writing", "median") + kpi(f"{m['hash_ok']}/{verified}" if verified else "—", "answers match receipt", f"{m['hash_missing']} without receipt") + "
") recent = "".join(f"{E(x['when'])}{_fmt(x['first'])}{_fmt(x['total'])}{x['src'] or '—'}" f"{x['pt'] or '—'}+{x['ct'] or '—'}{'✓' if x['hash'] else ('✗' if x['hash'] is False else '—')}" f"{E(x['q'])}" for x in reversed(m["rows"][-25:])) days = " · ".join(f"{d}: {c}" for d, c in sorted(m["per_day"].items())) ch, cm = commitment(HISTORY), commitment(MEMORY) val = lambda v: f"{E(str(v))}" if v else "— (empty)" proofs = ("
Commitments (shareable; they reveal no content)
" f"
.history
{ch['records']} records
" f"
Merkle root
{val(ch['merkle_root'])}
" f"
CID
{val(ch['file_cid'])}
" f"
.memory
{cm['records']} notes
" f"
Merkle root
{val(cm['merkle_root'])}
") return (tiles + _chart(tl, [("first", "waiting for the first token", "s1"), ("write", "writing the answer", "s2"), ("total", "total (no first-token time)", "s3")], "s", f"response time per exchange{' (last 60)' if len(m['rows']) > 60 else ''}", stacked=True, cap=cap) + _chart(tp, [("pre", "prompt reading (prefill)", "s1"), ("wr", "writing", "s2")], "tok/s", "throughput per exchange") + f"
per day: {E(days)} · hover a column for its values
" "" + row("time to first token (from Send)", m["first_token_s"], "s") + row("response time (from Send)", m["response_s"], "s") + row("prompt reading (prefill)", m["prefill_tok_s"], "tok/s", 2) + row("writing", m["write_tok_s"], "tok/s", 2) + "
measurenmedianp90meanminmax
" "
Times are measured from the press of Send where recorded (ui, 0.0.7+); older exchanges use the " "receipt's gateway times. Prefill = prompt tokens ÷ time to first token; writing = completion tokens ÷ the rest.
" "" + recent + "
sentfirst tokenansweredsourcetokenshashquestion
" + proofs) def _aspect(v, depth=0) -> str: """Any persona value as escaped HTML: objects as definition lists, lists as lists, text as text.""" if isinstance(v, dict): if not v: return "—" return "
" + "".join(f"
{E(str(k))}
{_aspect(x, depth + 1)}
" for k, x in v.items()) + "
" if isinstance(v, list): if not v: return "—" return "
    " + "".join(f"
  1. {_aspect(x, depth + 1)}
  2. " for x in v) + "
" if v is None: return "null" return E(str(v)) IMG_KINDS = {b"\x89PNG\r\n\x1a\n": "png", b"\xff\xd8\xff": "jpeg", b"RIFF": "webp"} def image_kind(b: bytes): for magic, kind in IMG_KINDS.items(): if b.startswith(magic) and (kind != "webp" or b[8:12] == b"WEBP"): return kind return None def savante_images() -> list: d = CANON / "gfx" return sorted(p.name for p in d.iterdir() if p.suffix.lower() in (".png", ".jpg", ".jpeg", ".webp")) if d.is_dir() else [] def chosen_image(canon: Canon): """(bytes, kind, label) of the portrait in use: a custom agent's own x-bankml.aivatar, or Savante's chosen canon image.""" slug = ACTIVE["slug"] if slug: import agents for ext in ("png", "jpeg", "webp"): q = agents.agent_dir(slug) / f"{slug}.aivatar.{ext}" if q.is_file(): b = q.read_bytes() return b, image_kind(b), q.name return None, None, None pref = STATE / "savante.aivatar" name = pref.read_text(encoding="utf-8").strip() if pref.is_file() else "" rel = f"gfx/{name}" if name in savante_images() else ((canon.ledger.get("image_candidate") or {}).get("path") or "gfx/Savante3.png") b = canon.file(rel) return (b, image_kind(b) if b else None, rel) LINKS = [("Savante on Hugging Face", "https://huggingface.co/spaces/PYTHAI/savante", "the public office: chat, canon, integrity"), ("sAGI on Hugging Face", "https://huggingface.co/spaces/PYTHAI/savante/blob/main/skills/sagi/SKILL.md", "the sAGI skill Savante runs"), ("Savante's loop", "https://huggingface.co/datasets/PYTHAI/savante-loop", "public questions and answers (dataset)"), ("sAGI engine", "https://github.com/cryptoAGI/sagi", "the verdict contract, enforced in code"), ("Savante's canon", "https://github.com/cryptoAGI/savante", "persona, charter, facets, ledger"), ("bankml", "https://github.com/cryptoAGI/bankml", "the runtime this page runs on")] def links_html(derived: bool) -> str: head = "derived from Savante — her public places" if derived else "Savante in public" a = "".join(f"{E(t)}{E(d)}" for t, u, d in LINKS) return f"
{E(head)}
" def _clip_li(text: str, item, lead: bool = False) -> str: if item: return (f"" f"{E(text)}") return f"
  • ·{E(text)}rendering
  • " def _chapters_html(chapters: list, lead_n: int = 0) -> tuple: """(html, ready, total) for a list of chapters: whatever is rendered plays now.""" import speak total = sum(len(x) for _, x in chapters) ready, parts = 0, [] for n, (title, sents) in enumerate(chapters, 1): items = speak.cached(sents) k = sum(1 for i in items if i) ready += k secs = sum((i["seconds"] or 0) for i in items if i) state = f"{secs / 60:.1f} min" if k == len(sents) else f"{k}/{len(sents)} ready" nlead = lead_n if n == 1 else 0 lis = "".join(_clip_li(t, i, j < nlead) for j, (t, i) in enumerate(zip(sents, items))) play = "" if k else "" parts.append(f"
    {n}{E(title)}" f"{state}{play}
      {lis}
    ") return "".join(parts), ready, total def export_html(name: str, chapters: list, lead: list = ()) -> str: """⤓ .opus: the whole set as one file, offered only when it is complete and current.""" import speak st = speak.export_state(name, ([("Voice examples", list(lead))] if lead else []) + list(chapters)) if st["current"]: f = st["file"] return (f"" f"⤓ {E(name)}.opus {f.stat().st_size / 1e6:.1f} MB") return f"⤓ {E(name)}.opus · {st['ready']}/{st['total']} rendered" def intro_html(canon: Canon) -> str: """Savante reads herself to a new participant: chapters from her canon; whatever is rendered plays now.""" import speak chapters = speak.intro_chapters(CANON, canon.persona, canon.card) body, ready, total = _chapters_html(chapters, len(speak.speech(canon.card.get("description") or ""))) reading = speak.reading_chapters() rbody, rready, rtotal = _chapters_html(reading) sets = speak.export_sets(CANON, canon.persona, canon.card) if ready + rready < total + rtotal or not all(speak.export_state(n, ([("Voice examples", l)] if l else []) + c)["current"] for n, (l, c) in sets.items()): speak.render_intro_async(CANON, canon.persona, canon.card) note = "" if ready == total else f" · {ready}/{total} sentences rendered so far — refresh the page for more" rnote = "" if rready == rtotal else f" · {rready}/{rtotal} rendered so far" return (f"
    introduction — Savante reads herself to a new participant
    " f"
    " f"{export_html('Savante', *sets['Savante'][::-1])}" f"{len(chapters)} chapters from her canon (persona, .prompt, explanation, manifesto, Savante.md) read verbatim in her own voice, " f"with one note of bankml's own (SCIEN·TIFIC){note}
    " + body + "
    " + (f"
    the reading — Savante reads bankml's thesis, and binary and ternary
    " f"
    " f"{export_html('Savante-reading', reading)}" f"{len(reading)} chapters from TECHNICAL.md, verbatim, the notation read aloud{rnote}
    " + rbody + "
    " if reading else "")) def voice_html(examples: list, name: str) -> str: """The persona's voice examples in Savante's voice: whatever is rendered plays now; PLAY ALL plays them in order.""" import speak lines = [x for x in examples if isinstance(x, str) and x.strip()] if not lines: return (f"
    {E(name)} speaks

    No voice examples in this persona yet — add them to " "voice_examples in the .persona editor (Agents).

    ") ok, why = speak.available() if not ok: return f"
    {E(name)} speaks

    Not rendered on this computer ({E(why)}).

    " items = speak.cached(lines) k = sum(1 for i in items if i) if k < len(lines): speak.render_async(lines) v = speak.savante_voice() total = sum((i["seconds"] or 0) for i in items if i) who = ("Savante's own voice (Cori's body, Jaimla's pitch, SAVANTE's resonance; slower, steadier)" if v.get("model") else f"the house stand-in {E(v['voice'])} at {v['wpm']} wpm") return (f"
    {E(name)} speaks — every voice example, pre-rendered
    " f"
    " f"" f"" f"{k}/{len(lines)} ready · {total:.0f} s · {who} · said sav-ont
    " f"
      {''.join(_clip_li(t, i) for t, i in zip(lines, items))}
    ") # ── the aivatar: click the portrait for the agent's card (pure CSS toggle; the image is embedded, never served) ── def aivatar_html(canon: Canon) -> str: import base64 slug = ACTIVE["slug"] if slug: import agents f = agents.files(slug) p = json.loads(f["persona"].read_text(encoding="utf-8")) card = json.loads(f["agentcard.json"].read_text(encoding="utf-8")) led = json.loads(f["commitments.json"].read_text(encoding="utf-8")) tp = agents.agent_dir(slug) / f"{slug}.thot.json" man = json.loads(tp.read_text(encoding="utf-8")) if tp.is_file() else {} ledger_ok = f"{sum(ok for _, ok, _ in agents.verify(slug))}/{len(agents.verify(slug))}" persona_sha, droot = led["artifacts"]["persona"]["sha256"], led["doctrine_root"]["value"] status = (card.get("bankml") or {}).get("status", "not_yet_minted") arts = [(k, a["path"], a["bytes"], a["sha256"]) for k, a in led["artifacts"].items()] + [("card", led["card"]["path"], led["card"]["bytes"], led["card"]["sha256"])] else: p, card, led = canon.persona, canon.card, canon.ledger try: man = json.loads((CANON / "savante.thot.json").read_text(encoding="utf-8")) except (OSError, ValueError): man = {} ledger_ok = f"{sum(r[2] for r in canon.rows)}/{len(canon.rows)}" persona_sha = ((led.get("artifacts") or {}).get("identity") or {}).get("sha256", "") droot = (led.get("doctrine_root") or {}).get("value", "") status = (card.get("savante") or {}).get("status", "?") arts = [(k, a.get("path"), a.get("bytes"), a.get("sha256")) for k, a in (led.get("artifacts") or {}).items()] name = p.get("name", "agent") img, kind, img_label = chosen_image(canon) pic = (f"{E(name)}" if img and kind else f"
    {E(''.join(w[0] for w in name.split()[:2]).upper())}
    ") ident = man.get("identity") or {} beliefs = [b.get("belief", "") for b in ((p.get("bdi") or {}).get("beliefs") or [])[:5] if isinstance(b, dict)] attrs = "".join(f"{E(str(a.get('trait_type')))}: {E(str(a.get('value')))}" for a in (card.get("attributes") or [])[:8]) facets = " · ".join(E(x.get("facet", "")) for x in (man.get("facets") or [])) rows = [("persona sha256", persona_sha), ("doctrine root", droot), ("THOT", ident.get("thot", "—")), ("THOT CID", ident.get("cid", "—")), ("contentRoot", ident.get("contentRoot", "—")), ("generation", str((man.get("bundle") or {}).get("generation", "—"))), ("facets", facets or "—"), ("ledger", f"{ledger_ok} verify · status {status}")] dl = "".join(f"
    {E(k)}
    {E(v) if k != 'facets' else v}
    " for k, v in rows) shown = {"persona", "name", "system_prompt"} # name is the title; the system prompt gets its own section below aspects = "".join(f"{E(k)}
    {_aspect(v)}
    " for k, v in p.items() if k not in shown) aspects += f"
    system_prompt
    {E(p.get('system_prompt', ''))}
    " files = "".join(f"{E(str(k))}{E(str(pth))}{E(str(n))}{E(str(h))}" for k, pth, n, h in arts) voice = voice_html(p.get("voice_examples") or [], name) lead_btn = ("" if not slug else "") intro = intro_html(canon) if not slug else "" links = links_html(bool(slug)) return f"""
    ⠿VOICE
    """ # Gradio 3 has no layout API: this runs once in the page. Drag the side panel's grip to either side of the chat; # drag any panel's corner to resize it. The choice is kept in this browser (localStorage). LAYOUT_JS = """() => { const K = 'bankml-interact-layout-v2'; // the way back to the Interaction tab: from any card (the pinned bar, or Esc) and from any other tab (the pill) if (!window.bkToInteraction) { const tabBtn = () => [...document.querySelectorAll('.tab-nav button')].find(b => b.textContent.trim() === 'Interaction'); const closeCards = () => document.querySelectorAll('.bk-av-t:checked').forEach(c => { c.checked = false }); window.bkToInteraction = () => { closeCards(); const b = tabBtn(); if (b) b.click(); setTimeout(() => { const i = document.querySelector('#bk-input textarea'); if (i) i.focus() }, 80); }; // the aivatar always opens its card — never a toggle, so no stuck state can turn a press into a close document.addEventListener('click', e => { const pic = e.target.closest && e.target.closest('.bk-av-pic'); if (!pic) return; const c = document.getElementById(pic.htmlFor); if (!c) return; e.preventDefault(); c.checked = true; }, true); document.addEventListener('keydown', e => { if (e.key === 'Escape' && document.querySelector('.bk-av-t:checked')) closeCards() }); // data-bk-home, not data-bk: inside a card, data-bk is the voice player's (it cancels those clicks) // the card's bar is a plain