one log event in full
+"""
+
+import json
+import sqlite3
+from dataclasses import dataclass, field
+from importlib import resources
+from typing import Any
+from urllib.parse import quote, unquote, urlsplit
+
+from jinja2 import Environment, PackageLoader, StrictUndefined
+
+from nisse_core.models import calls
+from nisse_ui import walk
+from nisse_ui.sources import Source, StateReader, UnknownSchema, call_record
+
+AGENT_LEVEL = "-" # the item id that stands for the agent-level log
+
+# Everything shown is untrusted text (L11): escaped by the templates, and
+# the browser runs no script at all, loads nothing from elsewhere, and
+# sends no referrer.
+HEADERS = {
+ "Content-Security-Policy": (
+ "default-src 'none'; style-src 'self'; img-src 'self'; base-uri 'none'; "
+ "form-action 'none'; frame-ancestors 'none'"
+ ),
+ "X-Content-Type-Options": "nosniff",
+ "Referrer-Policy": "no-referrer",
+ "Cache-Control": "no-store",
+}
+
+
+@dataclass(frozen=True)
+class Response:
+ status: int
+ body: bytes
+ content_type: str = "text/html; charset=utf-8"
+ headers: dict[str, str] = field(default_factory=lambda: dict(HEADERS))
+
+
+def _when(ts: Any) -> str:
+ return str(ts)[:19].replace("T", " ") if ts else "—"
+
+
+def _num(n: Any) -> str:
+ return f"{n:,}" if isinstance(n, int) and not isinstance(n, bool) else "—"
+
+
+def _pretty(text: Any) -> str:
+ """JSON text indented, anything else as it is."""
+ if not isinstance(text, str):
+ return json.dumps(text, indent=2, ensure_ascii=False)
+ try:
+ return json.dumps(json.loads(text), indent=2, ensure_ascii=False)
+ except ValueError:
+ return text
+
+
+def _clip(text: Any, limit: int = 300) -> str:
+ text = str(text or "")
+ return (
+ text
+ if len(text) <= limit
+ else text[:limit] + f" … [{len(text) - limit:,} more]"
+ )
+
+
+def summary(kind: str, payload: dict[str, Any]) -> str:
+ """One event as a line of text: what it said or did."""
+ match kind:
+ case "user_message" | "assistant_text" | "fact":
+ return str(payload.get("text", ""))
+ case "tool_call":
+ args = json.dumps(payload.get("arguments", {}), ensure_ascii=False)
+ return f"{payload.get('tool', '?')}({args})"
+ case "tool_result":
+ mark = "ok" if payload.get("success") else "FAILED"
+ return f"{mark}: {payload.get('result', '')}"
+ case "plan_submitted":
+ return str(payload.get("plan", ""))
+ case "gate_opened":
+ return f"{payload.get('gate', '')}: {payload.get('reason', '')}"
+ case "delegation_started":
+ return f"({payload.get('model', '')}) {payload.get('brief', '')}"
+ case "delegation_completed":
+ return f"[{payload.get('exit_status', '')}] {payload.get('handback', '')}"
+ case "compaction":
+ first, last = (payload.get("replaces") or ["?", "?"])[:2]
+ return f"replaces {first}–{last}: {payload.get('summary', '')}"
+ case "action_verdict":
+ return (
+ f"{payload.get('tool', '')}: {payload.get('effect', '')} → "
+ f"{payload.get('decision', '')} ({payload.get('reason', '')})"
+ )
+ case _:
+ return json.dumps(payload, ensure_ascii=False)
+
+
+def _reply_line(reply: walk.Reply) -> str:
+ calls_made = "; ".join(f"{tool}({args})" for tool, args in reply.calls)
+ text = " ".join(reply.text.split())
+ return " · ".join(p for p in (text, calls_made) if p) or "(no reply)"
+
+
+class App:
+ def __init__(self, sources: list[Source]) -> None:
+ self.sources = list(sources)
+ self.by_name = {s.name: s for s in sources}
+ env = Environment(
+ loader=PackageLoader("nisse_ui", "templates"),
+ autoescape=True, # always: every page shows untrusted text
+ undefined=StrictUndefined,
+ trim_blocks=True,
+ lstrip_blocks=True,
+ )
+ env.filters.update(
+ u=lambda s: quote(str(s), safe=""),
+ when=_when,
+ num=_num,
+ pretty=_pretty,
+ clip=_clip,
+ reply_line=_reply_line,
+ )
+ env.globals.update(summary=summary, AGENT_LEVEL=AGENT_LEVEL)
+ self.env = env
+
+ def page(self, template: str, status: int = 200, **values: Any) -> Response:
+ html = self.env.get_template(template).render(**values)
+ return Response(status, html.encode())
+
+ def problem(self, status: int, title: str, detail: str) -> Response:
+ return self.page("problem.html", status, title=title, detail=detail)
+
+ def handle(self, raw_path: str) -> Response:
+ path = urlsplit(raw_path).path
+ parts = [unquote(p) for p in path.split("/")[1:]]
+ if parts in ([], [""]):
+ return self.home()
+ if parts == ["static", "style.css"]:
+ css = resources.files("nisse_ui").joinpath("static/style.css").read_bytes()
+ return Response(200, css, "text/css; charset=utf-8")
+ if len(parts) >= 2 and parts[0] == "s":
+ source = self.by_name.get(parts[1])
+ if source is None:
+ return self.problem(404, "No such source", parts[1])
+ rest = parts[2:]
+ try:
+ if rest in ([], [""]):
+ return self.items(source)
+ if len(rest) == 2 and rest[0] == "item":
+ item = None if rest[1] == AGENT_LEVEL else rest[1]
+ return self.item(source, item)
+ if (
+ len(rest) == 2
+ and rest[0] in ("call", "event")
+ and rest[1].isdigit()
+ ):
+ if rest[0] == "call":
+ return self.call(source, int(rest[1]))
+ return self.event(source, int(rest[1]))
+ except UnknownSchema as exc:
+ return self.problem(
+ 409, "A state store this page doesn't know", str(exc)
+ )
+ except calls.UnknownLayout as exc:
+ return self.problem(
+ 409, "A call record this page doesn't know", str(exc)
+ )
+ except sqlite3.Error as exc:
+ return self.problem(503, "Couldn't read the store", str(exc))
+ return self.problem(404, "Not found", path)
+
+ # --- pages ---------------------------------------------------------------------
+
+ def home(self) -> Response:
+ rows = []
+ for source in self.sources:
+ row: dict[str, Any] = {"source": source, "count": None, "note": ""}
+ try:
+ with StateReader(source.state_db) as state:
+ row["count"] = state.count_items()
+ row["schema"] = state.schema_version
+ except (UnknownSchema, sqlite3.Error) as exc:
+ row["note"] = str(exc)
+ row["record"] = source.calls_db.is_file()
+ rows.append(row)
+ return self.page("sources.html", rows=rows)
+
+ def items(self, source: Source) -> Response:
+ with StateReader(source.state_db) as state:
+ return self.page(
+ "items.html",
+ source=source,
+ items=state.items(),
+ active=state.active_item(),
+ )
+
+ def item(self, source: Source, item_id: str | None) -> Response:
+ with StateReader(source.state_db) as state:
+ if item_id is not None and state.item(item_id) is None:
+ return self.problem(404, "No such work item", item_id)
+ try:
+ with call_record(source) as reader:
+ view = walk.walkthrough(state, reader, item_id)
+ except calls.UnknownLayout as exc:
+ view = walk.walkthrough(state, None, item_id, record_note=str(exc))
+ return self.page("item.html", source=source, view=view)
+
+ def call(self, source: Source, call_id: int) -> Response:
+ with StateReader(source.state_db) as state, call_record(source) as reader:
+ if reader is None:
+ return self.problem(404, "No call record", str(source.calls_db))
+ view = walk.turn(state, reader, call_id)
+ if view is None:
+ return self.problem(404, "No such call", str(call_id))
+ return self.page("call.html", source=source, view=view)
+
+ def event(self, source: Source, event_id: int) -> Response:
+ with StateReader(source.state_db) as state:
+ found = state.event(event_id)
+ if found is None:
+ return self.problem(404, "No such event", str(event_id))
+ return self.page("event.html", source=source, event=found)
diff --git a/nisse-ui/src/nisse_ui/main.py b/nisse-ui/src/nisse_ui/main.py
new file mode 100644
index 0000000..8f68ef5
--- /dev/null
+++ b/nisse-ui/src/nisse_ui/main.py
@@ -0,0 +1,70 @@
+"""Serve the status page (X6), read-only, on this machine only.
+
+ nisse-ui --source dev=/var/lib/nisse-dev/state.db --runs bench-kept/
+
+Each source is an agent's state store; its call record is found beside it.
+``--runs`` names folders of runs the benchmark kept (``--keep-runs``). It
+listens on 127.0.0.1: until it sits behind Authelia (M6.6d), reach it
+through an SSH tunnel, since it shows everything the models saw.
+"""
+
+import argparse
+import sys
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+
+from nisse_ui.app import App
+from nisse_ui.sources import discover
+
+
+def _source(text: str) -> tuple[str, Path]:
+ name, sep, path = text.partition("=")
+ if not sep or not name or not path:
+ raise argparse.ArgumentTypeError("expected NAME=STATE_DB")
+ return name, Path(path)
+
+
+def handler_for(app: App) -> type[BaseHTTPRequestHandler]:
+ class Handler(BaseHTTPRequestHandler):
+ server_version = "nisse-ui"
+ sys_version = ""
+
+ def do_GET(self) -> None:
+ response = app.handle(self.path)
+ self.send_response(response.status)
+ self.send_header("Content-Type", response.content_type)
+ self.send_header("Content-Length", str(len(response.body)))
+ for key, value in response.headers.items():
+ self.send_header(key, value)
+ self.end_headers()
+ self.wfile.write(response.body)
+
+ def log_message(self, format: str, *args: object) -> None:
+ sys.stderr.write(f"nisse-ui {self.address_string()} {format % args}\n")
+
+ return Handler
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ parser.add_argument(
+ "--source", action="append", type=_source, default=[], metavar="NAME=STATE_DB"
+ )
+ parser.add_argument("--runs", action="append", type=Path, default=[], metavar="DIR")
+ parser.add_argument("--host", default="127.0.0.1")
+ parser.add_argument("--port", type=int, default=8765)
+ args = parser.parse_args(argv)
+ app = App(discover(args.source, args.runs))
+ server = ThreadingHTTPServer((args.host, args.port), handler_for(app))
+ print(f"nisse-ui on http://{args.host}:{args.port}/", file=sys.stderr)
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+ finally:
+ server.server_close()
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/nisse-ui/src/nisse_ui/sources.py b/nisse-ui/src/nisse_ui/sources.py
new file mode 100644
index 0000000..c5f7eae
--- /dev/null
+++ b/nisse-ui/src/nisse_ui/sources.py
@@ -0,0 +1,229 @@
+"""Where the page reads from (X6): each agent's state store and the call
+record beside it, and the runs the benchmark kept. Everything is opened
+read-only (SQLite ``mode=ro``), one short connection per page, so the agent
+stays the only writer of its state (L8) and nothing here can lock it."""
+
+import contextlib
+import json
+import re
+import sqlite3
+from collections.abc import Iterator
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Self
+
+from nisse_core.models import calls
+from nisse_core.state import readonly
+
+# The state store schema versions this page knows: the shared store's, and
+# each agent's that adds tables of its own. The page reads only the shared
+# tables, but says so for any version it doesn't know rather than guessing.
+KNOWN_SCHEMAS = {4: "nisse-core (nisse-review)", 5: "nisse-dev"}
+
+
+class UnknownSchema(RuntimeError):
+ pass
+
+
+@dataclass(frozen=True)
+class Source:
+ name: str # what the page calls it, safe in a URL
+ state_db: Path
+ kind: str # "agent" | "run" (a kept benchmark run)
+
+ @property
+ def calls_db(self) -> Path:
+ return calls.path_for(self.state_db)
+
+
+def safe_name(text: str) -> str:
+ return re.sub(r"[^A-Za-z0-9._-]+", "-", text).strip("-") or "source"
+
+
+def discover(agents: list[tuple[str, Path]], runs: list[Path]) -> list[Source]:
+ """The agents named on the command line, then each run kept under the
+ runs folders (a folder holding a ``state.db``), newest first."""
+ found = [Source(safe_name(name), path, "agent") for name, path in agents]
+ for folder in runs:
+ kept = sorted(
+ (p for p in folder.glob("*/state.db") if p.is_file()),
+ key=lambda p: p.stat().st_mtime,
+ reverse=True,
+ )
+ found += [
+ Source(safe_name(f"{folder.name}.{p.parent.name}"), p, "run") for p in kept
+ ]
+ names: set[str] = set()
+ unique = []
+ for source in found:
+ name, n = source.name, 2
+ while name in names:
+ name, n = f"{source.name}-{n}", n + 1
+ names.add(name)
+ unique.append(Source(name, source.state_db, source.kind))
+ return unique
+
+
+@dataclass(frozen=True)
+class Item:
+ id: str
+ title: str
+ status: str
+ outcome: str | None
+ position: str
+ scope: str | None
+ opened_at: str
+ updated_at: str
+ closed_at: str | None
+ data: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass(frozen=True)
+class Event:
+ id: int
+ ts: str
+ type: str
+ payload: dict[str, Any]
+ work_item: str | None
+ actor: str
+
+
+def _loads(text: str | None) -> Any:
+ """Item data and payloads, read tolerantly: whatever doesn't parse is kept
+ as text, so a field from a newer agent never breaks the page."""
+ if text is None:
+ return None
+ try:
+ return json.loads(text)
+ except (TypeError, ValueError):
+ return {"unreadable": text}
+
+
+def connect(path: Path) -> sqlite3.Connection:
+ return readonly.connect(path)
+
+
+class StateReader:
+ """A state store, read-only. Use as a context manager: it closes at the
+ end of the page that opened it."""
+
+ def __init__(self, path: Path) -> None:
+ self.path = path
+ self._conn = connect(path)
+ try:
+ row = self._conn.execute(
+ "SELECT value FROM meta WHERE key = 'schema_version'"
+ ).fetchone()
+ except sqlite3.Error as exc:
+ self._conn.close()
+ raise UnknownSchema(f"not a state store: {exc}") from exc
+ self.schema_version = int(row["value"]) if row else 0
+ if self.schema_version not in KNOWN_SCHEMAS:
+ self._conn.close()
+ known = ", ".join(str(v) for v in sorted(KNOWN_SCHEMAS))
+ raise UnknownSchema(
+ f"state store schema {self.schema_version}, not known to this "
+ f"page (it knows {known})"
+ )
+
+ def __enter__(self) -> Self:
+ return self
+
+ def __exit__(self, *exc: object) -> None:
+ self.close()
+
+ def close(self) -> None:
+ self._conn.close()
+
+ def active_item(self) -> str | None:
+ row = self._conn.execute(
+ "SELECT active_item FROM agent WHERE id = 1"
+ ).fetchone()
+ return row["active_item"] if row else None
+
+ def items(self) -> list[Item]:
+ rows = self._conn.execute(
+ "SELECT * FROM work_items ORDER BY opened_at DESC, id"
+ ).fetchall()
+ return [self._item(r) for r in rows]
+
+ def item(self, item_id: str) -> Item | None:
+ row = self._conn.execute(
+ "SELECT * FROM work_items WHERE id = ?", (item_id,)
+ ).fetchone()
+ return self._item(row) if row else None
+
+ def _item(self, row: sqlite3.Row) -> Item:
+ data = _loads(row["data"])
+ return Item(
+ id=row["id"],
+ title=row["title"],
+ status=row["status"],
+ outcome=row["outcome"],
+ position=row["position"],
+ scope=row["scope"],
+ opened_at=row["opened_at"],
+ updated_at=row["updated_at"],
+ closed_at=row["closed_at"],
+ data=data if isinstance(data, dict) else {"unreadable": row["data"]},
+ )
+
+ def events(self, work_item: str | None) -> list[Event]:
+ """One item's log, or the agent-level log (None)."""
+ if work_item is None:
+ rows = self._conn.execute(
+ "SELECT * FROM canonical_events WHERE work_item IS NULL ORDER BY id"
+ )
+ else:
+ rows = self._conn.execute(
+ "SELECT * FROM canonical_events WHERE work_item = ? ORDER BY id",
+ (work_item,),
+ )
+ return [self._event(r) for r in rows]
+
+ def event(self, event_id: int) -> Event | None:
+ row = self._conn.execute(
+ "SELECT * FROM canonical_events WHERE id = ?", (event_id,)
+ ).fetchone()
+ return self._event(row) if row else None
+
+ def events_by_id(self, ids: list[int]) -> dict[int, Event]:
+ found: dict[int, Event] = {}
+ wanted = sorted({i for i in ids if i > 0})
+ for start in range(0, len(wanted), 500):
+ chunk = wanted[start : start + 500]
+ marks = ",".join("?" * len(chunk))
+ for row in self._conn.execute(
+ f"SELECT * FROM canonical_events WHERE id IN ({marks})", chunk
+ ):
+ found[row["id"]] = self._event(row)
+ return found
+
+ def count_items(self) -> int:
+ return self._conn.execute("SELECT COUNT(*) FROM work_items").fetchone()[0]
+
+ def _event(self, row: sqlite3.Row) -> Event:
+ payload = _loads(row["payload"])
+ return Event(
+ id=row["id"],
+ ts=row["ts"],
+ type=row["type"],
+ payload=payload if isinstance(payload, dict) else {"value": payload},
+ work_item=row["work_item"],
+ actor=row["actor"],
+ )
+
+
+@contextlib.contextmanager
+def call_record(source: Source) -> Iterator[calls.CallReader | None]:
+ """The source's call record, or None when there is none (an agent
+ started before M6.6a, or recording turned off). An unknown layout raises
+ ``calls.UnknownLayout`` for the page to report."""
+ if not source.calls_db.is_file():
+ yield None
+ return
+ reader = calls.CallReader(source.calls_db)
+ try:
+ yield reader
+ finally:
+ reader.close()
diff --git a/nisse-ui/src/nisse_ui/static/style.css b/nisse-ui/src/nisse_ui/static/style.css
new file mode 100644
index 0000000..e31fde6
--- /dev/null
+++ b/nisse-ui/src/nisse_ui/static/style.css
@@ -0,0 +1,77 @@
+/* nisse-ui: plain, readable, no external assets (X6). */
+:root {
+ --bg: #fbfbfa; --fg: #1d1d1b; --muted: #6b6b66; --line: #deded8;
+ --panel: #f2f2ee; --accent: #2f5d8a; --bad: #a3322a; --warn: #8a5a00;
+ --new: #e3f0e0; --change: #fbe6c8; --flag: #f6d9d6;
+ --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+}
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #161615; --fg: #e6e6e1; --muted: #9a9a93; --line: #34342f;
+ --panel: #20201e; --accent: #8fb8e0; --bad: #ec8a80; --warn: #e2b462;
+ --new: #233523; --change: #3d2f17; --flag: #432522;
+ }
+}
+* { box-sizing: border-box; }
+body { margin: 0; background: var(--bg); color: var(--fg);
+ font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
+main { max-width: 1100px; margin: 0 auto; padding: 0 16px 64px; }
+a { color: var(--accent); }
+h1 { font-size: 1.5rem; margin: 1.2rem 0 .4rem; }
+h2 { font-size: 1.15rem; margin: 1.8rem 0 .6rem; border-bottom: 1px solid var(--line); }
+h3 { font-size: 1rem; margin: 1.2rem 0 .4rem; }
+.top { border-bottom: 1px solid var(--line); background: var(--panel); }
+.crumbs { max-width: 1100px; margin: 0 auto; padding: 8px 16px; }
+.muted, .meta { color: var(--muted); }
+.meta { font-size: .9rem; }
+.bad { color: var(--bad); }
+.path { font-family: var(--mono); font-size: .85rem; word-break: break-all; }
+pre, code { font-family: var(--mono); font-size: .85rem; }
+pre { background: var(--panel); border: 1px solid var(--line); border-radius: 4px;
+ padding: 8px 10px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; margin: .4rem 0; }
+table { border-collapse: collapse; width: 100%; margin: .6rem 0; font-size: .92rem; }
+th, td { text-align: left; vertical-align: top; padding: 4px 8px; border-bottom: 1px solid var(--line); }
+th { color: var(--muted); font-weight: 600; }
+tr.active td { background: var(--new); }
+table.facts th { width: 14%; white-space: nowrap; }
+table.log td.text { font-family: var(--mono); font-size: .8rem; word-break: break-word; }
+details { margin: .4rem 0; }
+summary { cursor: pointer; }
+.tag { display: inline-block; font-size: .78rem; padding: 0 6px; border-radius: 3px;
+ background: var(--panel); border: 1px solid var(--line); margin-right: 2px; }
+.tag.warn { color: var(--warn); }
+.flag { display: inline-block; font-size: .78rem; padding: 0 6px; border-radius: 3px;
+ background: var(--flag); color: var(--bad); margin-left: 4px; }
+.badge { display: inline-block; font-size: .78rem; padding: 0 6px; border-radius: 3px; }
+.badge.new { background: var(--new); }
+.badge.change { background: var(--change); }
+.note { background: var(--panel); border-left: 3px solid var(--accent); padding: 6px 10px; }
+.circle { background: var(--flag); color: var(--bad); padding: 4px 10px; border-radius: 4px; }
+.timeline > * { margin: .5rem 0; }
+.step { border-left: 3px solid var(--line); padding: 2px 10px; }
+.step .text { white-space: pre-wrap; word-break: break-word; }
+.step-user_message { border-left-color: var(--accent); }
+.step-gate_opened, .step-fact { border-left-color: var(--warn); }
+.run { border: 1px solid var(--line); border-radius: 6px; padding: 4px 10px; background: var(--bg); }
+.run .run { margin: .4rem 0 .4rem 12px; }
+.run > summary { padding: 4px 0; }
+.turn { padding: 4px 0 4px 8px; border-top: 1px dashed var(--line); }
+.turn.flagged { background: var(--flag); }
+.turn-no { font-weight: 600; margin-right: 6px; }
+.turn .reply { font-family: var(--mono); font-size: .82rem; color: var(--muted); word-break: break-word; }
+.told { font-size: .82rem; color: var(--warn); }
+.pager { display: flex; justify-content: space-between; margin-top: 1rem; }
+.msg { border: 1px solid var(--line); border-radius: 6px; padding: 6px 10px; margin: .6rem 0; }
+.msg.first-change { border-color: var(--warn); border-width: 2px; }
+.msg-head { font-size: .88rem; }
+.msg-head .role { font-weight: 700; text-transform: uppercase; letter-spacing: .03em; margin-right: 4px; }
+.role-user { border-left: 4px solid var(--accent); }
+.role-assistant { border-left: 4px solid #6a8f4e; }
+.role-tool { border-left: 4px solid var(--muted); }
+.role-system { border-left: 4px solid var(--warn); }
+ul.from { margin: .3rem 0; padding-left: 1.2rem; font-size: .85rem; }
+ul.from .text { font-family: var(--mono); font-size: .8rem; color: var(--muted); }
+.toolcall { margin: .3rem 0; }
+@media (max-width: 640px) {
+ table.facts th, table.facts td { display: block; width: auto; border: 0; }
+}
diff --git a/nisse-ui/src/nisse_ui/templates/base.html b/nisse-ui/src/nisse_ui/templates/base.html
new file mode 100644
index 0000000..bed8d18
--- /dev/null
+++ b/nisse-ui/src/nisse_ui/templates/base.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+{% block title %}Agents{% endblock %} · nisse-ui
+
+
+
+
+ nisse-ui {% block crumbs %}{% endblock %}
+
+
+{% block main %}{% endblock %}
+
+
+
diff --git a/nisse-ui/src/nisse_ui/templates/call.html b/nisse-ui/src/nisse_ui/templates/call.html
new file mode 100644
index 0000000..a95f994
--- /dev/null
+++ b/nisse-ui/src/nisse_ui/templates/call.html
@@ -0,0 +1,93 @@
+{% extends "base.html" %}
+{% from "macros.html" import src_link, item_href, event_link, flags %}
+{% set c = view.call %}
+{% block title %}Turn {{ view.number }}{% endblock %}
+{% block crumbs %} › {{ src_link(source) }} › {{ c.work_item or "agent" }} › {% if view.run %}{{ view.run.kind }} {{ view.run.role }} › {% endif %}turn {{ view.number }}{% endblock %}
+{% block main %}
+
+Turn {{ view.number }}{% if c.purpose %}: {{ c.purpose }}{% endif %} {{ flags(view.flags) }}
+
+
+ Role {{ c.role or "—" }} Time {{ c.at|when }}, {{ "%.2f"|format(c.seconds) }}s
+ Model asked {{ c.model }} Answered by {{ c.answered_by or "—" }}
+ Tokens {{ c.tokens_in|num }} in · {{ c.tokens_out|num }} out Prompt reuse (server) {{ view.reused or "not reported" }}
+ Estimate {{ c.estimate|num }} tokens (factor {{ c.factor }}) Route {{ c.route or "—" }}, HTTP {{ c.status or "—" }}
+ Outcome {{ c.outcome }} Fitting {% if c.trim %}{{ c.trim|pretty }}{% else %}nothing cut{% endif %}
+
+{% if c.error %}{{ c.error }} {% endif %}
+
+The reply
+{% if view.reply.reasoning %}
+Reasoning ({{ view.reply.reasoning|length|num }} characters) {{ view.reply.reasoning }}
+{% endif %}
+{% if view.reply.text %}{{ view.reply.text }} {% endif %}
+{% for tool, args in view.reply.calls %}
+
+{% endfor %}
+{% if not view.reply.text and not view.reply.calls and not view.reply.reasoning %}No reply recorded.
{% endif %}
+{% if view.reply.finish %}Finish reason: {{ view.reply.finish }}
{% endif %}
+
+The request
+{% if view.settings %}
+{% for k, v in view.settings.items() %}{{ k }} {{ v|pretty }} {% endfor %}
+{% endif %}
+{% if view.tools %}
+{{ view.tools|length }} tools offered{% if view.tools_changed %} changed since the turn before {% endif %}
+{{ view.tools|join(", ") }}
+{% endif %}
+{% if view.compared %}
+Messages marked new weren't in the turn before. first change marks where this request first differs from the last one: prompt reuse can't go past it. The server reused {{ view.reused or "an unreported number of" }} tokens.
+{% endif %}
+{% if view.match_note %}{{ view.match_note }}
{% endif %}
+
+{% for m in view.messages %}
+
+
+ {{ m.role }} {% if m.index >= 0 %} #{{ m.index }} {% endif %}
+ {% if m.new %}new {% endif %}
+ {% if m.first_change %}first change {% endif %}
+ {% if m.tool_call_id %}result of {{ m.tool_call_id }} {% endif %}
+ {{ m.text|length|num }} characters
+
+ {% if m.role == "system" %}
+ System prompt {{ m.text }}
+ {% elif m.text %}{{ m.text }} {% endif %}
+ {% for id, tool, args in m.tool_calls %}
+
+ {% endfor %}
+ {% if m.events %}
+
+ {% for s in m.events %}from {{ event_link(source, s.id) }} {{ s.type|replace("_", " ") }} by {{ s.actor }}: {% for t in s.tags %}{{ t }} {% endfor %} {% endfor %}
+
+ {% endif %}
+
+{% endfor %}
+
+{% if view.unmatched %}
+Events sent{% if view.match_note %}, not matched to messages{% else %} but replaced by a compaction{% endif %}
+
+{% endif %}
+
+{% if view.compacted %}
+Replaced by compaction
+{{ view.compacted|length }} events of the log stood behind a compaction in this request:
+{% for e, comp in view.compacted %}{{ event_link(source, e) }}{% if comp %} → {{ event_link(source, comp) }}{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}
+{% endif %}
+
+{% if view.dropped %}
+Dropped by fitting
+
+{% for id, e in view.dropped %}{{ event_link(source, id) }}{% if e %} {{ e.type|replace("_", " ") }} by {{ e.actor }}: {{ summary(e.type, e.payload)|clip(200) }}{% else %} (not in this store) {% endif %} {% endfor %}
+
+{% endif %}
+
+Raw
+Request JSON, exactly as sent ({{ view.raw_request|length|num }} characters) {{ view.raw_request or "(not recorded)" }}
+Response JSON, as received ({{ view.raw_response|length|num }} characters) {{ view.raw_response or "(not recorded)" }}
+{% endblock %}
diff --git a/nisse-ui/src/nisse_ui/templates/event.html b/nisse-ui/src/nisse_ui/templates/event.html
new file mode 100644
index 0000000..026ef83
--- /dev/null
+++ b/nisse-ui/src/nisse_ui/templates/event.html
@@ -0,0 +1,10 @@
+{% extends "base.html" %}
+{% from "macros.html" import src_link, item_href %}
+{% block title %}Event {{ event.id }}{% endblock %}
+{% block crumbs %} › {{ src_link(source) }} › {{ event.work_item or "agent" }} › event {{ event.id }}{% endblock %}
+{% block main %}
+Event #{{ event.id }}: {{ event.type|replace("_", " ") }}
+{{ event.ts|when }} · by {{ event.actor }} · work item {{ event.work_item or "(agent-level)" }}
+{{ summary(event.type, event.payload) }}
+Payload {{ event.payload|pretty }}
+{% endblock %}
diff --git a/nisse-ui/src/nisse_ui/templates/item.html b/nisse-ui/src/nisse_ui/templates/item.html
new file mode 100644
index 0000000..25c100e
--- /dev/null
+++ b/nisse-ui/src/nisse_ui/templates/item.html
@@ -0,0 +1,37 @@
+{% extends "base.html" %}
+{% from "macros.html" import src_link, run_node, turn_row, step, event_link %}
+{% set title = view.item.title if view.item else "Agent-level log" %}
+{% block title %}{{ title }}{% endblock %}
+{% block crumbs %} › {{ src_link(source) }} › {{ view.item.id if view.item else "agent" }}{% endblock %}
+{% block main %}
+{{ title }}
+{% if view.item %}
+{{ view.item.id }} · {{ view.item.status }}{% if view.item.outcome %} ({{ view.item.outcome }}){% endif %}
+ · position {{ view.item.position }} · opened {{ view.item.opened_at|when }}{% if view.item.closed_at %} · closed {{ view.item.closed_at|when }}{% endif %}
+Item data {{ view.item.data|pretty }}
+{% endif %}
+
+{% if view.record_note %}{{ view.record_note }}
+{% elif view.before_record %}Recording began {{ view.started_at|when }}: steps before then have no requests.
{% endif %}
+
+Steps
+
+{% for kind, obj in view.timeline %}
+ {% if kind == "event" %}{{ step(source, obj) }}
+ {% elif kind == "run" %}{{ run_node(source, obj) }}
+ {% else %}{{ turn_row(source, obj) }}{% endif %}
+{% else %}
+
Nothing recorded yet.
+{% endfor %}
+
+
+The log
+
+ All {{ view.log|length }} events, as stored
+
+ {% for e in view.log %}
+ {{ event_link(source, e.id) }} {{ e.ts|when }} {{ e.actor }} {{ e.type }} {{ summary(e.type, e.payload)|clip(200) }}
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/nisse-ui/src/nisse_ui/templates/items.html b/nisse-ui/src/nisse_ui/templates/items.html
new file mode 100644
index 0000000..7d80a3b
--- /dev/null
+++ b/nisse-ui/src/nisse_ui/templates/items.html
@@ -0,0 +1,26 @@
+{% extends "base.html" %}
+{% from "macros.html" import item_href %}
+{% block title %}{{ source.name }}{% endblock %}
+{% block crumbs %} › {{ source.name }}{% endblock %}
+{% block main %}
+{{ source.name }}
+{{ source.state_db }}
+Agent-level log : what happened outside any work item.
+
+ Work item Title Status Position Opened Updated
+
+ {% for item in items %}
+
+ {{ item.id }} {% if item.id == active %} active {% endif %}
+ {{ item.title }}
+ {{ item.status }}{% if item.outcome %} ({{ item.outcome }}){% endif %}
+ {{ item.position }}
+ {{ item.opened_at|when }}
+ {{ item.updated_at|when }}
+
+ {% else %}
+ No work items.
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/nisse-ui/src/nisse_ui/templates/macros.html b/nisse-ui/src/nisse_ui/templates/macros.html
new file mode 100644
index 0000000..e1ba2c8
--- /dev/null
+++ b/nisse-ui/src/nisse_ui/templates/macros.html
@@ -0,0 +1,48 @@
+{% macro src_link(source) %}{{ source.name }} {% endmacro %}
+
+{% macro item_href(source, item_id) %}/s/{{ source.name|u }}/item/{{ (item_id if item_id is not none else AGENT_LEVEL)|u }}{% endmacro %}
+
+{% macro event_link(source, id) %}{% if id > 0 %}#{{ id }} {% else %}(no id) {% endif %}{% endmacro %}
+
+{% macro flags(names) %}{% for f in names %}{{ f }} {% endfor %}{% endmacro %}
+
+{% macro turn_row(source, t) %}
+
+
turn {{ t.number }}
+
+ {% if t.call.purpose %}{{ t.call.purpose }} {% endif %}
+ {{ t.call.role or "" }} · {{ t.call.model }}{% if t.call.answered_by and t.call.answered_by != t.call.model %} → {{ t.call.answered_by }}{% endif %}
+ · {{ "%.1f"|format(t.call.seconds) }}s · {{ t.call.tokens_in|num }} in / {{ t.call.tokens_out|num }} out
+ {% if t.reused %} · reused {{ t.reused }}{% endif %}
+ {% if t.call.outcome != "answered" %} · {{ t.call.outcome }} {% endif %}
+
+ {{ flags(t.flags) }}
+ {% for told in t.told %}
orchestrator: {{ told|clip(160) }}
{% endfor %}
+
{{ t.reply|reply_line|clip(240) }}
+
+{% endmacro %}
+
+{% macro run_node(source, node) %}
+
+
+ {{ node.run.kind }} {{ node.run.role }}
+ · {{ node.run.outcome or "running" }}
+ · {{ node.run.started_at|when }} → {{ node.run.ended_at|when }}
+ · {{ node.turns|length }} turns
+ {% if node.run.note %} · {{ node.run.note|clip(160) }} {% endif %}
+
+ {% for c in node.circles %}
+ Going in circles: {{ c.kind }} at turns {% for i in c.turns %}{{ i + 1 }}{% if not loop.last %}, {% endif %}{% endfor %}: {{ c.detail }}
+ {% endfor %}
+ {% for kind, obj in node.entries %}
+ {% if kind == "turn" %}{{ turn_row(source, obj) }}{% elif kind == "event" %}{{ step(source, obj) }}{% else %}{{ run_node(source, obj) }}{% endif %}
+ {% endfor %}
+
+{% endmacro %}
+
+{% macro step(source, e) %}
+
+
{{ event_link(source, e.id) }} {{ e.ts|when }} · {{ e.actor }} · {{ e.type|replace("_", " ") }}
+
{{ summary(e.type, e.payload)|clip(600) }}
+
+{% endmacro %}
diff --git a/nisse-ui/src/nisse_ui/templates/problem.html b/nisse-ui/src/nisse_ui/templates/problem.html
new file mode 100644
index 0000000..72616be
--- /dev/null
+++ b/nisse-ui/src/nisse_ui/templates/problem.html
@@ -0,0 +1,6 @@
+{% extends "base.html" %}
+{% block title %}{{ title }}{% endblock %}
+{% block main %}
+{{ title }}
+{{ detail }}
+{% endblock %}
diff --git a/nisse-ui/src/nisse_ui/templates/sources.html b/nisse-ui/src/nisse_ui/templates/sources.html
new file mode 100644
index 0000000..6107547
--- /dev/null
+++ b/nisse-ui/src/nisse_ui/templates/sources.html
@@ -0,0 +1,21 @@
+{% extends "base.html" %}
+{% from "macros.html" import src_link %}
+{% block title %}Sources{% endblock %}
+{% block main %}
+Agents and recorded runs
+{% if not rows %}No sources. Start with --source NAME=STATE_DB or --runs DIR.
{% endif %}
+
+ Source Kind Work items Call record State store
+
+ {% for r in rows %}
+
+ {{ src_link(r.source) }}
+ {{ r.source.kind }}
+ {% if r.count is not none %}{{ r.count }}{% else %}{{ r.note }} {% endif %}
+ {{ "yes" if r.record else "none" }}
+ {{ r.source.state_db }}
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/nisse-ui/src/nisse_ui/walk.py b/nisse-ui/src/nisse_ui/walk.py
new file mode 100644
index 0000000..06f2fd1
--- /dev/null
+++ b/nisse-ui/src/nisse_ui/walk.py
@@ -0,0 +1,543 @@
+"""What the pages show, built from the two stores (X6): an item's
+walkthrough (its steps, runs and turns) and one turn read against the log it
+was made from. Pure reading: nothing here writes."""
+
+import json
+from dataclasses import dataclass, field
+from typing import Any
+
+from nisse_core.log.canonical import StoredEvent, from_payload
+from nisse_core.loop.circles import Circle, Turn, find_circles
+from nisse_core.models import calls
+from nisse_core.models.render import OpenAIChatRenderer
+from nisse_ui.sources import Event, Item, StateReader
+
+# The log events shown as steps between runs: what people and the
+# orchestrator said and decided. A model's own turns are shown as turns.
+STEP_TYPES = {
+ "user_message",
+ "fact",
+ "gate_opened",
+ "plan_submitted",
+ "delegation_started",
+ "delegation_completed",
+ "compaction",
+}
+_TURN_TYPES = {"assistant_text", "tool_call", "tool_result", "action_verdict"}
+
+
+# --- a model's reply ------------------------------------------------------------
+
+
+@dataclass(frozen=True)
+class Reply:
+ text: str = ""
+ reasoning: str = ""
+ calls: tuple[tuple[str, str], ...] = () # (tool, arguments as sent)
+ finish: str | None = None
+
+
+def _text(content: Any) -> str:
+ if content is None:
+ return ""
+ if isinstance(content, str):
+ return content
+ if isinstance(content, list):
+ parts = []
+ for part in content:
+ if isinstance(part, dict) and isinstance(part.get("text"), str):
+ parts.append(part["text"])
+ else:
+ parts.append(json.dumps(part, ensure_ascii=False))
+ return "\n".join(parts)
+ return json.dumps(content, ensure_ascii=False)
+
+
+def _arguments(value: Any) -> str:
+ return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
+
+
+def reply_of(response: Any) -> Reply:
+ """The reply in a raw response, from either route: the OpenAI-shaped chat
+ route or Anthropic's messages route. Anything else is no reply."""
+ if not isinstance(response, dict):
+ return Reply()
+ choices = response.get("choices")
+ if isinstance(choices, list) and choices and isinstance(choices[0], dict):
+ message = choices[0].get("message") or {}
+ reasoning = message.get("reasoning_content") or message.get("reasoning") or ""
+ tool_calls = []
+ for call in message.get("tool_calls") or []:
+ function = (call.get("function") or {}) if isinstance(call, dict) else {}
+ tool_calls.append(
+ (str(function.get("name", "?")), _arguments(function.get("arguments")))
+ )
+ return Reply(
+ text=_text(message.get("content")),
+ reasoning=_text(reasoning),
+ calls=tuple(tool_calls),
+ finish=choices[0].get("finish_reason"),
+ )
+ blocks = response.get("content")
+ if isinstance(blocks, list):
+ texts, thinking, tool_calls = [], [], []
+ for block in blocks:
+ if not isinstance(block, dict):
+ continue
+ match block.get("type"):
+ case "text":
+ texts.append(str(block.get("text", "")))
+ case "thinking":
+ thinking.append(str(block.get("thinking", "")))
+ case "tool_use":
+ tool_calls.append(
+ (str(block.get("name", "?")), _arguments(block.get("input")))
+ )
+ return Reply(
+ text="\n".join(texts),
+ reasoning="\n".join(thinking),
+ calls=tuple(tool_calls),
+ finish=response.get("stop_reason"),
+ )
+ return Reply()
+
+
+# --- an item's walkthrough --------------------------------------------------------
+
+
+@dataclass
+class TurnRow:
+ """One call, as a line of the walkthrough."""
+
+ call: dict[str, Any]
+ number: int # within its run, from 1
+ reply: Reply
+ told: tuple[str, ...] # corrections given since the turn before
+ flags: list[str] = field(default_factory=list)
+
+ @property
+ def reused(self) -> str | None:
+ return reuse(self.call)
+
+
+@dataclass
+class RunNode:
+ run: dict[str, Any]
+ entries: list[tuple[str, Any]] = field(default_factory=list) # turns, runs
+ turns: list[TurnRow] = field(default_factory=list)
+ circles: list[Circle] = field(default_factory=list)
+
+
+@dataclass
+class Walkthrough:
+ item: Item | None # None: the agent-level log
+ timeline: list[
+ tuple[str, Any]
+ ] # ("event", Event) | ("run", RunNode) | ("turn", TurnRow)
+ log: list[Event]
+ record_note: str | None # why there are no requests, when there aren't
+ started_at: str | None
+ before_record: bool # some of this work happened before recording began
+
+
+def reuse(call: dict[str, Any]) -> str | None:
+ """Prompt reuse as the server counted it: cached tokens of the prompt."""
+ cached, fresh = call.get("cache_n"), call.get("prompt_n")
+ if isinstance(cached, int) and isinstance(fresh, int):
+ return f"{cached:,} of {cached + fresh:,}"
+ return None
+
+
+def _corrections(
+ reader: calls.CallReader, call: dict[str, Any], before: set[int]
+) -> tuple[str, ...]:
+ """The orchestrator's statements first sent in this call: a correction
+ is appended to the log each time it's given, so it is new each time."""
+ told = []
+ for event_id, actor, kind, digest in call["sent"]:
+ if kind == "fact" and actor == "orchestrator" and event_id not in before:
+ try:
+ told.append(str(reader.blob(digest).get("text", "")))
+ except (KeyError, AttributeError):
+ continue
+ return tuple(told)
+
+
+def _response(reader: calls.CallReader, call: dict[str, Any]) -> Any:
+ try:
+ return reader.response(call)
+ except KeyError:
+ return None
+
+
+def walkthrough(
+ state: StateReader,
+ reader: calls.CallReader | None,
+ item_id: str | None,
+ *,
+ record_note: str | None = None,
+) -> Walkthrough:
+ item = state.item(item_id) if item_id is not None else None
+ log = state.events(item_id)
+ steps = [e for e in log if e.type in STEP_TYPES or e.type not in _TURN_TYPES]
+ timeline: list[tuple[str, str, Any]] = []
+ started_at = None
+ if reader is None:
+ record_note = record_note or "No call record: requests weren't recorded."
+ else:
+ started_at = reader.started_at
+ nodes = {
+ r["id"]: RunNode(r) for r in reader.runs() if r["work_item"] == item_id
+ }
+ seen: dict[str | None, set[int]] = {}
+ numbers: dict[str | None, int] = {}
+ loose: list[TurnRow] = []
+ for call in reader.calls_for(item_id):
+ run = call["run"] if call["run"] in nodes else None
+ before = seen.setdefault(run, set())
+ numbers[run] = numbers.get(run, 0) + 1
+ row = TurnRow(
+ call=call,
+ number=numbers[run],
+ reply=reply_of(_response(reader, call)),
+ told=_corrections(reader, call, before),
+ )
+ before.update(entry[0] for entry in call["sent"])
+ if run is None:
+ loose.append(row)
+ else:
+ nodes[run].turns.append(row)
+ # A step taken while a run was going on (a correction, a fact) is
+ # shown inside the innermost run it happened during.
+ inside: dict[str, list[tuple[str, str, Any]]] = {}
+ for e in steps:
+ during = [n for n in nodes.values() if _during(n.run, e.ts)]
+ if during:
+ innermost = max(during, key=lambda n: n.run["started_at"])
+ inside.setdefault(innermost.run["id"], []).append(("event", e.ts, e))
+ else:
+ timeline.append(("event", e.ts, e))
+ steps = []
+ for node in nodes.values():
+ node.circles = find_circles(
+ [Turn(t.reply.text, t.reply.calls, t.told) for t in node.turns]
+ )
+ for circle in node.circles:
+ for i in circle.turns:
+ node.turns[i].flags.append(circle.kind)
+ children = [n for n in nodes.values() if n.run["parent"] == node.run["id"]]
+ entries = [("turn", t.call["at"], t) for t in node.turns]
+ entries += [("run", c.run["started_at"], c) for c in children]
+ entries += inside.get(node.run["id"], [])
+ node.entries = [(kind, obj) for kind, _, obj in sorted(entries, key=_when)]
+ for node in nodes.values():
+ if node.run["parent"] not in nodes:
+ timeline.append(("run", node.run["started_at"], node))
+ timeline += [("turn", t.call["at"], t) for t in loose]
+ timeline += [("event", e.ts, e) for e in steps]
+ ordered = [(kind, obj) for kind, _, obj in sorted(timeline, key=_when)]
+ before_record = reader is None or (
+ started_at is not None and bool(log) and log[0].ts < started_at
+ )
+ return Walkthrough(item, ordered, log, record_note, started_at, before_record)
+
+
+def _during(run: dict[str, Any], ts: str) -> bool:
+ ended = run.get("ended_at")
+ return run["started_at"] <= ts and (ended is None or ts <= ended)
+
+
+def _when(entry: tuple[str, str, Any]) -> str:
+ return entry[1] or ""
+
+
+# --- one turn, read against its log -----------------------------------------------
+
+
+@dataclass
+class SentEvent:
+ id: int
+ actor: str
+ type: str
+ payload: Any
+ tags: list[str]
+ log: Event | None
+
+
+@dataclass
+class Message:
+ index: int
+ role: str
+ text: str
+ tool_calls: list[tuple[str, str, str]] # (id, tool, arguments)
+ tool_call_id: str | None
+ events: list[SentEvent] = field(default_factory=list)
+ new: bool = False # not in the turn before
+ first_change: bool = False # where this request first differs from the last
+
+
+@dataclass
+class TurnView:
+ call: dict[str, Any]
+ number: int
+ run: dict[str, Any] | None
+ previous: int | None
+ following: int | None
+ request: dict[str, Any] | None
+ response: Any
+ reply: Reply
+ messages: list[Message]
+ unmatched: list[SentEvent] # sent events not matched to a message
+ match_note: str | None
+ dropped: list[tuple[int, Event | None]]
+ compacted: list[tuple[int, int | None]]
+ tools: list[str]
+ tools_changed: bool
+ settings: dict[str, Any] # the request's other fields: tool_choice, limits
+ compared: bool # whether there was a turn before to compare with
+ flags: list[str]
+ raw_request: str
+ raw_response: str
+
+ @property
+ def reused(self) -> str | None:
+ return reuse(self.call)
+
+
+def _fields(call: dict[str, Any] | None) -> dict[str, Any]:
+ if not call or not call.get("request"):
+ return {}
+ try:
+ return dict(json.loads(call["request"])["fields"])
+ except (ValueError, KeyError, TypeError):
+ return {}
+
+
+def _tags(
+ entry: list[Any], payload: Any, log: Event | None, call: dict[str, Any]
+) -> list[str]:
+ event_id, _, kind, _ = entry
+ tags: list[str] = []
+ cut = call["shortened"].get(str(event_id))
+ if event_id == 0:
+ tags.append("made for this call, not in the log")
+ elif kind == "compaction" and isinstance(payload, dict):
+ first, last = (payload.get("replaces") or [None, None])[:2]
+ tags.append(f"compaction of events {first}–{last}")
+ elif log is None:
+ tags.append("not in this state store")
+ elif log.payload == payload and log.type == kind:
+ tags.append("unchanged")
+ else:
+ # A role's view says another's work as a labelled statement (L6):
+ # a tool step narrated, a fact or someone's message labelled.
+ source = payload.get("source", "") if isinstance(payload, dict) else ""
+ viewed = kind == "user_message" and str(source).startswith("view:")
+ was = f"{log.type.replace('_', ' ')} by {log.actor}"
+ if viewed and log.type in ("tool_call", "tool_result"):
+ tags.append(f"narrated from {was}")
+ elif viewed:
+ tags.append(
+ "labelled for this role"
+ + ("" if log.type == "user_message" else f" (a {was})")
+ )
+ elif log.type != kind:
+ tags.append(f"rewritten from {was}")
+ elif cut is None:
+ tags.append("changed for this call")
+ if cut is not None:
+ tags.append(f"shortened by {int(cut):,} characters")
+ return tags
+
+
+def turn(state: StateReader, reader: calls.CallReader, call_id: int) -> TurnView | None:
+ call = reader.call(call_id)
+ if call is None:
+ return None
+ run = next((r for r in reader.runs() if r["id"] == call["run"]), None)
+ siblings = (
+ reader.calls(run=call["run"])
+ if call["run"]
+ else [c for c in reader.calls_for(call["work_item"]) if not c["run"]]
+ )
+ at = next(i for i, c in enumerate(siblings) if c["id"] == call_id)
+ before = siblings[at - 1] if at > 0 else None
+ after = siblings[at + 1] if at + 1 < len(siblings) else None
+
+ try:
+ request = reader.request(call)
+ except KeyError:
+ request = None
+ response = _response(reader, call)
+ reply = reply_of(response)
+
+ logged = state.events_by_id([entry[0] for entry in call["sent"]])
+ dropped = state.events_by_id(call["dropped"])
+ sent: list[SentEvent] = []
+ for entry in call["sent"]:
+ try:
+ payload = reader.blob(entry[3])
+ except KeyError:
+ payload = None
+ log = logged.get(entry[0])
+ sent.append(
+ SentEvent(
+ entry[0],
+ entry[1],
+ entry[2],
+ payload,
+ _tags(entry, payload, log, call),
+ log,
+ )
+ )
+
+ raw_messages = (request or {}).get("messages")
+ messages: list[Message] = []
+ if isinstance(raw_messages, list):
+ for i, m in enumerate(raw_messages):
+ m = m if isinstance(m, dict) else {"role": "?", "content": m}
+ calls_made = []
+ for c in m.get("tool_calls") or []:
+ function = (c.get("function") or {}) if isinstance(c, dict) else {}
+ calls_made.append(
+ (
+ str(c.get("id", "")) if isinstance(c, dict) else "",
+ str(function.get("name", "?")),
+ _arguments(function.get("arguments")),
+ )
+ )
+ if isinstance(m.get("content"), list): # Anthropic content blocks
+ for block in m["content"]:
+ if isinstance(block, dict) and block.get("type") == "tool_use":
+ calls_made.append(
+ (
+ str(block.get("id", "")),
+ str(block.get("name", "?")),
+ _arguments(block.get("input")),
+ )
+ )
+ messages.append(
+ Message(
+ index=i,
+ role=str(m.get("role", "?")),
+ text=_text(m.get("content")),
+ tool_calls=calls_made,
+ tool_call_id=m.get("tool_call_id"),
+ )
+ )
+
+ unmatched, note = _match(call, request, sent, messages)
+
+ mine, theirs = _fields(call), _fields(before)
+ compared = bool(theirs)
+ if compared:
+ old = theirs.get("messages") or []
+ new = mine.get("messages") or []
+ old_set = set(old)
+ for message, digest in zip(messages, new, strict=False):
+ message.new = digest not in old_set
+ change = next(
+ (i for i, (a, b) in enumerate(zip(old, new, strict=False)) if a != b),
+ min(len(old), len(new)),
+ )
+ if change < len(messages):
+ messages[change].first_change = True
+
+ tools_raw = (request or {}).get("tools") or []
+ tools = []
+ for t in tools_raw if isinstance(tools_raw, list) else []:
+ if isinstance(t, dict):
+ tools.append(
+ str((t.get("function") or {}).get("name") or t.get("name", "?"))
+ )
+ settings = {
+ k: v
+ for k, v in (request or {}).items()
+ if k not in ("messages", "tools", "system")
+ }
+ if isinstance(request, dict) and "system" in request:
+ messages.insert(
+ 0,
+ Message(
+ index=-1,
+ role="system",
+ text=_text(request["system"]),
+ tool_calls=[],
+ tool_call_id=None,
+ ),
+ )
+
+ flags = []
+ if run is not None:
+ node_turns = [
+ Turn(r.text, r.calls, ())
+ for r in (reply_of(_response(reader, c)) for c in siblings)
+ ]
+ flags = sorted({c.kind for c in find_circles(node_turns) if at in c.turns})
+
+ return TurnView(
+ call=call,
+ number=at + 1,
+ run=run,
+ previous=before["id"] if before else None,
+ following=after["id"] if after else None,
+ request=request,
+ response=response,
+ reply=reply,
+ messages=messages,
+ unmatched=unmatched,
+ match_note=note,
+ dropped=[(i, dropped.get(i)) for i in call["dropped"]],
+ compacted=[(int(k), v) for k, v in call["compacted"].items()],
+ tools=tools,
+ tools_changed=compared and mine.get("tools") != theirs.get("tools"),
+ settings=settings,
+ compared=compared,
+ flags=flags,
+ raw_request=json.dumps(request, indent=2, ensure_ascii=False)
+ if request is not None
+ else "",
+ raw_response=json.dumps(response, indent=2, ensure_ascii=False)
+ if response is not None
+ else "",
+ )
+
+
+def _match(
+ call: dict[str, Any],
+ request: dict[str, Any] | None,
+ sent: list[SentEvent],
+ messages: list[Message],
+) -> tuple[list[SentEvent], str | None]:
+ """Each sent event beside the message it went into, found with the same
+ renderer that built the request; when that can't be done exactly, the
+ events are listed apart, with why."""
+ if not sent:
+ return [], None
+ if request is None:
+ return sent, "The request wasn't recorded (the call failed before it)."
+ if call.get("route") != "chat":
+ return sent, "Messages-route requests are shown beside their events, unmatched."
+ try:
+ events = [
+ StoredEvent(id=s.id, ts="", event=from_payload(s.type, s.payload))
+ for s in sent
+ ]
+ except (ValueError, TypeError):
+ return sent, "Some events didn't read as this version's event types."
+ first = messages[0] if messages else None
+ system = first.text if first is not None and first.role == "system" else None
+ renderer = OpenAIChatRenderer()
+ expected = renderer.render(events, None, "m", system)["messages"]
+ if len(expected) != len(messages):
+ return sent, (
+ f"Rendering the sent events gives {len(expected)} messages, the request "
+ f"has {len(messages)}; shown apart."
+ )
+ unmatched = []
+ for s, index in zip(sent, renderer.message_indexes(events, system), strict=True):
+ if index is None:
+ unmatched.append(s)
+ else:
+ messages[index].events.append(s)
+ return unmatched, None
diff --git a/nisse-ui/tests/test_ui_contract.py b/nisse-ui/tests/test_ui_contract.py
new file mode 100644
index 0000000..6b14018
--- /dev/null
+++ b/nisse-ui/tests/test_ui_contract.py
@@ -0,0 +1,102 @@
+"""The page and the agents are released together (X6): these fail in the PR
+that would break the page, not in the operator's browser."""
+
+import threading
+from pathlib import Path
+
+import httpx
+import pytest
+from ui_fixtures import recorded, store_at
+
+from nisse_core.models import calls
+from nisse_core.state.store import StateStore as CoreStore
+from nisse_dev.state import StateStore as DevStore
+from nisse_review.state import StateStore as ReviewStore
+from nisse_ui import main as ui_main
+from nisse_ui.app import App
+from nisse_ui.sources import KNOWN_SCHEMAS, discover
+
+
+@pytest.mark.parametrize("store", [CoreStore, DevStore, ReviewStore])
+def test_every_agents_schema_is_one_the_page_knows(store: type[CoreStore]) -> None:
+ # An agent that moves its schema on must teach the page too.
+ assert store.SCHEMA_VERSION in KNOWN_SCHEMAS
+
+
+@pytest.mark.parametrize("store_class", [DevStore, ReviewStore])
+def test_each_agents_own_store_opens(
+ tmp_path: Path, store_class: type[CoreStore]
+) -> None:
+ store = store_at(tmp_path, store_class)
+ store.open_work_item("i1", title="Agent item", position="NEW", cause="t")
+ store.close()
+ app = App(discover([("a", tmp_path / "state.db")], []))
+ assert app.handle("/s/a/").status == 200
+ assert "Agent item" in app.handle("/s/a/item/i1").body.decode()
+
+
+def test_runs_the_benchmark_kept_are_sources(tmp_path: Path) -> None:
+ # As bench --keep-runs leaves them: /-/state.db + record.
+ for name in ("fix-bug-1a2b3c4d", "trace-bug-5e6f7a8b"):
+ _, store = recorded(tmp_path / "kept" / name)
+ store.close()
+ (tmp_path / "kept" / "not-a-run").mkdir()
+ sources = discover([], [tmp_path / "kept"])
+ assert sorted(s.name for s in sources) == [
+ "kept.fix-bug-1a2b3c4d",
+ "kept.trace-bug-5e6f7a8b",
+ ]
+ assert all(s.kind == "run" and s.calls_db.is_file() for s in sources)
+ assert sources[0].calls_db == calls.path_for(sources[0].state_db)
+
+
+def test_source_names_are_safe_and_unique(tmp_path: Path) -> None:
+ sources = discover(
+ [("dev agent/1", tmp_path / "a"), ("dev agent/1", tmp_path / "b")], []
+ )
+ assert [s.name for s in sources] == ["dev-agent-1", "dev-agent-1-2"]
+
+
+def test_the_server_listens_on_this_machine_and_sends_the_headers(
+ tmp_path: Path,
+) -> None:
+ source, store = recorded(tmp_path / "a")
+ store.close()
+ server = ui_main.ThreadingHTTPServer(
+ ("127.0.0.1", 0), ui_main.handler_for(App([source]))
+ )
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ try:
+ port = server.server_address[1]
+ response = httpx.get(f"http://127.0.0.1:{port}/s/agent/item/w1")
+ assert response.status_code == 200
+ assert "Fix the parser" in response.text
+ assert "default-src 'none'" in response.headers["content-security-policy"]
+ assert response.headers["x-content-type-options"] == "nosniff"
+ assert response.headers["server"].startswith("nisse-ui")
+ finally:
+ server.shutdown()
+ server.server_close()
+
+
+def test_the_command_line_defaults_to_this_machine_only(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ assert ui_main._source("dev=/var/lib/nisse-dev/state.db") == (
+ "dev",
+ Path("/var/lib/nisse-dev/state.db"),
+ )
+ with pytest.raises(Exception, match="NAME=STATE_DB"):
+ ui_main._source("no-equals")
+
+ class Bound(Exception):
+ pass
+
+ def server(address: tuple[str, int], handler: object) -> None:
+ raise Bound(address)
+
+ monkeypatch.setattr(ui_main, "ThreadingHTTPServer", server)
+ with pytest.raises(Bound) as bound:
+ ui_main.main([])
+ assert bound.value.args[0] == ("127.0.0.1", 8765)
diff --git a/nisse-ui/tests/test_ui_pages.py b/nisse-ui/tests/test_ui_pages.py
new file mode 100644
index 0000000..d5bbf1d
--- /dev/null
+++ b/nisse-ui/tests/test_ui_pages.py
@@ -0,0 +1,316 @@
+"""The walkthrough (X6, M6.6b): an item's steps, its runs' turns, and each
+turn's request read against the log it was made from."""
+
+import hashlib
+import sqlite3
+from pathlib import Path
+
+import pytest
+from ui_fixtures import TOKEN, circling, recorded
+
+from nisse_core.log.canonical import UserMessage
+from nisse_core.models import calls
+from nisse_ui import walk
+from nisse_ui.app import App
+from nisse_ui.sources import Source, StateReader, call_record
+
+
+@pytest.fixture
+def agent(tmp_path: Path) -> Source:
+ source, store = recorded(tmp_path / "agent")
+ store.close()
+ return source
+
+
+def page(app: App, path: str, status: int = 200) -> str:
+ response = app.handle(path)
+ assert response.status == status, response.body.decode()[:500]
+ return response.body.decode()
+
+
+def view(source: Source, call_id: int) -> walk.TurnView:
+ with StateReader(source.state_db) as state, call_record(source) as reader:
+ assert reader is not None
+ found = walk.turn(state, reader, call_id)
+ assert found is not None
+ return found
+
+
+def steps(source: Source, item: str | None) -> walk.Walkthrough:
+ with StateReader(source.state_db) as state, call_record(source) as reader:
+ return walk.walkthrough(state, reader, item)
+
+
+# --- the way in -------------------------------------------------------------------
+
+
+def test_the_sources_and_an_agents_items_are_listed(agent: Source) -> None:
+ app = App([agent])
+ home = page(app, "/")
+ assert "/s/agent/" in home and ">1<" in home # one work item
+ items = page(app, "/s/agent/")
+ assert "Fix the parser" in items and "/s/agent/item/w1" in items
+ assert "/s/agent/item/-" in items # the agent-level log
+
+
+# --- the walkthrough --------------------------------------------------------------
+
+
+def test_an_items_steps_hold_its_runs_turns_and_what_the_orchestrator_said(
+ agent: Source,
+) -> None:
+ found = steps(agent, "w1")
+ kinds = [kind for kind, _ in found.timeline]
+ assert kinds == ["event", "event", "run"] # the restatement, "go", the run
+ [run] = [obj for kind, obj in found.timeline if kind == "run"]
+ assert run.run["outcome"] == "wait"
+ assert [t.number for t in run.turns] == [1, 2, 3]
+ # The correction for the prose reply came during the run: it's shown
+ # inside it, between the turns it fell between.
+ inner = [kind for kind, _ in run.entries]
+ assert inner == ["turn", "turn", "event", "turn"]
+ assert any("prose" in told for told in run.turns[2].told)
+ assert run.turns[0].reply.calls == (("read_file", '{"path": "parser.py"}'),)
+ assert run.turns[1].reused == "280 of 340"
+ html = page(App([agent]), "/s/agent/item/w1")
+ assert "turn 3" in html and "reused 330 of 350" in html
+
+
+def test_agent_level_calls_have_their_own_log(agent: Source) -> None:
+ found = steps(agent, None)
+ [run] = [obj for kind, obj in found.timeline if kind == "run"]
+ assert (run.run["kind"], run.run["role"]) == ("classification", "classifier")
+ assert "harmless" in page(App([agent]), "/s/agent/item/-")
+
+
+def test_work_from_before_recording_has_its_steps_but_no_requests(
+ tmp_path: Path,
+) -> None:
+ source, store = recorded(tmp_path / "a")
+ # The agent restarts: a fresh, empty record; the log persists.
+ calls.CallRecorder(source.calls_db).close()
+ store.close()
+ found = steps(source, "w1")
+ assert found.before_record
+ # The restatement, "go" and the correction: steps, with no runs to hold them.
+ assert [kind for kind, _ in found.timeline] == ["event", "event", "event"]
+ html = page(App([source]), "/s/agent/item/w1")
+ assert "Recording began" in html
+ assert "All 8 events" in html # the log is all still there
+
+
+def test_without_a_call_record_the_log_is_shown(tmp_path: Path) -> None:
+ source, store = recorded(tmp_path / "a")
+ store.close()
+ source.calls_db.unlink()
+ html = page(App([source]), "/s/agent/item/w1")
+ assert "No call record" in html and "All 8 events" in html
+ page(App([source]), "/s/agent/call/1", 404)
+
+
+def test_a_run_going_in_circles_is_flagged(tmp_path: Path) -> None:
+ source = circling(tmp_path / "c")
+ found = steps(source, "pr-7")
+ [run] = [obj for kind, obj in found.timeline if kind == "run"]
+ [circle] = run.circles
+ assert (circle.kind, circle.turns) == ("repeated reply", (0, 1, 2, 3))
+ assert run.turns[0].flags == ["repeated reply"] and not run.turns[4].flags
+ assert "Going in circles: repeated reply at turns 1, 2, 3, 4" in page(
+ App([source]), "/s/circling/item/pr-7"
+ )
+ assert view(source, 2).flags == ["repeated reply"]
+
+
+# --- one turn ---------------------------------------------------------------------
+
+
+def test_each_message_is_shown_beside_the_log_events_it_came_from(
+ agent: Source,
+) -> None:
+ turn = view(agent, 2)
+ assert turn.match_note is None and turn.unmatched == []
+ roles = [m.role for m in turn.messages]
+ assert roles == ["system", "user", "assistant", "tool"]
+ # The restatement and "go" fold into one user message.
+ assert [s.id for s in turn.messages[1].events] == [1, 2]
+ assert [s.id for s in turn.messages[2].events] == [3]
+ assert [s.id for s in turn.messages[3].events] == [4]
+ assert all(s.tags == ["unchanged"] for m in turn.messages for s in m.events)
+ assert calls.NOTICE in turn.messages[0].text
+
+
+def test_what_changed_since_the_turn_before_is_marked(agent: Source) -> None:
+ first, second = view(agent, 1), view(agent, 2)
+ assert not first.compared and not any(m.new for m in first.messages)
+ assert second.compared
+ assert [m.new for m in second.messages] == [False, False, True, True]
+ assert [m.first_change for m in second.messages] == [False, False, True, False]
+ assert not second.tools_changed
+ assert (second.previous, second.following) == (1, 3)
+
+
+def test_the_turn_page_shows_the_reply_reasoning_and_raw_json(agent: Source) -> None:
+ html = page(App([agent]), "/s/agent/call/1")
+ assert "First, read the parser." in html # the reasoning
+ assert "Request JSON, exactly as sent" in html
+ assert "local-backing" in html # the model that answered
+ assert "reused" not in html or "0 of 300" in html
+
+
+def test_how_rendering_changed_an_event_is_named() -> None:
+ call = {"shortened": {"5": 1200}}
+ logged = walk.Event(5, "t", "tool_result", {"result": "long"}, "w", "driver")
+ sent = {"result": "lo"}
+ assert walk._tags([5, "driver", "tool_result", "h"], sent, logged, call) == [
+ "shortened by 1,200 characters"
+ ]
+ told = walk.Event(6, "t", "tool_call", {"tool": "x"}, "w", "helper")
+ narrated = {"text": "x → ok", "source": "view:helper"}
+ assert walk._tags(
+ [6, "helper", "user_message", "h"], narrated, told, {"shortened": {}}
+ ) == ["narrated from tool call by helper"]
+ fact = walk.Event(8, "t", "fact", {"text": "agreed"}, "w", "orchestrator")
+ as_said = {"text": "[orchestrator] agreed", "source": "view:orchestrator"}
+ assert walk._tags(
+ [8, "orchestrator", "user_message", "h"], as_said, fact, {"shortened": {}}
+ ) == ["labelled for this role (a fact by orchestrator)"]
+ said = walk.Event(
+ 7, "t", "user_message", {"text": "hi", "source": "m"}, "w", "operator"
+ )
+ labelled = {"text": "[operator, matrix] hi", "source": "view:operator"}
+ assert walk._tags(
+ [7, "operator", "user_message", "h"], labelled, said, {"shortened": {}}
+ ) == ["labelled for this role"]
+ assert walk._tags(
+ [0, "harness", "fact", "h"], {"text": "x"}, None, {"shortened": {}}
+ ) == ["made for this call, not in the log"]
+ assert walk._tags(
+ [9, "driver", "compaction", "h"], {"replaces": [2, 8]}, None, {"shortened": {}}
+ ) == ["compaction of events 2–8"]
+
+
+def test_a_reply_reads_from_either_route() -> None:
+ chat = {
+ "choices": [
+ {
+ "message": {
+ "content": "ok",
+ "reasoning_content": "hm",
+ "tool_calls": [{"function": {"name": "f", "arguments": "{}"}}],
+ },
+ "finish_reason": "tool_calls",
+ }
+ ]
+ }
+ assert walk.reply_of(chat) == walk.Reply("ok", "hm", (("f", "{}"),), "tool_calls")
+ messages = {
+ "content": [
+ {"type": "thinking", "thinking": "hm"},
+ {"type": "text", "text": "ok"},
+ {"type": "tool_use", "name": "f", "input": {"a": 1}},
+ ],
+ "stop_reason": "tool_use",
+ }
+ assert walk.reply_of(messages) == walk.Reply(
+ "ok", "hm", (("f", '{"a": 1}'),), "tool_use"
+ )
+ assert walk.reply_of("garbage") == walk.Reply()
+
+
+# --- untrusted text and read-only -------------------------------------------------
+
+
+def test_everything_shown_is_escaped_and_no_script_may_run(agent: Source) -> None:
+ app = App([agent])
+ for path in (
+ "/s/agent/item/w1",
+ "/s/agent/call/2",
+ "/s/agent/call/3",
+ "/s/agent/event/4",
+ ):
+ response = app.handle(path)
+ html = response.body.decode()
+ assert ""
+READ = tool("read_file", "read a file", path="the file")
+POST = tool("post_comment", "post a comment", body="the comment")
+FINISH = tool("finish", "finish the work", summary="what was done")
+
+
+def reply(
+ content: str = "",
+ *,
+ call: tuple[str, dict[str, Any]] | None = None,
+ reasoning: str = "",
+ timings: dict[str, int] | None = None,
+) -> dict[str, Any]:
+ message: dict[str, Any] = {"role": "assistant", "content": content}
+ if reasoning:
+ message["reasoning_content"] = reasoning
+ if call:
+ message["tool_calls"] = [
+ {
+ "id": f"x{abs(hash(json.dumps(call)))}",
+ "type": "function",
+ "function": {"name": call[0], "arguments": json.dumps(call[1])},
+ }
+ ]
+ data: dict[str, Any] = {
+ "model": "local-backing",
+ "choices": [{"message": message, "finish_reason": "stop"}],
+ "usage": {"prompt_tokens": 120, "completion_tokens": 7},
+ }
+ if timings is not None:
+ data["timings"] = timings
+ return data
+
+
+class Server:
+ """A fake LiteLLM answering each request in turn."""
+
+ def __init__(self, *answers: dict[str, Any]) -> None:
+ self.answers = list(answers)
+
+ def __call__(self, request: httpx.Request) -> httpx.Response:
+ data = self.answers.pop(0) if self.answers else reply("ok")
+ return httpx.Response(200, json=data)
+
+
+def client(server: Server, recorder: calls.CallRecorder) -> BudgetedClient:
+ inner = LiteLLMClient(
+ "https://litellm.test", TOKEN, transport=httpx.MockTransport(server)
+ )
+ return BudgetedClient(inner, lambda m: (32768, 1024), call_recorder=recorder)
+
+
+def store_at(root: Path, store_class: type[StateStore] = StateStore) -> StateStore:
+ return store_class(root / "state.db", oplog=OpLog("test", io.StringIO()))
+
+
+def recorded(root: Path) -> tuple[Source, StateStore]:
+ """One work item, one driver run of three turns: a tool call whose
+ result is hostile text, a prose reply the driver corrects, then the
+ finish. And one agent-level call. The store stays open, as a live
+ agent's does."""
+ root.mkdir(parents=True, exist_ok=True)
+ recorder = calls.CallRecorder(calls.path_for(root / "state.db"))
+ store = store_at(root)
+ store.open_work_item("w1", title="Fix the parser", position="RUNNING", cause="t")
+ store.transcript("w1", "orchestrator").append(
+ Fact(text="The operator agreed to this restatement: fix the parser.")
+ )
+ store.transcript("w1", "operator").append(UserMessage(text="go", source="m:1"))
+ server = Server(
+ reply(
+ call=("read_file", {"path": "parser.py"}),
+ reasoning="First, read the parser.",
+ timings={"cache_n": 0, "prompt_n": 300},
+ ),
+ reply(
+ "I believe the parser is fine now.",
+ timings={"cache_n": 280, "prompt_n": 60},
+ ),
+ reply(
+ call=("finish", {"summary": "fixed"}),
+ timings={"cache_n": 330, "prompt_n": 20},
+ ),
+ )
+ Driver(client(server, recorder), "local").run(
+ store.transcript("w1", "driver"),
+ system="You are the driver.",
+ tools=[READ, FINISH],
+ waiting=("finish",),
+ workspace=None,
+ handlers={"read_file": lambda args: (f"contents {HOSTILE}", True)},
+ )
+ store.transcript(None, "operator").append(UserMessage(text="hello", source="m:9"))
+ with calls.run(recorder, work_item=None, role="classifier", kind="classification"):
+ client(Server(reply("harmless")), recorder).complete(
+ store.transcript(None, "operator").read(), None, "local", system="Classify."
+ )
+ recorder.close()
+ return Source("agent", root / "state.db", "agent"), store
+
+
+def circling(root: Path) -> Source:
+ """A run that posts the same comment over and over, as Flash-Next did
+ on 2026-10-24, until the finish."""
+ root.mkdir(parents=True, exist_ok=True)
+ recorder = calls.CallRecorder(calls.path_for(root / "state.db"))
+ store = store_at(root)
+ store.open_work_item("pr-7", title="Review #7", position="RUNNING", cause="t")
+ store.transcript("pr-7", "operator").append(UserMessage(text="review", source="m"))
+ answers = [
+ reply(call=("post_comment", {"body": f"Duplicate, please disregard ({n})."}))
+ for n in range(4)
+ ]
+ Driver(
+ client(Server(*answers, reply(call=("finish", {"summary": "x"}))), recorder),
+ "local",
+ ).run(
+ store.transcript("pr-7", "driver"),
+ system="You review.",
+ tools=[POST, FINISH],
+ waiting=("finish",),
+ workspace=None,
+ handlers={"post_comment": lambda args: ("posted", True)},
+ )
+ recorder.close()
+ store.close()
+ return Source("circling", root / "state.db", "agent")
diff --git a/pyproject.toml b/pyproject.toml
index afc718a..8529d54 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,5 +1,5 @@
[tool.uv.workspace]
-members = ["nisse-core", "nisse-dev", "nisse-ops", "nisse-review"]
+members = ["nisse-core", "nisse-dev", "nisse-ops", "nisse-review", "nisse-ui"]
[dependency-groups]
dev = [
@@ -14,6 +14,7 @@ testpaths = [
"nisse-dev/tests",
"nisse-ops/tests",
"nisse-review/tests",
+ "nisse-ui/tests",
"tests",
]
# The seed is a test project's own code, pushed to each run's repository.
@@ -33,4 +34,4 @@ extend-exclude = ["tests/integration/seed"] # a test project's own code
extend-select = ["I"]
[tool.ruff.lint.isort]
-known-first-party = ["nisse_core", "nisse_dev", "nisse_ops", "nisse_review"]
+known-first-party = ["nisse_core", "nisse_dev", "nisse_ops", "nisse_review", "nisse_ui"]
diff --git a/uv.lock b/uv.lock
index 277c437..53cf264 100644
--- a/uv.lock
+++ b/uv.lock
@@ -8,6 +8,7 @@ members = [
"nisse-dev",
"nisse-ops",
"nisse-review",
+ "nisse-ui",
]
[manifest.dependency-groups]
@@ -112,6 +113,131 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" },
+ { url = "https://files.pythonhosted.org/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" },
+ { url = "https://files.pythonhosted.org/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" },
+ { url = "https://files.pythonhosted.org/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" },
+ { url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" },
+ { url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" },
+ { url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" },
+ { url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" },
+ { url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" },
+ { url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" },
+ { url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" },
+ { url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" },
+ { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" },
+ { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" },
+ { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" },
+ { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" },
+ { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" },
+ { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" },
+ { url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" },
+ { url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" },
+ { url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" },
+ { url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" },
+ { url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" },
+ { url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" },
+ { url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" },
+ { url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" },
+ { url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" },
+ { url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" },
+ { url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" },
+ { url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" },
+ { url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" },
+ { url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" },
+ { url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" },
+ { url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" },
+ { url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" },
+]
+
[[package]]
name = "nisse-core"
version = "0.1.0"
@@ -166,6 +292,21 @@ dependencies = [
[package.metadata]
requires-dist = [{ name = "nisse-core", editable = "nisse-core" }]
+[[package]]
+name = "nisse-ui"
+version = "0.1.0"
+source = { editable = "nisse-ui" }
+dependencies = [
+ { name = "jinja2" },
+ { name = "nisse-core" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "jinja2", specifier = ">=3.1" },
+ { name = "nisse-core", editable = "nisse-core" },
+]
+
[[package]]
name = "packaging"
version = "26.3"