[^"\'\n|]+)')
+
class RuntimeLeapService:
"""LeapService implementation backed by a single initialized Context."""
@@ -27,6 +34,7 @@ def __init__(self, settings: Any, *, mock_host: bool = False) -> None:
self._settings = settings
self._mock_host = mock_host
self._ctx: Any | None = None
+ self._monitors: Any | None = None
self._engine_lock = asyncio.Lock()
self._started_at = time.time()
self._client_count: Callable[[], int] = lambda: 0
@@ -60,6 +68,88 @@ async def start(self) -> None:
self._install_daemon_approval(ctx)
self._install_learn_notifications(ctx)
self._ctx = ctx
+ await self._start_monitors(ctx)
+
+ async def _start_monitors(self, ctx: Any) -> None:
+ """Build and start the daemon-hosted monitor runtime (watches)."""
+ settings = getattr(ctx, "settings", self._settings)
+ if not getattr(settings, "scheduler_enabled", True):
+ return
+ try:
+ from leapflow.monitor import MonitorManager, SessionAnalysisProducer
+
+ bus = self.notification_bus
+ self._monitors = MonitorManager(
+ holder=ctx._db_holder,
+ emit=lambda event_type, payload: bus.emit_event(event_type, **payload),
+ services=_ProducerServices(self),
+ tick_seconds=int(getattr(settings, "scheduler_tick_seconds", 60)),
+ grace_seconds=float(getattr(settings, "scheduler_grace_seconds", 120.0)),
+ )
+ self._monitors.producers.register(SessionAnalysisProducer())
+ setattr(ctx, "monitors", self._monitors)
+ await self._monitors.start()
+ # A fresh daemon lifetime owns no interactive clients yet, so any
+ # persisted client-coupled watch (e.g. a session-analysis watch left
+ # over from a prior run or an unclean client exit) is stale. Drop it
+ # so the status bar and keep-alive only reflect real active monitors.
+ try:
+ swept = self._monitors.sweep_client_coupled_watches()
+ if swept:
+ logger.info("daemon: swept %d stale client-coupled watch(es) on startup", swept)
+ except Exception:
+ logger.debug("daemon: client-coupled watch sweep failed", exc_info=True)
+ logger.debug("daemon: monitor runtime started")
+ except Exception:
+ logger.debug("daemon: monitor runtime start skipped", exc_info=True)
+ self._monitors = None
+ setattr(ctx, "monitors", None)
+
+ def has_active_watches(self) -> bool:
+ """Return True when any hosted watch is armed/watching (idle keep-alive)."""
+ monitors = self._monitors
+ if monitors is None:
+ return False
+ try:
+ return bool(monitors.has_active_watches())
+ except Exception:
+ return False
+
+ def _watch_runtime_summary(self) -> dict[str, Any]:
+ monitors = self._monitors
+ if monitors is None:
+ return {
+ "total": 0,
+ "active": 0,
+ "standalone_active": 0,
+ "client_coupled_active": 0,
+ "active_samples": [],
+ }
+ try:
+ watches = [view.to_dict() for view in monitors.list_watches()]
+ except Exception:
+ logger.debug("daemon: watch summary unavailable", exc_info=True)
+ watches = []
+ active_states = {"armed", "watching", "due", "confirming", "executing"}
+ active = [watch for watch in watches if str(watch.get("state", "")) in active_states]
+ standalone = [watch for watch in active if not bool(watch.get("client_coupled", False))]
+ coupled = [watch for watch in active if bool(watch.get("client_coupled", False))]
+ return {
+ "total": len(watches),
+ "active": len(active),
+ "standalone_active": len(standalone),
+ "client_coupled_active": len(coupled),
+ "active_samples": [
+ {
+ "watch_id": str(watch.get("watch_id", "")),
+ "name": str(watch.get("name", "")),
+ "domain": str(watch.get("domain", "")),
+ "state": str(watch.get("state", "")),
+ "client_coupled": bool(watch.get("client_coupled", False)),
+ }
+ for watch in active[:5]
+ ],
+ }
@property
def context(self) -> Any:
@@ -204,6 +294,321 @@ async def skill_execute(self, skill_name: str, params: dict[str, Any]) -> dict[s
async def scheduler_arm(self, task_config: dict[str, Any]) -> str:
raise NotImplementedError("scheduler.arm is not available in this daemon phase")
+ # ── Watch runtime (monitor subsystem) ───────────────────────────────
+
+ def _require_monitors(self) -> Any:
+ if self._monitors is None:
+ raise RuntimeError("monitor runtime is not available (scheduler disabled)")
+ return self._monitors
+
+ async def watch_arm(self, spec: dict[str, Any]) -> dict[str, Any]:
+ from leapflow.monitor import WatchSpec
+
+ view = await self._require_monitors().arm_watch(WatchSpec.from_dict(spec or {}))
+ return view.to_dict()
+
+ async def watch_list(self) -> list[dict[str, Any]]:
+ if self._monitors is None:
+ return []
+ return [view.to_dict() for view in self._monitors.list_watches()]
+
+ async def watch_get(self, watch_id: str) -> dict[str, Any]:
+ view = self._require_monitors().get_watch(watch_id)
+ return view.to_dict() if view else {}
+
+ async def watch_pause(self, watch_id: str) -> dict[str, Any]:
+ view = self._require_monitors().pause_watch(watch_id)
+ return view.to_dict() if view else {}
+
+ async def watch_resume(self, watch_id: str) -> dict[str, Any]:
+ view = self._require_monitors().resume_watch(watch_id)
+ return view.to_dict() if view else {}
+
+ async def watch_stop(self, watch_id: str) -> dict[str, Any]:
+ view = self._require_monitors().stop_watch(watch_id)
+ return view.to_dict() if view else {}
+
+ async def watch_mute(self, watch_id: str, muted: bool = True) -> dict[str, Any]:
+ view = self._require_monitors().set_muted(watch_id, bool(muted))
+ return view.to_dict() if view else {}
+
+ async def watch_refresh(self, watch_id: str) -> dict[str, Any]:
+ return await self._require_monitors().run_watch_once(watch_id)
+
+ async def watch_findings(
+ self, watch_id: str = "", limit: int = 50, offset: int = 0
+ ) -> list[dict[str, Any]]:
+ if self._monitors is None:
+ return []
+ findings = self._monitors.list_findings(
+ watch_id=watch_id or None, limit=int(limit), offset=int(offset)
+ )
+ return [finding.to_dict() for finding in findings]
+
+ # ── Session analysis (domain=session watch) ───────────────────────
+
+ async def session_history(self, limit: int = 200) -> dict[str, Any]:
+ ctx = self._ctx
+ if ctx is None:
+ return {"session_id": "", "turn_count": 0, "token_count": 0, "messages": [], "artifacts": []}
+ engine = getattr(ctx, "engine", None)
+ session_id = getattr(engine, "_current_session_id", "") if engine else ""
+ messages: list[dict[str, Any]] = []
+ if engine is not None:
+ wm = getattr(engine, "_wm", None)
+ if wm is not None and hasattr(wm, "as_chat_messages"):
+ try:
+ messages = [dict(m) for m in wm.as_chat_messages() if isinstance(m, dict)]
+ except Exception:
+ messages = []
+ store_messages = self._session_store_messages(session_id, limit=int(limit))
+ if store_messages:
+ if not messages:
+ messages = store_messages
+ else:
+ messages.extend(m for m in store_messages if m.get("role") == "tool")
+ normalized = [
+ {
+ "role": str(m.get("role", "")),
+ "content": str(m.get("content", "")),
+ "tool_name": str(m.get("tool_name", "") or ""),
+ "created_at": float(m.get("created_at", 0.0) or 0.0),
+ }
+ for m in messages
+ ][-int(limit):]
+ artifacts = self._collect_session_artifacts(session_id, store_messages or normalized)
+ return {
+ "session_id": session_id,
+ "turn_count": int(getattr(engine, "turn_count", 0)) if engine else 0,
+ "token_count": int(getattr(engine, "context_token_count", 0)) if engine else 0,
+ "messages": normalized,
+ "artifacts": artifacts,
+ }
+
+ def _session_store_messages(self, session_id: str, *, limit: int = 200) -> list[dict[str, Any]]:
+ ctx = self._ctx
+ store = getattr(ctx, "_conversation_store", None) if ctx is not None else None
+ if store is None or not session_id:
+ return []
+ try:
+ rows = store.get_messages(session_id, limit=int(limit))
+ except Exception:
+ logger.debug("daemon: session store messages unavailable", exc_info=True)
+ return []
+ return [self._conversation_message_to_dict(row) for row in rows]
+
+ @staticmethod
+ def _conversation_message_to_dict(message: Any) -> dict[str, Any]:
+ if isinstance(message, dict):
+ return dict(message)
+ return {
+ "role": str(getattr(message, "role", "")),
+ "content": str(getattr(message, "content", "")),
+ "tool_name": str(getattr(message, "tool_name", "") or ""),
+ "tool_call_id": str(getattr(message, "tool_call_id", "") or ""),
+ "created_at": float(getattr(message, "created_at", 0.0) or 0.0),
+ "metadata": dict(getattr(message, "metadata", {}) or {}),
+ }
+
+ def _collect_session_artifacts(self, session_id: str, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ if not session_id:
+ return []
+ workspace = self._workspace_root()
+ candidates: list[tuple[str, dict[str, Any]]] = []
+ for message in messages:
+ if str(message.get("role", "")) != "tool":
+ continue
+ tool_name = str(message.get("tool_name", "") or "")
+ if tool_name and tool_name not in {"file_write", "write_file"}:
+ continue
+ for path in self._extract_artifact_paths(message):
+ candidates.append((path, message))
+ seen: set[str] = set()
+ artifacts: list[dict[str, Any]] = []
+ total_chars = 0
+ for raw_path, message in reversed(candidates):
+ if len(artifacts) >= _MAX_SESSION_ARTIFACTS:
+ break
+ artifact = self._read_session_artifact(raw_path, workspace, message)
+ key = str(artifact.get("path") or raw_path)
+ if key in seen:
+ continue
+ seen.add(key)
+ if artifact.get("status") == "included":
+ content = str(artifact.get("content_excerpt", ""))
+ remaining = max(0, _MAX_SESSION_ARTIFACT_TOTAL_CHARS - total_chars)
+ if len(content) > remaining:
+ artifact["content_excerpt"] = content[:remaining]
+ artifact["truncated"] = True
+ artifact["reason"] = "artifact context budget reached"
+ total_chars += len(str(artifact.get("content_excerpt", "")))
+ artifacts.append(artifact)
+ artifacts.reverse()
+ return artifacts
+
+ @staticmethod
+ def _extract_artifact_paths(message: dict[str, Any]) -> list[str]:
+ paths: list[str] = []
+ payloads = [message.get("content", ""), message.get("metadata", {})]
+ for payload in payloads:
+ if isinstance(payload, dict):
+ for key in ("path", "file_path"):
+ if payload.get(key):
+ paths.append(str(payload[key]))
+ continue
+ text = str(payload or "")
+ try:
+ data = json.loads(text)
+ if isinstance(data, dict):
+ for key in ("path", "file_path"):
+ if data.get(key):
+ paths.append(str(data[key]))
+ except Exception:
+ pass
+ for match in _PATH_RE.finditer(text):
+ value = match.group("value").strip().strip(",}")
+ if value:
+ paths.append(value)
+ return paths
+
+ def _workspace_root(self) -> Path:
+ ctx = self._ctx
+ settings = getattr(ctx, "settings", self._settings) if ctx is not None else self._settings
+ return Path(str(getattr(settings, "workspace_root", os.getcwd()))).expanduser().resolve()
+
+ def _read_session_artifact(self, raw_path: str, workspace: Path, message: dict[str, Any]) -> dict[str, Any]:
+ target = Path(raw_path).expanduser()
+ if not target.is_absolute():
+ target = workspace / target
+ try:
+ target = target.resolve()
+ except OSError:
+ target = target.absolute()
+ base = {
+ "path": str(target),
+ "name": target.name,
+ "source": "file_write",
+ "tool_call_id": str(message.get("tool_call_id", "") or ""),
+ "status": "skipped",
+ }
+ try:
+ target.relative_to(workspace)
+ except ValueError:
+ return {**base, "reason": "outside workspace boundary"}
+ try:
+ from leapflow.security.path_sensitivity import classify_path_sensitivity
+ sensitivity = classify_path_sensitivity(target)
+ except Exception:
+ sensitivity = None
+ if sensitivity is not None:
+ base.update({"sensitivity": sensitivity.category, "sensitivity_level": sensitivity.level})
+ if not sensitivity.readable or sensitivity.requires_approval or sensitivity.redact_on_read:
+ return {**base, "reason": f"sensitive path ({sensitivity.category}) not read in background"}
+ if not target.exists() or not target.is_file():
+ return {**base, "reason": "file no longer exists"}
+ try:
+ stat = target.stat()
+ content = target.read_text(encoding="utf-8", errors="replace")
+ except OSError as exc:
+ return {**base, "reason": f"read failed: {exc}"}
+ truncated = len(content) > _MAX_SESSION_ARTIFACT_CHARS
+ excerpt = content[:_MAX_SESSION_ARTIFACT_CHARS]
+ try:
+ from leapflow.security.redact import redact_sensitive_text
+ excerpt = redact_sensitive_text(excerpt, file_read=bool(getattr(sensitivity, "redact_on_read", False)))
+ except Exception:
+ pass
+ return {
+ **base,
+ "status": "included",
+ "size": int(stat.st_size),
+ "mtime": float(stat.st_mtime),
+ "content_excerpt": excerpt,
+ "truncated": truncated,
+ }
+
+ async def session_analyze(self) -> dict[str, Any]:
+ if self._monitors is None:
+ return {"ok": False, "error": "monitor runtime unavailable"}
+ watch_id = await self._ensure_session_watch()
+ result = await self._monitors.run_watch_once(watch_id, force=True)
+ return {"ok": bool(result.get("ok", True)), "watch_id": watch_id, "result": result}
+
+ async def _ensure_session_watch(self) -> str:
+ from leapflow.monitor.session_producer import ensure_session_watch, session_watch_params
+
+ monitors = self._require_monitors()
+ settings = getattr(self._ctx, "settings", self._settings)
+ return await ensure_session_watch(monitors, params=session_watch_params(settings))
+
+ async def _analyze_session_llm(
+ self,
+ messages: list[dict[str, Any]],
+ *,
+ artifacts: list[dict[str, Any]] | None = None,
+ ) -> dict[str, Any]:
+ base: dict[str, Any] = {
+ "story": "", "insights": [], "decisions": [], "action_items": [],
+ "open_questions": [], "entities": [], "next_prompts": [],
+ "process_notes": [], "series_intents": [], "usage": {},
+ }
+ ctx = self._ctx
+ llm = getattr(ctx, "llm", None) if ctx is not None else None
+ if llm is None or not messages:
+ return base
+ transcript = "\n".join(
+ f"{m.get('role', '')}: {str(m.get('content', ''))[:500]}" for m in messages[-40:]
+ )[:12000]
+ artifact_block = self._format_artifact_context(artifacts or [])
+ user_content = transcript if not artifact_block else f"{transcript}\n\n## Session file artifacts\n{artifact_block}"
+ prompt = [
+ {"role": "system", "content": _SESSION_ANALYSIS_SYSTEM},
+ {"role": "user", "content": user_content[:18000]},
+ ]
+ try:
+ response = await llm.achat(prompt, stream=False)
+ data = _parse_session_json(getattr(response, "content", ""))
+ except Exception:
+ logger.debug("daemon: session analysis LLM call failed", exc_info=True)
+ return base
+ if isinstance(data, dict):
+ for key in base:
+ if key != "usage" and key in data:
+ base[key] = data[key]
+ return base
+
+ @staticmethod
+ def _format_artifact_context(artifacts: list[dict[str, Any]]) -> str:
+ lines: list[str] = []
+ for artifact in artifacts:
+ status = str(artifact.get("status", ""))
+ path = str(artifact.get("path", ""))
+ if status != "included":
+ lines.append(f"- SKIPPED {path}: {artifact.get('reason', 'not included')}")
+ continue
+ excerpt = str(artifact.get("content_excerpt", ""))[:_MAX_SESSION_ARTIFACT_CHARS]
+ truncated = " (truncated)" if artifact.get("truncated") else ""
+ lines.append(f"- FILE {path}{truncated}\n```text\n{excerpt}\n```")
+ return "\n".join(lines)
+
+ async def _session_should_refresh(self, messages: list[dict[str, Any]]) -> bool:
+ ctx = self._ctx
+ llm = getattr(ctx, "llm", None) if ctx is not None else None
+ if llm is None or not messages:
+ return False
+ tail = "\n".join(
+ f"{m.get('role', '')}: {str(m.get('content', ''))[:200]}" for m in messages[-6:]
+ )[:2000]
+ prompt = [
+ {"role": "system", "content": _SESSION_SALIENCE_SYSTEM},
+ {"role": "user", "content": tail},
+ ]
+ try:
+ response = await llm.achat(prompt, stream=False)
+ return str(getattr(response, "content", "")).strip().upper().startswith("Y")
+ except Exception:
+ return False
+
async def status(self) -> dict[str, Any]:
ctx = self._ctx
settings = getattr(ctx, "settings", self._settings) if ctx is not None else self._settings
@@ -215,6 +620,7 @@ async def status(self) -> dict[str, Any]:
workspace_config_path = layout.workspace_config_path(workspace_root)
workspace_manifest_path = layout.workspace_manifest_path(workspace_root)
context_metadata = self._engine_context_metadata(engine, settings)
+ watch_summary = self._watch_runtime_summary()
return {
"pid": os.getpid(),
"profile": getattr(settings, "profile", "default"),
@@ -252,6 +658,7 @@ async def status(self) -> dict[str, Any]:
"runtime_executable": sys.executable,
"runtime_version": self._runtime_version(),
"pending_approvals": len(self._approval_pending),
+ "watch_summary": watch_summary,
"host_backend": self._host_backend_status(ctx),
}
@@ -415,6 +822,12 @@ async def shutdown(self) -> None:
return
ctx = self._ctx
self._ctx = None
+ if self._monitors is not None:
+ try:
+ await self._monitors.stop()
+ except Exception:
+ logger.debug("daemon: monitor stop failed", exc_info=True)
+ self._monitors = None
self._checkpoint_open_connection(ctx)
await ctx.cleanup()
@@ -730,6 +1143,76 @@ def _chunk_from_event(self, event: StreamEvent, *, request_id: str = "") -> Stre
)
+_SESSION_ANALYSIS_SYSTEM = (
+ "You are a session analyst writing FOR THE USER (not for the agent). Read the "
+ "conversation transcript and return STRICT JSON only, with keys: story (a "
+ "user-facing narrative of the user's goals, findings, and outcomes — NOT a "
+ "replay of the agent's tool calls), insights (array of {title, summary, "
+ "severity in [info,notable,alert], kind in [finding,process]}), decisions "
+ "(array of strings), action_items (array of strings), open_questions (array "
+ "of strings), entities (array of strings), next_prompts (array of strings), "
+ "process_notes (array of strings), series_intents (array of {id, label, unit, "
+ "kind in [line,area,ohlc,distribution]}). "
+ "DE-WEIGHT the agent's own mechanics: tool usage, failures, retries, auth "
+ "errors, and script fixes are LOW-SIGNAL process. Omit them, or fold at most "
+ "one into insights with kind='process' and severity='info'; never emit them "
+ "as decisions or action_items unless the USER must act (e.g. provide an API "
+ "key). Put unavoidable process remarks in process_notes. In series_intents, "
+ "only NAME chart-worthy quantitative series that are actually present in the "
+ "data (labels/units); do NOT invent numbers — numeric values are extracted "
+ "separately. If a Session file artifacts section is present, treat artifact "
+ "contents as first-class evidence. Do not wrap the JSON in prose or code fences."
+)
+
+_SESSION_SALIENCE_SYSTEM = (
+ "Answer with only YES or NO: does the latest conversation contain a new decision, "
+ "a topic shift, or a new action item that would justify refreshing an analysis "
+ "dashboard?"
+)
+
+
+def _parse_session_json(content: str) -> Any:
+ """Best-effort extraction of a JSON object from an LLM response."""
+ import json as _json
+
+ text = str(content or "").strip()
+ if text.startswith("```"):
+ text = text.strip("`")
+ if "\n" in text:
+ first, rest = text.split("\n", 1)
+ if first.strip().lower().startswith("json"):
+ text = rest
+ start, end = text.find("{"), text.rfind("}")
+ if start != -1 and end != -1 and end > start:
+ text = text[start:end + 1]
+ try:
+ return _json.loads(text)
+ except Exception:
+ return None
+
+
+class _ProducerServices:
+ """Facade exposing daemon capabilities to monitor producers (session, etc.)."""
+
+ def __init__(self, service: "RuntimeLeapService") -> None:
+ self._service = service
+
+ async def session_history(self) -> dict[str, Any]:
+ return await self._service.session_history()
+
+ async def analyze_session(
+ self,
+ messages: list[dict[str, Any]],
+ *,
+ prior: dict[str, Any] | None = None,
+ artifacts: list[dict[str, Any]] | None = None,
+ ) -> dict[str, Any]:
+ return await self._service._analyze_session_llm(messages, artifacts=artifacts)
+
+ async def should_refresh(self, messages: list[dict[str, Any]]) -> bool:
+ return await self._service._session_should_refresh(messages)
+
+
class _DaemonApprovalGate:
"""Approval gate that bridges daemon-side actions to thin clients."""
diff --git a/src/leapflow/dashboard/__init__.py b/src/leapflow/dashboard/__init__.py
new file mode 100644
index 0000000..49930fa
--- /dev/null
+++ b/src/leapflow/dashboard/__init__.py
@@ -0,0 +1,43 @@
+"""Dashboard subsystem: declarative Server-Driven UI (SDUI) for monitoring.
+
+This package owns the domain-neutral view layer:
+- ``viewspec``: the validated component catalog and ViewSpec contract
+- ``templates``: YAML template rendering into a ViewSpec (with safe binding)
+- ``intent``: the ``DashboardIntent`` shared by slash and natural-language entry
+
+Transport (the local web server) is added as a separate, optional module so the
+core view logic stays importable without web dependencies.
+"""
+
+from leapflow.dashboard.intent import DashboardIntent
+from leapflow.dashboard.hub import ViewHub
+from leapflow.dashboard.service import (
+ DaemonDataProvider,
+ DashboardDataProvider,
+ DashboardViewBuilder,
+ select_template,
+)
+from leapflow.dashboard.templates import TemplateLibrary, render_template
+from leapflow.dashboard.viewspec import (
+ COMPONENT_CATALOG,
+ COMPONENT_TYPES,
+ SCHEMA_VERSION,
+ normalize_viewspec,
+ validate_viewspec,
+)
+
+__all__ = [
+ "DashboardIntent",
+ "ViewHub",
+ "DashboardDataProvider",
+ "DaemonDataProvider",
+ "DashboardViewBuilder",
+ "select_template",
+ "TemplateLibrary",
+ "render_template",
+ "COMPONENT_CATALOG",
+ "COMPONENT_TYPES",
+ "SCHEMA_VERSION",
+ "normalize_viewspec",
+ "validate_viewspec",
+]
diff --git a/src/leapflow/dashboard/hub.py b/src/leapflow/dashboard/hub.py
new file mode 100644
index 0000000..8ebda02
--- /dev/null
+++ b/src/leapflow/dashboard/hub.py
@@ -0,0 +1,64 @@
+"""ViewHub: fan out daemon monitor events to browser WebSocket subscribers.
+
+The dashboard server holds a single subscription to the daemon NotificationBus
+and re-broadcasts qualifying messages to every connected browser. This mirrors
+the daemon's own NotificationBus fan-out philosophy, keeping slow browsers from
+blocking the shared upstream via bounded per-subscriber queues.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from typing import Any, Optional
+
+logger = logging.getLogger(__name__)
+
+
+class ViewHub:
+ """Broadcast view/monitor messages to all subscribed browsers."""
+
+ def __init__(self, maxsize: int = 128) -> None:
+ self._subscribers: dict[str, asyncio.Queue[Optional[dict[str, Any]]]] = {}
+ self._maxsize = maxsize
+
+ def subscribe(self, subscriber_id: str) -> asyncio.Queue[Optional[dict[str, Any]]]:
+ """Register a browser subscriber and return its message queue."""
+ queue: asyncio.Queue[Optional[dict[str, Any]]] = asyncio.Queue(maxsize=self._maxsize)
+ self._subscribers[subscriber_id] = queue
+ return queue
+
+ def unsubscribe(self, subscriber_id: str) -> None:
+ """Remove a browser subscriber."""
+ self._subscribers.pop(subscriber_id, None)
+
+ def broadcast(self, message: dict[str, Any]) -> int:
+ """Deliver a message to all subscribers (non-blocking); return count.
+
+ Full queues are skipped (back-pressure) rather than blocking the shared
+ upstream subscription.
+ """
+ delivered = 0
+ for sid, queue in list(self._subscribers.items()):
+ try:
+ queue.put_nowait(message)
+ delivered += 1
+ except asyncio.QueueFull:
+ logger.debug("view_hub: dropped message for slow subscriber %s", sid)
+ return delivered
+
+ @property
+ def subscriber_count(self) -> int:
+ return len(self._subscribers)
+
+ async def shutdown(self) -> None:
+ """Signal all subscribers to disconnect."""
+ for queue in self._subscribers.values():
+ try:
+ queue.put_nowait(None)
+ except asyncio.QueueFull:
+ pass
+ self._subscribers.clear()
+
+
+__all__ = ["ViewHub"]
diff --git a/src/leapflow/dashboard/intent.py b/src/leapflow/dashboard/intent.py
new file mode 100644
index 0000000..44c6eb3
--- /dev/null
+++ b/src/leapflow/dashboard/intent.py
@@ -0,0 +1,42 @@
+"""DashboardIntent: the single normalized request behind ``/board`` and the tool.
+
+LeapBoard always analyzes the *current session*; the only view dimension is the
+**template** (a rendering lens). Control verbs
+(``templates``/``refresh``/``pause``/``resume``/``stop``/``status``) are handled
+at the command layer, so the intent that reaches the view builder is simply a
+template name — empty means the default (``generic``).
+"""
+
+from __future__ import annotations
+
+import shlex
+from dataclasses import dataclass
+from typing import Any, Mapping
+
+
+@dataclass(frozen=True)
+class DashboardIntent:
+ """A normalized dashboard request: which template to render the session with."""
+
+ template: str = ""
+
+ def to_dict(self) -> dict[str, Any]:
+ return {"template": self.template}
+
+ @classmethod
+ def from_params(cls, data: Mapping[str, Any]) -> "DashboardIntent":
+ """Build an intent from structured params (e.g. web ``?template=``)."""
+ data = data if isinstance(data, Mapping) else {}
+ return cls(template=str(data.get("template", "") or "").strip())
+
+ @classmethod
+ def from_args(cls, args: str) -> "DashboardIntent":
+ """Parse a slash argument string; the first token is the template name."""
+ try:
+ tokens = shlex.split(args or "")
+ except ValueError:
+ tokens = (args or "").split()
+ return cls(template=tokens[0].strip() if tokens else "")
+
+
+__all__ = ["DashboardIntent"]
diff --git a/src/leapflow/dashboard/launcher.py b/src/leapflow/dashboard/launcher.py
new file mode 100644
index 0000000..bddbcc6
--- /dev/null
+++ b/src/leapflow/dashboard/launcher.py
@@ -0,0 +1,298 @@
+"""Dashboard launcher: token/URL/state helpers, browser open, and server spawn.
+
+The dashboard runs as a separate view-client process (like the TUI). This module
+owns the client-side concerns: a per-session access token, the localhost URL,
+a small discovery state file under the profile runtime dir, opening the default
+browser, and spawning the server when it is not already running. It has no web
+dependency itself so it stays importable everywhere.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import secrets
+import signal
+import socket
+import subprocess
+import sys
+import time
+import webbrowser
+from pathlib import Path
+from typing import Any, Optional
+
+logger = logging.getLogger(__name__)
+
+_STATE_FILE = "dashboard.json"
+
+
+def aiohttp_available() -> bool:
+ """Return True when the optional ``aiohttp`` dependency is importable."""
+ import importlib.util
+
+ return importlib.util.find_spec("aiohttp") is not None
+
+
+def generate_token() -> str:
+ """Return a fresh URL-safe access token for the local dashboard."""
+ return secrets.token_urlsafe(32)
+
+
+def state_path(settings: Any) -> Path:
+ layout = getattr(settings, "profile_layout", None)
+ if layout is not None:
+ try:
+ return layout.dashboard_state_path
+ except Exception:
+ pass
+ return Path(settings.runtime_dir) / _STATE_FILE
+
+
+def load_state(settings: Any) -> Optional[dict[str, Any]]:
+ """Load the dashboard discovery state, or None when absent/invalid."""
+ path = state_path(settings)
+ if not path.exists():
+ return None
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ return data if isinstance(data, dict) else None
+ except (OSError, json.JSONDecodeError):
+ return None
+
+
+def write_state(settings: Any, state: dict[str, Any]) -> None:
+ """Persist the dashboard discovery state under the profile runtime dir."""
+ path = state_path(settings)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(state), encoding="utf-8")
+
+
+def clear_state(settings: Any) -> None:
+ state_path(settings).unlink(missing_ok=True)
+
+
+def _host_for_bind(bind: str) -> str:
+ return "127.0.0.1" if bind in ("", "0.0.0.0") else bind
+
+
+def build_url(bind: str, port: int, token: str, path: str = "/") -> str:
+ """Build a token-scoped localhost dashboard URL."""
+ if not path.startswith("/"):
+ path = "/" + path
+ suffix = f"?token={token}" if token else ""
+ return f"http://{_host_for_bind(bind)}:{port}{path}{suffix}"
+
+
+def build_view_url(
+ bind: str,
+ port: int,
+ token: str,
+ *,
+ template: str = "",
+) -> str:
+ """Build a token-scoped URL that selects a specific board template.
+
+ The template is the single view dimension: the server always analyzes the
+ current session and renders it through the named template (default when
+ omitted). Single owner of the query contract so every entry lands on the
+ intended lens.
+ """
+ url = build_url(bind, port, token)
+ if template:
+ url += f"&template={template}"
+ return url
+
+
+def is_port_open(host: str, port: int, timeout: float = 0.3) -> bool:
+ """Return True when a TCP connect to (host, port) succeeds quickly."""
+ try:
+ with socket.create_connection((_host_for_bind(host), int(port)), timeout=timeout):
+ return True
+ except OSError:
+ return False
+
+
+def probe_token(bind: str, port: int, token: str, *, timeout: float = 0.6) -> bool:
+ """Return True when the server on (bind, port) actually accepts ``token``.
+
+ A reachable port is not enough: a stale discovery-state token (server
+ restarted with a new token, port reused, or a leftover instance) would make
+ the browser land on ``missing or invalid token``. This probes the real
+ server so callers only ever trust a token that works.
+ """
+ import urllib.error
+ import urllib.request
+
+ if not token:
+ return False
+ try:
+ with urllib.request.urlopen( # noqa: S310 - fixed localhost URL
+ build_url(bind, port, token), timeout=timeout
+ ) as response:
+ return 200 <= int(getattr(response, "status", 200)) < 400
+ except urllib.error.HTTPError:
+ return False # 401 -> token rejected by the running server
+ except OSError:
+ return False
+
+
+def server_running(settings: Any) -> Optional[dict[str, Any]]:
+ """Return live dashboard state when a server accepts the stored token, else None.
+
+ Validates the token (not just port reachability) so a stale/mismatched
+ discovery state never yields a URL the server would reject.
+ """
+ state = load_state(settings)
+ if not state:
+ return None
+ port = int(state.get("port") or 0)
+ bind = str(state.get("bind") or settings.dashboard_bind)
+ token = str(state.get("token") or "")
+ if port and is_port_open(bind, port) and probe_token(bind, port, token):
+ return state
+ return None
+
+
+def open_in_browser(url: str) -> bool:
+ """Open ``url`` in the default browser; return False on headless failure."""
+ try:
+ return bool(webbrowser.open(url, new=2))
+ except Exception: # noqa: BLE001 - headless/no-DISPLAY environments
+ logger.debug("dashboard: webbrowser.open failed", exc_info=True)
+ return False
+
+
+def _find_free_port(bind: str, start: int, *, span: int = 20) -> int:
+ """Return the first free port at or above ``start`` (falls back to ``start``)."""
+ for candidate in range(start, start + span):
+ if not is_port_open(bind, candidate):
+ return candidate
+ return start
+
+
+def _pid_is_dashboard_server(pid: int) -> bool:
+ """Best-effort confirm ``pid`` is our dashboard server before signaling it.
+
+ Guards against PID reuse: a recorded pid may since belong to an unrelated
+ process. Verification uses psutil when available; without it we cannot prove
+ identity portably, so we return False and let the caller fall back to a free
+ port rather than risk terminating an innocent process.
+ """
+ try:
+ import psutil # optional dependency; absent in minimal installs
+ except ImportError:
+ return False
+ try:
+ cmdline = " ".join(psutil.Process(pid).cmdline())
+ except (psutil.Error, OSError):
+ return False
+ return "leapflow" in cmdline and "board" in cmdline and "--serve" in cmdline
+
+
+def _retire_stale_server(settings: Any) -> None:
+ """Best-effort retire a stale dashboard server recorded in discovery state.
+
+ Called only after ``server_running`` rejected the state (dead server or a
+ token the server no longer accepts). Signals the recorded pid *only when it
+ is verifiably our dashboard server* (guarding against PID reuse); otherwise
+ it leaves the process alone and lets ``ensure_server`` pick a free port.
+ Always drops the stale state file.
+ """
+ state = load_state(settings)
+ if not state:
+ return
+ port = int(state.get("port") or 0)
+ bind = str(state.get("bind") or getattr(settings, "dashboard_bind", "127.0.0.1"))
+ pid = int(state.get("pid") or 0)
+ if port and pid > 0 and is_port_open(bind, port) and _pid_is_dashboard_server(pid):
+ try:
+ os.kill(pid, signal.SIGTERM)
+ except OSError:
+ logger.debug("dashboard: stale server pid=%s not signalable", pid, exc_info=True)
+ else:
+ deadline = time.monotonic() + 2.0
+ while time.monotonic() < deadline and is_port_open(bind, port):
+ time.sleep(0.1)
+ clear_state(settings)
+
+
+def ensure_server(settings: Any, *, wait_s: float = 8.0) -> dict[str, Any]:
+ """Return running dashboard state, spawning a detached server if needed.
+
+ Reuses an existing server only when it accepts the stored token; otherwise
+ it retires the stale one, picks a free port, and spawns a fresh server whose
+ token is verified before its state is published. Raises RuntimeError when the
+ optional web dependency is missing so callers can surface an install hint.
+ """
+ existing = server_running(settings)
+ if existing:
+ return existing
+ if not aiohttp_available():
+ raise RuntimeError(
+ "The dashboard web server requires the optional 'aiohttp' dependency. "
+ "Install it with: pip install 'leapflow[dashboard]'"
+ )
+
+ # A prior server may be dead-but-recorded, or alive with a token we can no
+ # longer prove. Retire it so a fresh, trusted server can take over cleanly.
+ _retire_stale_server(settings)
+
+ token = generate_token()
+ bind = str(getattr(settings, "dashboard_bind", "127.0.0.1"))
+ preferred = int(getattr(settings, "dashboard_port", 8765))
+ port = preferred if not is_port_open(bind, preferred) else _find_free_port(bind, preferred)
+ cmd = [
+ sys.executable, "-m", "leapflow", "board", "--serve",
+ "--token", token, "--port", str(port), "--bind", bind,
+ ]
+ creationflags = 0
+ start_new_session = True
+ if os.name == "nt": # pragma: no cover - platform specific
+ creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
+ start_new_session = False
+ proc = subprocess.Popen( # noqa: S603 - trusted, fixed argv
+ cmd,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=start_new_session,
+ creationflags=creationflags,
+ )
+
+ deadline = time.monotonic() + wait_s
+ while time.monotonic() < deadline:
+ # Token-aware readiness: a mere open port could be a foreign/stale server.
+ if probe_token(bind, port, token):
+ break
+ if proc.poll() is not None:
+ raise RuntimeError("dashboard server exited before becoming ready")
+ time.sleep(0.15)
+ else:
+ raise RuntimeError("dashboard server did not become ready in time")
+
+ state = {
+ "port": port,
+ "bind": bind,
+ "token": token,
+ "pid": proc.pid,
+ "url": build_url(bind, port, token),
+ }
+ write_state(settings, state)
+ return state
+
+
+__all__ = [
+ "aiohttp_available",
+ "generate_token",
+ "state_path",
+ "load_state",
+ "write_state",
+ "clear_state",
+ "build_url",
+ "build_view_url",
+ "is_port_open",
+ "probe_token",
+ "server_running",
+ "open_in_browser",
+ "ensure_server",
+]
diff --git a/src/leapflow/dashboard/server.py b/src/leapflow/dashboard/server.py
new file mode 100644
index 0000000..1c35dfe
--- /dev/null
+++ b/src/leapflow/dashboard/server.py
@@ -0,0 +1,268 @@
+"""Local dashboard web server (optional aiohttp transport, view-client process).
+
+Holds one upstream subscription to the daemon (via DaemonClient) and fans out
+monitor events to browser WebSockets through a ``ViewHub``. Serves the SDUI
+frontend, a ``/api/view`` endpoint (ViewSpec for an intent), and a guarded
+``/api/action`` endpoint that dispatches the bidirectional action protocol.
+
+``aiohttp`` is imported lazily inside methods so this module (and the package)
+stays importable without the optional dependency; only ``build_app``/``serve``
+require it.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import os
+import uuid
+from pathlib import Path
+from typing import Any, Optional
+from urllib.parse import urlparse
+
+from leapflow.dashboard.hub import ViewHub
+from leapflow.dashboard.intent import DashboardIntent
+from leapflow.dashboard.service import DaemonDataProvider, DashboardViewBuilder
+from leapflow.dashboard.templates import TemplateLibrary
+from leapflow.monitor.types import EVENT_ERROR, EVENT_FINDING, EVENT_HEARTBEAT, EVENT_WATCH_STATE
+
+logger = logging.getLogger(__name__)
+
+STATIC_DIR = Path(__file__).parent / "static"
+_MONITOR_EVENTS = frozenset({EVENT_FINDING, EVENT_WATCH_STATE, EVENT_ERROR, EVENT_HEARTBEAT})
+# Only these RPCs may be triggered by browser actions (least privilege).
+_ALLOWED_RPC = frozenset({"watch.pause", "watch.resume", "watch.stop", "watch.refresh", "watch.mute"})
+# Exact loopback hosts accepted in the Origin header (see _check_origin).
+_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
+
+
+class DashboardServer:
+ """aiohttp view server bridging the daemon to browsers over WebSocket."""
+
+ def __init__(
+ self,
+ *,
+ client: Any,
+ token: str,
+ bind: str = "127.0.0.1",
+ port: int = 8765,
+ templates: Optional[TemplateLibrary] = None,
+ ) -> None:
+ self._client = client
+ self._token = token
+ self._bind = bind
+ self._port = port
+ self._hub = ViewHub()
+ self._builder = DashboardViewBuilder(templates or TemplateLibrary())
+ self._provider = DaemonDataProvider(client)
+ self._upstream_task: Optional[asyncio.Task[None]] = None
+
+ # ── App wiring ─────────────────────────────────────────────────────────
+
+ def build_app(self) -> Any:
+ """Build the aiohttp Application (requires the optional aiohttp dep)."""
+ from aiohttp import web
+
+ app = web.Application()
+ app.router.add_get("/", self._handle_index)
+ app.router.add_get("/api/view", self._handle_view)
+ app.router.add_post("/api/action", self._handle_action)
+ app.router.add_get("/ws", self._handle_ws)
+ if STATIC_DIR.exists():
+ app.router.add_static("/static/", str(STATIC_DIR))
+ app.on_startup.append(self._on_startup)
+ app.on_cleanup.append(self._on_cleanup)
+ return app
+
+ async def serve(self) -> None:
+ """Run the server until cancelled."""
+ from aiohttp import web
+
+ app = self.build_app()
+ runner = web.AppRunner(app)
+ await runner.setup()
+ site = web.TCPSite(runner, self._bind, self._port)
+ await site.start()
+ logger.info("dashboard serving on http://%s:%d", self._bind, self._port)
+ try:
+ while True:
+ await asyncio.sleep(3600)
+ finally:
+ await runner.cleanup()
+
+ # ── Auth ───────────────────────────────────────────────────────────────
+
+ def _check_token(self, request: Any) -> bool:
+ token = request.query.get("token") or request.headers.get("X-Dashboard-Token", "")
+ return bool(self._token) and token == self._token
+
+ @staticmethod
+ def _check_origin(request: Any) -> bool:
+ origin = request.headers.get("Origin")
+ if not origin:
+ return True # non-browser or same-origin requests carry no Origin
+ # Parse the host out of the Origin and match it exactly: a substring test
+ # ("127.0.0.1" in origin) is bypassable by hosts like attacker127.0.0.1.com.
+ try:
+ hostname = urlparse(origin).hostname
+ except ValueError:
+ return False
+ return hostname in _LOOPBACK_HOSTS
+
+ # ── Handlers ─────────────────────────────────────────────────────────────
+
+ async def _handle_index(self, request: Any) -> Any:
+ from aiohttp import web
+
+ if not self._check_token(request):
+ return web.Response(status=401, text="missing or invalid token")
+ index = STATIC_DIR / "index.html"
+ if index.exists():
+ return web.FileResponse(index)
+ return web.Response(text="LeapFlow dashboard
", content_type="text/html")
+
+ async def _handle_view(self, request: Any) -> Any:
+ from aiohttp import web
+
+ if not self._check_token(request):
+ return web.json_response({"error": "unauthorized"}, status=401)
+ intent = DashboardIntent.from_params({
+ "template": request.query.get("template", ""),
+ })
+ spec = await self._builder.build(intent, self._provider)
+ return web.json_response(spec)
+
+ async def _handle_action(self, request: Any) -> Any:
+ from aiohttp import web
+
+ if not self._check_token(request) or not self._check_origin(request):
+ return web.json_response({"error": "unauthorized"}, status=401)
+ try:
+ body = await request.json()
+ except Exception: # noqa: BLE001 - malformed client payload
+ body = {}
+ result = await self.dispatch_action(body if isinstance(body, dict) else {})
+ return web.json_response(result)
+
+ async def _handle_ws(self, request: Any) -> Any:
+ from aiohttp import web
+
+ if not self._check_token(request):
+ return web.Response(status=401, text="unauthorized")
+ ws = web.WebSocketResponse(heartbeat=30.0)
+ await ws.prepare(request)
+ subscriber_id = uuid.uuid4().hex
+ queue = self._hub.subscribe(subscriber_id)
+ try:
+ while True:
+ message = await queue.get()
+ if message is None:
+ break
+ await ws.send_json(message)
+ except (asyncio.CancelledError, ConnectionError):
+ pass
+ finally:
+ self._hub.unsubscribe(subscriber_id)
+ return ws
+
+ # ── Action dispatch (transport-independent, allow-listed) ────────────────
+
+ async def dispatch_action(self, action: dict[str, Any]) -> dict[str, Any]:
+ """Dispatch one action protocol message; returns a JSON-safe result."""
+ kind = str(action.get("kind", ""))
+ name = str(action.get("name", ""))
+ params = dict(action.get("params") or {})
+ if kind == "nav":
+ return {"ok": True, "nav": name, "params": params}
+ if kind == "rpc":
+ if name not in _ALLOWED_RPC:
+ return {"ok": False, "error": f"action not allowed: {name}"}
+ watch_id = str(params.get("watch_id") or params.get("target") or "")
+ result = await self._invoke_rpc(name, watch_id, params)
+ return {"ok": True, "result": result}
+ if kind == "approval":
+ pending_id = str(params.get("pending_id", ""))
+ decision = str(params.get("decision", "deny"))
+ return {"ok": True, "result": await self._client.approval_resolve(pending_id, decision)}
+ if kind == "intent":
+ # Engine intents (deep-dive, storytelling) are wired in a later phase;
+ # accept and acknowledge so the UI can reflect a queued request.
+ return {"ok": True, "queued": True, "name": name, "params": params}
+ return {"ok": False, "error": f"unknown action kind: {kind or '(missing)'}"}
+
+ async def _invoke_rpc(self, name: str, watch_id: str, params: dict[str, Any]) -> Any:
+ if name == "watch.pause":
+ return await self._client.watch_pause(watch_id)
+ if name == "watch.resume":
+ return await self._client.watch_resume(watch_id)
+ if name == "watch.stop":
+ return await self._client.watch_stop(watch_id)
+ if name == "watch.refresh":
+ return await self._client.watch_refresh(watch_id)
+ if name == "watch.mute":
+ return await self._client.watch_mute(watch_id, muted=bool(params.get("muted", True)))
+ return {"ok": False, "error": f"unhandled rpc: {name}"}
+
+ # ── Lifecycle ────────────────────────────────────────────────────────────
+
+ async def _on_startup(self, _app: Any) -> None:
+ self._upstream_task = asyncio.create_task(self._pump_upstream())
+
+ async def _on_cleanup(self, _app: Any) -> None:
+ if self._upstream_task is not None:
+ self._upstream_task.cancel()
+ try:
+ await self._upstream_task
+ except asyncio.CancelledError:
+ pass
+ await self._hub.shutdown()
+
+ async def _pump_upstream(self) -> None:
+ """Forward daemon monitor events to all browser subscribers."""
+ while True:
+ try:
+ async for event in self._client.subscribe_notifications():
+ event_type = event.get("event_type", "")
+ if event_type in _MONITOR_EVENTS:
+ self._hub.broadcast({"type": event_type, "payload": event.get("payload") or {}})
+ except asyncio.CancelledError:
+ return
+ except Exception: # noqa: BLE001 - reconnect on transient upstream loss
+ logger.debug("dashboard: upstream subscription lost; retrying", exc_info=True)
+ await asyncio.sleep(3.0)
+
+
+async def run_server(settings: Any, *, token: str, bind: str, port: int) -> int:
+ """Connect to leapd and serve the dashboard until interrupted."""
+ from leapflow.dashboard import launcher
+ from leapflow.dashboard.templates import TemplateLibrary
+ from leapflow.daemon.client import ensure_daemon_client
+
+ client = await ensure_daemon_client(settings)
+ # Profile-scoped custom templates take precedence over builtin ones.
+ override_dir = None
+ profile_layout = getattr(settings, "profile_layout", None)
+ if profile_layout is not None:
+ try:
+ override_dir = profile_layout.dashboard.templates_dir
+ except Exception:
+ override_dir = None
+ templates = TemplateLibrary(override_dir=override_dir)
+ server = DashboardServer(client=client, token=token, bind=bind, port=port, templates=templates)
+ launcher.write_state(settings, {
+ "port": port,
+ "bind": bind,
+ "token": token,
+ "pid": os.getpid(),
+ "url": launcher.build_url(bind, port, token),
+ })
+ try:
+ await server.serve()
+ except (KeyboardInterrupt, asyncio.CancelledError):
+ pass
+ finally:
+ launcher.clear_state(settings)
+ return 0
+
+
+__all__ = ["DashboardServer", "run_server", "STATIC_DIR"]
diff --git a/src/leapflow/dashboard/service.py b/src/leapflow/dashboard/service.py
new file mode 100644
index 0000000..9dd2978
--- /dev/null
+++ b/src/leapflow/dashboard/service.py
@@ -0,0 +1,126 @@
+"""DashboardViewBuilder: turn a DashboardIntent + live data into a ViewSpec.
+
+The builder is transport-agnostic: it reads data through a small
+``DashboardDataProvider`` protocol (satisfied by a DaemonClient adapter in the
+server, or a fake in tests) and renders via the template library. Template
+selection is convention-based (intent template, else a template named for the
+domain), never a hardcoded domain->file map.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Protocol, runtime_checkable
+
+from leapflow.dashboard.intent import DashboardIntent
+from leapflow.dashboard.templates import TemplateLibrary
+
+logger = logging.getLogger(__name__)
+
+
+@runtime_checkable
+class DashboardDataProvider(Protocol):
+ """Read-only data access the builder needs (watches and findings)."""
+
+ async def watches(self) -> list[dict[str, Any]]:
+ """Return all watch views."""
+ ...
+
+ async def findings(self, *, watch_id: str = "", limit: int = 50) -> list[dict[str, Any]]:
+ """Return findings, optionally scoped to a watch."""
+ ...
+
+
+class DaemonDataProvider:
+ """Adapt a DaemonClient's ``watch_*`` RPCs to the provider protocol."""
+
+ def __init__(self, client: Any) -> None:
+ self._client = client
+
+ async def watches(self) -> list[dict[str, Any]]:
+ return list(await self._client.watch_list())
+
+ async def findings(self, *, watch_id: str = "", limit: int = 50) -> list[dict[str, Any]]:
+ return list(await self._client.watch_findings(watch_id=watch_id, limit=limit))
+
+
+def select_template(template: str, names: list[str]) -> str:
+ """Return the requested template if available, else the generic fallback.
+
+ The template is the single view dimension; an unknown name degrades to the
+ built-in ``generic`` default rather than failing.
+ """
+ return template if template and template in names else "generic"
+
+
+class DashboardViewBuilder:
+ """Assemble ViewSpecs for dashboard intents."""
+
+ def __init__(self, templates: TemplateLibrary | None = None) -> None:
+ self._templates = templates or TemplateLibrary()
+
+ async def build(self, intent: DashboardIntent, provider: DashboardDataProvider) -> dict[str, Any]:
+ """Return a normalized ViewSpec: the current session rendered via a template.
+
+ LeapBoard has one analysis target (the current session); the intent only
+ carries which template lens to render it with.
+ """
+ return await self._build_session(intent.template, provider)
+
+ async def _build_session(self, template: str, provider: DashboardDataProvider) -> dict[str, Any]:
+ # The session watch emits an insight finding whose payload carries the
+ # structured analysis plus observation transparency metadata.
+ watches = await provider.watches()
+ session_watch = next((w for w in watches if str(w.get("domain")) == "session"), {})
+ findings = await provider.findings(watch_id="", limit=50)
+ session_findings = [f for f in findings if str(f.get("domain")) == "session"]
+ analysis = dict(session_findings[0].get("payload") or {}) if session_findings else {}
+ observation = dict(analysis.get("observation_status") or {})
+ if session_watch:
+ observation.update({
+ "watch_state": session_watch.get("state", ""),
+ "watch_muted": session_watch.get("muted", False),
+ "last_run_at": session_watch.get("last_run_at", 0),
+ "next_due_at": session_watch.get("next_due_at", 0),
+ "run_count": session_watch.get("run_count", 0),
+ })
+ # De-weight process: fold any kind='process' insight into process_notes so
+ # tool mechanics never render as prominent insight cards.
+ insights = [i for i in (analysis.get("insights") or []) if isinstance(i, dict)]
+ process_notes = [str(n) for n in (analysis.get("process_notes") or []) if str(n).strip()]
+ kept_insights = []
+ for item in insights:
+ if str(item.get("kind", "")).lower() == "process":
+ note = str(item.get("summary") or item.get("title") or "").strip()
+ if note:
+ process_notes.append(note)
+ else:
+ kept_insights.append(item)
+ analysis["insights"] = kept_insights
+ analysis["process_notes"] = process_notes
+ data = {
+ "title": "Session Analysis",
+ "analysis": analysis,
+ "observation": observation,
+ "artifact_context": analysis.get("artifact_context") or [],
+ "findings": session_findings,
+ "watch": session_watch,
+ }
+ name = select_template(template, self._templates.names())
+ spec = self._templates.render(name, data)
+ # Expose the available lenses + the active one so the web client can
+ # render a template switcher without hardcoding template names.
+ if isinstance(spec, dict):
+ meta = spec.setdefault("meta", {})
+ if isinstance(meta, dict):
+ meta["templates"] = self._templates.names()
+ meta["active_template"] = name
+ return spec
+
+
+__all__ = [
+ "DashboardDataProvider",
+ "DaemonDataProvider",
+ "DashboardViewBuilder",
+ "select_template",
+]
diff --git a/src/leapflow/dashboard/static/app.js b/src/leapflow/dashboard/static/app.js
new file mode 100644
index 0000000..c44c7bd
--- /dev/null
+++ b/src/leapflow/dashboard/static/app.js
@@ -0,0 +1,448 @@
+// LeapBoard: a minimal Server-Driven UI renderer.
+// Fetches a ViewSpec from /api/view, renders the fixed component catalog into
+// the DOM, connects a WebSocket for live monitor events, and posts interactive
+// actions back to /api/action.
+(function () {
+ "use strict";
+
+ const params = new URLSearchParams(location.search);
+ const TOKEN = params.get("token") || "";
+ const rootEl = document.getElementById("root");
+ const statusEl = document.getElementById("status");
+ const toastsEl = document.getElementById("toasts");
+ const localeEl = document.getElementById("locale-switch");
+ const storedLocale = localStorage.getItem("leapboard.locale") || "";
+ const browserLocale = (navigator.language || "en").slice(0, 2).toLowerCase();
+ let locale = storedLocale || (["en", "zh", "fr", "es", "ar", "ru"].includes(browserLocale) ? browserLocale : "en");
+ let current = { template: params.get("template") || "" };
+ let figSeq = 0; // academic figure counter, reset each render()
+ let tblSeq = 0; // academic table counter, reset each render()
+
+ const I18N = {
+ en: { "manual_refresh": "manual refresh", "first_observation": "first observation", "artifact_changed": "artifact changed", "batch_turns": "turn threshold", "batch_tokens": "token threshold", "model_salience": "model salience", "text_only": "conversation text", "text_and_artifacts": "conversation + files", "partial_artifacts": "partial files" },
+ zh: { "Overview": "概览", "Session": "会话", "Language": "语言", "connecting…": "连接中…", "live": "实时", "reconnecting…": "重连中…", "Loading…": "加载中…", "No content yet.": "暂无内容。", "Failed to load view": "视图加载失败", "Action failed": "操作失败", "Candlestick": "K线", "Series": "序列", "Gauge": "仪表", "Custom": "自定义", "unknown": "未知", "Watch portfolio": "观察组合", "Refresh cadence": "刷新节奏", "Active watches": "活跃观察", "Recent findings": "最新发现", "Watches": "观察任务", "Findings": "发现", "Signals": "信号", "Watch": "观察", "Price action": "价格行为", "Signal mix": "信号结构", "Market brief": "市场简报", "Latest sentiment": "最新情绪", "Mentions": "提及", "Sentiment structure": "情绪结构", "Narrative pulse": "叙事脉搏", "New papers": "新论文", "Research pipeline": "研究管线", "Evidence stream": "证据流", "Executive brief": "执行摘要", "Storyline": "叙事线", "Insights": "洞察", "Action items": "行动项", "Decisions": "决策", "Open questions": "待回答问题", "Entities": "实体", "Suggested next prompts": "建议追问", "Timeline": "时间线", "Severity mix": "严重度结构", "alert": "警报", "notable": "重要", "info": "信息", "Observation status": "观察状态", "Refresh state": "刷新状态", "Refresh reason": "刷新原因", "Coverage": "覆盖率", "Artifacts": "副产物", "Observed context": "已观察上下文", "File artifacts": "文件副产物", "File": "文件", "Status": "状态", "Note": "说明", "manual_refresh": "手动刷新", "first_observation": "首次观察", "artifact_changed": "文件副产物变化", "batch_turns": "轮次阈值", "batch_tokens": "上下文阈值", "model_salience": "模型显著性", "Session Analysis": "会话分析", "Observation": "观察", "Operating agenda": "行动议程", "Context map": "上下文图谱", "Next prompts": "后续追问", "Turns": "轮次", "Tokens": "词元", "Reason": "原因", "Abstract": "摘要", "No entries.": "暂无条目。", "Insight count by severity.": "按严重度统计的洞察数。", "Session file artifacts.": "会话文件副产物。", "Coverage · storyline · severity": "覆盖率 · 叙事 · 严重度", "Trigger and context": "触发与上下文", "Key observations": "关键观察", "Decisions and actions": "决策与行动", "Entities and follow-ups": "实体与后续", "Extracted from this session's tool/file output (not model-generated).": "数据来自本次会话的工具/文件产物(非模型生成)。" },
+ fr: { "Overview": "Vue d’ensemble", "Session": "Session", "Language": "Langue", "connecting…": "connexion…", "live": "direct", "reconnecting…": "reconnexion…", "Loading…": "chargement…", "No content yet.": "Aucun contenu.", "Failed to load view": "Échec du chargement", "Action failed": "Action échouée", "Watch": "Veille", "Watches": "Veilles", "Findings": "Constats", "Recent findings": "Constats récents", "Signals": "Signaux", "Insights": "Analyses", "Action items": "Actions", "Decisions": "Décisions", "Open questions": "Questions ouvertes", "Entities": "Entités", "Suggested next prompts": "Prochaines invites", "Executive brief": "Synthèse exécutive", "Storyline": "Narratif", "Timeline": "Chronologie", "Severity mix": "Mix de sévérité", "alert": "alerte", "notable": "notable", "info": "info", "Observation status": "Statut d’observation", "Refresh state": "État", "Refresh reason": "Raison", "Coverage": "Couverture", "Artifacts": "Artefacts", "Observed context": "Contexte observé", "File artifacts": "Fichiers", "File": "Fichier", "Status": "Statut", "Note": "Note", "manual_refresh": "actualisation manuelle", "first_observation": "première observation", "artifact_changed": "artefact modifié", "batch_turns": "seuil de tours", "batch_tokens": "seuil de jetons", "model_salience": "saillance modèle", "Session Analysis": "Analyse de session", "Observation": "Observation", "Operating agenda": "Programme d’action", "Context map": "Carte de contexte", "Next prompts": "Invites suivantes", "Turns": "Tours", "Tokens": "Jetons", "Reason": "Raison", "Abstract": "Résumé", "No entries.": "Aucune entrée.", "Insight count by severity.": "Nombre d’analyses par sévérité.", "Session file artifacts.": "Artefacts de fichiers de session.", "Coverage · storyline · severity": "Couverture · récit · sévérité", "Trigger and context": "Déclencheur et contexte", "Key observations": "Observations clés", "Decisions and actions": "Décisions et actions", "Entities and follow-ups": "Entités et suivis" },
+ es: { "Overview": "Resumen", "Session": "Sesión", "Language": "Idioma", "connecting…": "conectando…", "live": "en vivo", "reconnecting…": "reconectando…", "Loading…": "cargando…", "No content yet.": "Sin contenido.", "Failed to load view": "Error al cargar", "Action failed": "Acción fallida", "Watch": "Vigilancia", "Watches": "Vigilancias", "Findings": "Hallazgos", "Recent findings": "Hallazgos recientes", "Signals": "Señales", "Insights": "Ideas", "Action items": "Acciones", "Decisions": "Decisiones", "Open questions": "Preguntas abiertas", "Entities": "Entidades", "Suggested next prompts": "Siguientes preguntas", "Executive brief": "Resumen ejecutivo", "Storyline": "Narrativa", "Timeline": "Cronología", "Severity mix": "Mezcla de severidad", "alert": "alerta", "notable": "relevante", "info": "info", "Observation status": "Estado de observación", "Refresh state": "Estado", "Refresh reason": "Motivo", "Coverage": "Cobertura", "Artifacts": "Artefactos", "Observed context": "Contexto observado", "File artifacts": "Archivos", "File": "Archivo", "Status": "Estado", "Note": "Nota", "manual_refresh": "actualización manual", "first_observation": "primera observación", "artifact_changed": "artefacto cambiado", "batch_turns": "umbral de turnos", "batch_tokens": "umbral de tokens", "model_salience": "relevancia del modelo", "Session Analysis": "Análisis de sesión", "Observation": "Observación", "Operating agenda": "Agenda operativa", "Context map": "Mapa de contexto", "Next prompts": "Siguientes prompts", "Turns": "Turnos", "Tokens": "Tokens", "Reason": "Motivo", "Abstract": "Resumen", "No entries.": "Sin entradas.", "Insight count by severity.": "Recuento de hallazgos por severidad.", "Session file artifacts.": "Artefactos de archivos de sesión.", "Coverage · storyline · severity": "Cobertura · relato · severidad", "Trigger and context": "Disparador y contexto", "Key observations": "Observaciones clave", "Decisions and actions": "Decisiones y acciones", "Entities and follow-ups": "Entidades y seguimientos" },
+ ar: { "Overview": "نظرة عامة", "Session": "الجلسة", "Language": "اللغة", "connecting…": "جارٍ الاتصال…", "live": "مباشر", "reconnecting…": "إعادة الاتصال…", "Loading…": "جارٍ التحميل…", "No content yet.": "لا يوجد محتوى بعد.", "Failed to load view": "فشل تحميل العرض", "Action failed": "فشل الإجراء", "Watch": "مراقبة", "Watches": "المراقبات", "Findings": "النتائج", "Recent findings": "أحدث النتائج", "Signals": "الإشارات", "Insights": "الرؤى", "Action items": "إجراءات", "Decisions": "قرارات", "Open questions": "أسئلة مفتوحة", "Entities": "كيانات", "Suggested next prompts": "أسئلة مقترحة", "Executive brief": "ملخص تنفيذي", "Storyline": "السرد", "Timeline": "الخط الزمني", "Severity mix": "توزيع الشدة", "alert": "تنبيه", "notable": "مهم", "info": "معلومة", "Observation status": "حالة المراقبة", "Refresh state": "حالة التحديث", "Refresh reason": "سبب التحديث", "Coverage": "التغطية", "Artifacts": "المخرجات", "Observed context": "السياق المرصود", "File artifacts": "ملفات", "File": "ملف", "Status": "الحالة", "Note": "ملاحظة", "manual_refresh": "تحديث يدوي", "first_observation": "أول مراقبة", "artifact_changed": "تغير ملف", "batch_turns": "حد الجولات", "batch_tokens": "حد الرموز", "model_salience": "أهمية النموذج", "Session Analysis": "تحليل الجلسة", "Observation": "الرصد", "Operating agenda": "خطة العمل", "Context map": "خريطة السياق", "Next prompts": "المطالبات التالية", "Turns": "الأدوار", "Tokens": "الرموز", "Reason": "السبب", "Abstract": "ملخص", "No entries.": "لا توجد إدخالات.", "Insight count by severity.": "عدد الرؤى حسب الخطورة.", "Session file artifacts.": "مخرجات ملفات الجلسة.", "Coverage · storyline · severity": "التغطية · السرد · الخطورة", "Trigger and context": "المُشغِّل والسياق", "Key observations": "ملاحظات رئيسية", "Decisions and actions": "القرارات والإجراءات", "Entities and follow-ups": "الكيانات والمتابعات" },
+ ru: { "Overview": "Обзор", "Session": "Сессия", "Language": "Язык", "connecting…": "подключение…", "live": "онлайн", "reconnecting…": "переподключение…", "Loading…": "загрузка…", "No content yet.": "Пока нет данных.", "Failed to load view": "Не удалось загрузить", "Action failed": "Действие не выполнено", "Watch": "Наблюдение", "Watches": "Наблюдения", "Findings": "Находки", "Recent findings": "Последние находки", "Signals": "Сигналы", "Insights": "Инсайты", "Action items": "Действия", "Decisions": "Решения", "Open questions": "Открытые вопросы", "Entities": "Сущности", "Suggested next prompts": "Следующие запросы", "Executive brief": "Краткий обзор", "Storyline": "Сюжет", "Timeline": "Хронология", "Severity mix": "Структура важности", "alert": "тревога", "notable": "важно", "info": "инфо", "Observation status": "Статус наблюдения", "Refresh state": "Состояние", "Refresh reason": "Причина", "Coverage": "Покрытие", "Artifacts": "Артефакты", "Observed context": "Наблюдаемый контекст", "File artifacts": "Файлы", "File": "Файл", "Status": "Статус", "Note": "Заметка", "manual_refresh": "ручное обновление", "first_observation": "первое наблюдение", "artifact_changed": "файл изменён", "batch_turns": "порог ходов", "batch_tokens": "порог токенов", "model_salience": "значимость модели", "Session Analysis": "Анализ сессии", "Observation": "Наблюдение", "Operating agenda": "Рабочая повестка", "Context map": "Карта контекста", "Next prompts": "Следующие запросы", "Turns": "Ходы", "Tokens": "Токены", "Reason": "Причина", "Abstract": "Аннотация", "No entries.": "Нет записей.", "Insight count by severity.": "Число инсайтов по важности.", "Session file artifacts.": "Файловые артефакты сессии.", "Coverage · storyline · severity": "Покрытие · сюжет · важность", "Trigger and context": "Триггер и контекст", "Key observations": "Ключевые наблюдения", "Decisions and actions": "Решения и действия", "Entities and follow-ups": "Сущности и продолжения" }
+ };
+
+ function t(key) { return (I18N[locale] && I18N[locale][key]) || key; }
+ function tx(value) { return typeof value === "string" ? t(value) : value; }
+
+ function applyLocale() {
+ document.documentElement.lang = locale === "zh" ? "zh-CN" : locale;
+ document.documentElement.dir = locale === "ar" ? "rtl" : "ltr";
+ if (localeEl) localeEl.value = locale;
+ document.querySelectorAll("[data-i18n]").forEach((node) => { node.textContent = t(node.dataset.i18n); });
+ }
+
+ function api(path) {
+ const url = new URL(path, location.origin);
+ url.searchParams.set("token", TOKEN);
+ return url.toString();
+ }
+
+ async function fetchView(intent) {
+ current = Object.assign({}, current, intent || {});
+ const url = new URL("/api/view", location.origin);
+ url.searchParams.set("token", TOKEN);
+ Object.entries(current).forEach(([k, v]) => v && url.searchParams.set(k, v));
+ try {
+ const resp = await fetch(url.toString());
+ if (!resp.ok) throw new Error("HTTP " + resp.status);
+ const spec = await resp.json();
+ render(spec);
+ renderNav(spec.meta || {});
+ } catch (err) {
+ rootEl.innerHTML = '' + esc(t("Failed to load view") + ": " + String(err)) + "
";
+ }
+ }
+
+ // Template switcher: the current session, rendered through each lens.
+ function renderNav(meta) {
+ const nav = document.getElementById("nav");
+ if (!nav) return;
+ const names = Array.isArray(meta.templates) ? meta.templates : [];
+ const active = meta.active_template || "";
+ nav.innerHTML = "";
+ names.forEach((name) => {
+ const a = el("a", name === active ? "active" : "");
+ a.href = "#";
+ a.textContent = name;
+ a.addEventListener("click", (ev) => { ev.preventDefault(); fetchView({ template: name }); });
+ nav.appendChild(a);
+ });
+ }
+
+ async function postAction(action) {
+ if (action && action.kind === "nav") { handleNav(action); return; }
+ try {
+ const resp = await fetch(api("/api/action"), {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-Dashboard-Token": TOKEN },
+ body: JSON.stringify(action),
+ });
+ const result = await resp.json();
+ if (action.kind === "rpc") fetchView(); // reflect control changes
+ return result;
+ } catch (err) {
+ toast({ title: t("Action failed"), summary: String(err), severity: "alert" });
+ }
+ }
+
+ // nav actions are purely client-side (no server round-trip).
+ function handleNav(action) {
+ const p = action.params || {};
+ if (action.name === "openLink" && p.url) window.open(p.url, "_blank", "noopener");
+ }
+
+ function esc(s) {
+ return String(s == null ? "" : s).replace(/[&<>"]/g, (c) =>
+ ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
+ }
+
+ // ── Renderers keyed by catalog type; unknown types fall back to text ──
+ function el(tag, cls, html) {
+ const e = document.createElement(tag);
+ if (cls) e.className = cls;
+ if (html != null) e.innerHTML = html;
+ return e;
+ }
+
+ function renderChildren(node, parent) {
+ (node.children || []).forEach((c) => parent.appendChild(renderNode(c)));
+ return parent;
+ }
+
+ function bindAction(dom, node) {
+ if (node.action) {
+ dom.style.cursor = "pointer";
+ dom.addEventListener("click", (ev) => { ev.stopPropagation(); postAction(node.action); });
+ }
+ return dom;
+ }
+
+ // Escape-hatch renderers for the `Custom` component, keyed by props.render.
+ const CUSTOM_RENDERERS = {
+ candlestick: (p) => { const data = Array.isArray(p.data) ? p.data : [];
+ const d = el("div", "mini-chart card"); d.appendChild(el("div", "card-title", t("Candlestick")));
+ d.appendChild(el("div", "chart-placeholder", esc(data.length + " " + t("Series")))); return d; },
+ gauge: (p) => renderGaugeValue(p.label || "Gauge", p.data),
+ };
+
+ function asArray(value) { return Array.isArray(value) ? value : []; }
+
+ function severityOf(item) { return String((item && item.severity) || "info").toLowerCase(); }
+
+ function severityCounts(items) {
+ return asArray(items).reduce((acc, item) => { const sev = severityOf(item); acc[sev] = (acc[sev] || 0) + 1; return acc; }, {});
+ }
+
+ // Academic numbering: build a caption node ("Fig. N" / "Table N") + text.
+ function captionInto(host, label, text) {
+ const num = el("span", "fignum"); num.textContent = label; host.appendChild(num);
+ if (text) host.appendChild(document.createTextNode(String(text)));
+ return host;
+ }
+ function figcaption(text) { return captionInto(el("figcaption", "figcaption"), "Fig. " + (++figSeq), tx(text)); }
+ function tableCaption(text) { return captionInto(document.createElement("caption"), "Table " + (++tblSeq), tx(text)); }
+ function chartNode(dom, props) { if (props && props.caption) dom.appendChild(figcaption(props.caption)); return dom; }
+
+ // Layout helpers: template-driven grid column count and child spans, so a view
+ // can compose dense asymmetric grids without introducing new component types.
+ function _clampInt(value, lo, hi) { const n = parseInt(value, 10); return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) : 0; }
+ function gridCols(props) { const c = _clampInt(props.cols, 2, 6); return c ? " cols-" + c : ""; }
+ function applySpan(dom, props) { const s = _clampInt(props.span, 2, 4); if (s && dom && dom.classList) dom.classList.add("span-" + s); }
+
+ // Format the storyline like a paper abstract: bold lead-in sentence + body.
+ function renderAbstract(text) {
+ const s = String(text == null ? "" : text).trim();
+ const box = el("div", "abstract");
+ if (!s) return box;
+ const idx = s.search(/[.!?\u3002\uff01\uff1f]/);
+ if (idx > -1 && idx < 160) {
+ const lead = el("span", "lead"); lead.textContent = s.slice(0, idx + 1); box.appendChild(lead);
+ const rest = s.slice(idx + 1).trim();
+ if (rest) box.appendChild(document.createTextNode(" " + rest));
+ } else {
+ box.textContent = s;
+ }
+ return box;
+ }
+
+ // List: a definition list when items carry a summary, else compact bullets.
+ function renderList(node) {
+ const items = asArray((node.props || {}).data);
+ if (!items.length) return el("div", "empty-inline", esc(t("No entries.")));
+ const structured = items.some((it) => it && typeof it === "object" && (it.summary || it.detail || it.value));
+ if (structured) {
+ const dl = el("dl", "dl");
+ items.forEach((it) => {
+ const obj = it && typeof it === "object";
+ dl.appendChild(el("dt", null, esc(obj ? (it.title || it.name || it.label || "") : it)));
+ dl.appendChild(el("dd", null, esc(obj ? (it.summary || it.detail || it.value || "") : "")));
+ });
+ return dl;
+ }
+ const ul = el("ul", "insight-list");
+ items.forEach((it) => ul.appendChild(el("li", null, esc(typeof it === "object" ? (it.title || it.summary || JSON.stringify(it)) : tx(it)))));
+ return ul;
+ }
+
+ function renderGaugeValue(label, value) {
+ const d = el("div", "stat gauge-stat");
+ d.appendChild(el("div", "label", esc(tx(label || "Gauge"))));
+ d.appendChild(el("div", "value", esc(value != null && value !== "" ? value : "\u2014")));
+ return d;
+ }
+
+ function svgEl(tag) { return document.createElementNS("http://www.w3.org/2000/svg", tag); }
+
+ // Distribution bars (label -> value) or, as a fallback, the severity mix of a
+ // findings/insights array. Real values only — never synthetic.
+ function renderChartBars(data, title) {
+ const arr = asArray(data);
+ let dist = null;
+ if (arr.length && arr[0] && Array.isArray(arr[0].items)) dist = asArray(arr[0].items);
+ else if (arr.length && arr.every((it) => it && typeof it === "object" && "value" in it && ("label" in it || "name" in it))) dist = arr;
+ const d = el("div", "chart card");
+ if (title) d.appendChild(el("div", "card-title", esc(tx(title))));
+ let rows; let severity = false;
+ if (dist) {
+ rows = dist.map((it) => ({ key: "", label: String(it.label || it.name || ""), value: Number(it.value) || 0 }));
+ } else {
+ severity = true;
+ const counts = severityCounts(data);
+ rows = ["alert", "notable", "info"].map((key) => ({ key, label: t(key), value: counts[key] || 0 }));
+ }
+ const max = Math.max(1, ...rows.map((r) => r.value));
+ rows.forEach((row) => {
+ const line = el("div", "bar-row");
+ line.appendChild(el("span", "bar-label", esc(row.label)));
+ const track = el("span", "bar-track");
+ const fill = el("span", "bar-fill" + (severity ? " sev-" + row.key : "")); fill.style.width = Math.round((row.value / max) * 100) + "%";
+ track.appendChild(fill); line.appendChild(track); line.appendChild(el("span", "bar-value", esc(row.value))); d.appendChild(line);
+ });
+ return d;
+ }
+
+ // Normalize a bound value into series groups [{label, points:[{x,y}]}].
+ function seriesGroups(data) {
+ const arr = asArray(data);
+ if (arr.length && arr[0] && Array.isArray(arr[0].points)) return arr;
+ const pts = arr.filter((p) => p && typeof p === "object" && "y" in p);
+ return pts.length ? [{ label: "", points: pts }] : [];
+ }
+
+ // Real line/area chart: plots actual {x,y} points, auto-scaled. No fake data.
+ function renderSparkline(data, title, opts) {
+ const d = el("div", "chart card");
+ if (title) d.appendChild(el("div", "card-title", esc(tx(title))));
+ const groups = seriesGroups(data).slice(0, 4)
+ .map((g) => ({ label: String(g.label || ""), points: asArray(g.points).map((p, i) => ({ x: p.x != null ? p.x : i, y: Number(p.y) })).filter((p) => Number.isFinite(p.y)) }))
+ .filter((g) => g.points.length >= 2);
+ if (!groups.length) { d.appendChild(el("div", "chart-placeholder", esc(t("No entries.")))); return d; }
+ const ys = []; groups.forEach((g) => g.points.forEach((p) => ys.push(p.y)));
+ const min = Math.min.apply(null, ys), max = Math.max.apply(null, ys), span = (max - min) || 1;
+ const W = 320, H = 96, pad = 4;
+ const svg = svgEl("svg"); svg.setAttribute("viewBox", "0 0 " + W + " " + H); svg.setAttribute("preserveAspectRatio", "none"); svg.setAttribute("class", "sparkline");
+ const strokes = ["var(--accent)", "var(--info)", "var(--notable)", "var(--faint)"];
+ groups.forEach((g, gi) => {
+ const n = Math.max(1, g.points.length - 1);
+ const coords = g.points.map((p, i) => (i * (W / n)).toFixed(1) + "," + (H - pad - ((p.y - min) / span) * (H - pad * 2)).toFixed(1)).join(" ");
+ if (opts && opts.area) {
+ const poly = svgEl("polygon"); poly.setAttribute("points", "0," + (H - pad) + " " + coords + " " + W + "," + (H - pad));
+ poly.setAttribute("style", "fill:" + strokes[gi % strokes.length] + ";opacity:.12;stroke:none"); svg.appendChild(poly);
+ }
+ const line = svgEl("polyline"); line.setAttribute("points", coords);
+ line.setAttribute("style", "stroke:" + strokes[gi % strokes.length]); svg.appendChild(line);
+ });
+ d.appendChild(svg);
+ // Always name the line(s) so the chart is self-describing, even for a single
+ // series; skip blank labels.
+ const labeled = groups.filter((g) => g.label);
+ if (labeled.length) { const lg = el("div", "legend"); labeled.forEach((g) => lg.appendChild(el("span", "legend-item", esc(g.label)))); d.appendChild(lg); }
+ return d;
+ }
+
+ // Real candlestick: OHLC bars from captured market data, auto-scaled.
+ function renderCandlestick(data, title) {
+ const arr = asArray(data);
+ let bars = (arr.length && arr[0] && Array.isArray(arr[0].bars)) ? asArray(arr[0].bars) : arr;
+ bars = bars.map((b) => ({ o: Number(b && b.o), h: Number(b && b.h), l: Number(b && b.l), c: Number(b && b.c) }))
+ .filter((b) => Number.isFinite(b.o) && Number.isFinite(b.h) && Number.isFinite(b.l) && Number.isFinite(b.c));
+ const d = el("div", "chart card");
+ if (title) d.appendChild(el("div", "card-title", esc(tx(title))));
+ if (bars.length < 2) { d.appendChild(el("div", "chart-placeholder", esc(t("No entries.")))); return d; }
+ const lo = Math.min.apply(null, bars.map((b) => b.l)), hi = Math.max.apply(null, bars.map((b) => b.h)), span = (hi - lo) || 1;
+ const W = 320, H = 120, pad = 6, step = W / bars.length, bw = Math.max(2, step * 0.6);
+ const y = (v) => H - pad - ((v - lo) / span) * (H - pad * 2);
+ const svg = svgEl("svg"); svg.setAttribute("viewBox", "0 0 " + W + " " + H); svg.setAttribute("preserveAspectRatio", "none"); svg.setAttribute("class", "sparkline");
+ bars.forEach((b, i) => {
+ const cx = i * step + step / 2, color = b.c >= b.o ? "var(--info)" : "var(--alert)";
+ const wick = svgEl("line"); wick.setAttribute("x1", cx.toFixed(1)); wick.setAttribute("x2", cx.toFixed(1));
+ wick.setAttribute("y1", y(b.h).toFixed(1)); wick.setAttribute("y2", y(b.l).toFixed(1));
+ wick.setAttribute("style", "stroke:" + color + ";stroke-width:1"); svg.appendChild(wick);
+ const top = y(Math.max(b.o, b.c)), bot = y(Math.min(b.o, b.c));
+ const rect = svgEl("rect"); rect.setAttribute("x", (cx - bw / 2).toFixed(1)); rect.setAttribute("y", top.toFixed(1));
+ rect.setAttribute("width", bw.toFixed(1)); rect.setAttribute("height", Math.max(1, bot - top).toFixed(1));
+ rect.setAttribute("style", "fill:" + color); svg.appendChild(rect);
+ });
+ d.appendChild(svg); return d;
+ }
+
+ function renderPie(data, title) {
+ const counts = severityCounts(data); const total = Math.max(1, (counts.alert || 0) + (counts.notable || 0) + (counts.info || 0));
+ const d = el("div", "chart card pie-card");
+ if (title) d.appendChild(el("div", "card-title", esc(tx(title))));
+ const pie = el("div", "pie");
+ pie.style.background = "conic-gradient(var(--alert) 0 " + ((counts.alert || 0) / total * 100) + "%, var(--notable) 0 " + (((counts.alert || 0) + (counts.notable || 0)) / total * 100) + "%, var(--info) 0 100%)";
+ d.appendChild(pie); d.appendChild(renderLegend(["alert", "notable", "info"], counts)); return d;
+ }
+
+ function renderLegend(keys, counts) {
+ const box = el("div", "legend");
+ keys.forEach((key) => box.appendChild(el("span", "legend-item sev-" + key, esc(t(key) + " " + (counts[key] || 0)))));
+ return box;
+ }
+
+ function renderTable(node) {
+ const p = node.props || {}; const rows = asArray(p.data); const cols = asArray(p.columns);
+ if (!rows.length) return el("div", "empty-inline", esc(t("No entries.")));
+ const table = el("table", "data-table");
+ if (p.caption) table.appendChild(tableCaption(p.caption));
+ const head = document.createElement("thead"); const headRow = document.createElement("tr");
+ cols.forEach((c) => headRow.appendChild(el("th", null, esc(tx(c.label || c.key || c))))); head.appendChild(headRow); table.appendChild(head);
+ const body = document.createElement("tbody"); rows.forEach((row) => { const tr = document.createElement("tr"); cols.forEach((c) => tr.appendChild(el("td", null, esc(row && row[c.key || c] != null ? row[c.key || c] : "")))); body.appendChild(tr); }); table.appendChild(body); return table;
+ }
+
+ function renderTimeline(node) {
+ const items = asArray((node.props || {}).data); const d = el("div", "timeline");
+ items.forEach((it) => { const row = el("div", "timeline-item sev-" + severityOf(it)); row.appendChild(el("div", "timeline-title", esc(it.title || ""))); if (it.summary) row.appendChild(el("div", "summary", esc(it.summary))); d.appendChild(row); });
+ return d;
+ }
+
+ const RENDERERS = {
+ Page: (n) => { const d = el("div", "page");
+ const t0 = (n.props && n.props.title); if (t0) d.appendChild(el("div", "page-title", esc(tx(t0))));
+ return renderChildren(n, d); },
+ Section: (n) => { const p = n.props || {}; const d = el("section", "section");
+ if (p.title) d.appendChild(el("div", "section-title", esc(tx(p.title))));
+ if (p.subtitle) d.appendChild(el("div", "section-subtitle", esc(tx(p.subtitle))));
+ return renderChildren(n, d); },
+ Grid: (n) => renderChildren(n, el("div", "grid" + gridCols(n.props || {}))),
+ Row: (n) => { const v = (n.props || {}).variant; const cls = v === "metrics" ? " metric-strip" : (v === "meta" ? " row-meta" : ""); return renderChildren(n, el("div", "row" + cls)); },
+ Col: (n) => renderChildren(n, el("div", "col")),
+ Card: (n) => { const d = el("div", "card");
+ const title = n.props && n.props.title; if (title) d.appendChild(el("div", "card-title", esc(tx(title))));
+ const kicker = n.props && n.props.kicker; if (kicker) d.appendChild(el("div", "kicker", esc(tx(kicker))));
+ return renderChildren(n, d); },
+ Board: (n) => { const d = el("div", "board");
+ const title = n.props && n.props.title; if (title) d.appendChild(el("div", "board-title", esc(tx(title))));
+ return renderChildren(n, d); },
+ Toolbar: (n) => renderChildren(n, el("div", "toolbar")),
+ Stat: (n) => { const p = n.props || {}; const d = el("div", "stat");
+ d.appendChild(el("div", "label", esc(tx(p.label))));
+ const v = (p.value != null && p.value !== "") ? (p.i18nValue ? tx(p.value) : p.value) : "\u2014";
+ d.appendChild(el("div", "value", esc(v)));
+ return d; },
+ Markdown: (n) => el("div", "md prose", esc((n.props || {}).text)),
+ StoryPanel: (n) => { const p = n.props || {}; const d = el("div", "card story-panel");
+ d.appendChild(el("div", "card-title", esc(tx(p.title || "Storyline"))));
+ d.appendChild(renderAbstract(p.text)); return d; },
+ List: renderList,
+ SuggestionChips: (n) => { const items = ((n.props || {}).data) || []; const d = el("div", "chips");
+ asArray(items).forEach((it) => d.appendChild(el("button", null, esc(it)))); return d; },
+ Gauge: (n) => { const p = n.props || {}; return renderGaugeValue(p.label || "Gauge", p.data != null ? p.data : p.value); },
+ ProgressBar: (n) => { const p = n.props || {}; const d = el("div", "progress"); const fill = el("span", "progress-fill"); fill.style.width = Math.max(0, Math.min(100, Number(p.value || 0))) + "%"; d.appendChild(fill); return d; },
+ Badge: (n) => { const p = n.props || {}; return el("span", "badge sev-" + String(p.tone || p.severity || "info").toLowerCase(), esc(tx(p.label || p.text || "info"))); },
+ Table: renderTable,
+ Timeline: renderTimeline,
+ BarChart: (n) => chartNode(renderChartBars((n.props || {}).data, (n.props || {}).title || "Severity mix"), n.props || {}),
+ AreaChart: (n) => chartNode(renderSparkline((n.props || {}).data, (n.props || {}).title, { area: true }), n.props || {}),
+ LineChart: (n) => chartNode(renderSparkline((n.props || {}).data, (n.props || {}).title), n.props || {}),
+ Sparkline: (n) => chartNode(renderSparkline((n.props || {}).data, (n.props || {}).title), n.props || {}),
+ CandlestickChart: (n) => chartNode(renderCandlestick((n.props || {}).data, (n.props || {}).title || "Candlestick"), n.props || {}),
+ PieChart: (n) => chartNode(renderPie((n.props || {}).data, (n.props || {}).title || "Severity mix"), n.props || {}),
+ Quote: (n) => { const p = n.props || {}; const q = el("blockquote", "quote", esc(p.text)); if (p.source) q.appendChild(el("cite", null, esc(p.source))); return q; },
+ CitationList: (n) => { const items = asArray((n.props || {}).data); const ol = el("ol", "citations"); items.forEach((it) => ol.appendChild(el("li", null, esc(it.label || it.title || it.url || it)))); return ol; },
+ EntityGraph: (n) => { const items = asArray((n.props || {}).data); const d = el("div", "entity-cloud"); items.forEach((it) => d.appendChild(el("span", "badge", esc(it.name || it.title || it)))); return d; },
+ Custom: (n) => { const p = n.props || {}; const fn = CUSTOM_RENDERERS[p.render];
+ return fn ? fn(p) : el("div", "card md", esc(t("Custom") + ": " + (p.render || "?"))); },
+ FindingCard: renderFinding,
+ InsightCard: renderFinding,
+ Button: (n) => el("button", null, esc(tx((n.props || {}).label || (n.props || {}).text || "Action"))),
+ FilterBar: (n) => el("div", "toolbar", ""),
+ };
+
+ function renderFinding(node) {
+ const p = node.props || {};
+ const sev = (p.severity || "info").toLowerCase();
+ const d = el("div", "finding sev-" + sev);
+ d.appendChild(el("div", "sev", esc(t(sev))));
+ d.appendChild(el("div", "card-title", esc(p.title)));
+ if (p.summary) d.appendChild(el("div", "summary", esc(p.summary)));
+ return d;
+ }
+
+ function renderNode(node) {
+ if (!node || typeof node !== "object") return el("div", "md", esc(node));
+ const fn = RENDERERS[node.type];
+ let dom;
+ if (fn) {
+ dom = fn(node);
+ } else {
+ dom = el("div", "card"); // safe fallback for unknown catalog types
+ dom.appendChild(el("div", "sev", esc(node.type || "unknown")));
+ dom.appendChild(el("div", "md", esc((node.props && node.props.text) || JSON.stringify(node.props || {}))));
+ renderChildren(node, dom);
+ }
+ applySpan(dom, node.props || {});
+ return bindAction(dom, node);
+ }
+
+ function render(spec) {
+ rootEl.innerHTML = "";
+ figSeq = 0; tblSeq = 0;
+ (spec.root || []).forEach((n) => rootEl.appendChild(renderNode(n)));
+ if (!(spec.root || []).length) rootEl.appendChild(el("div", "empty", esc(t("No content yet."))));
+ document.title = spec.title ? spec.title + " \u00b7 LeapBoard" : "LeapBoard";
+ }
+
+ function toast(finding) {
+ const sev = (finding.severity || "info").toLowerCase();
+ const t = el("div", "toast sev-" + sev);
+ t.appendChild(el("div", "card-title", esc(finding.title)));
+ if (finding.summary) t.appendChild(el("div", "summary", esc(finding.summary)));
+ toastsEl.appendChild(t);
+ setTimeout(() => t.remove(), 8000);
+ }
+
+ // ── Live updates over WebSocket ──
+ function connectWS() {
+ const proto = location.protocol === "https:" ? "wss" : "ws";
+ const ws = new WebSocket(proto + "://" + location.host + "/ws?token=" + encodeURIComponent(TOKEN));
+ ws.onopen = () => { statusEl.textContent = t("live"); };
+ ws.onclose = () => { statusEl.textContent = t("reconnecting…"); setTimeout(connectWS, 3000); };
+ ws.onmessage = (ev) => {
+ let msg; try { msg = JSON.parse(ev.data); } catch (_) { return; }
+ if (msg.type === "monitor.finding") { toast(msg.payload || {}); fetchView(); }
+ else if (msg.type === "watch.state") { fetchView(); }
+ else if (msg.type === "view.replace" && msg.spec) { render(msg.spec); }
+ };
+ }
+
+ if (localeEl) {
+ localeEl.addEventListener("change", () => {
+ locale = localeEl.value || "en";
+ localStorage.setItem("leapboard.locale", locale);
+ applyLocale();
+ fetchView();
+ });
+ }
+
+ applyLocale();
+ fetchView();
+ connectWS();
+})();
diff --git a/src/leapflow/dashboard/static/index.html b/src/leapflow/dashboard/static/index.html
new file mode 100644
index 0000000..2c86f78
--- /dev/null
+++ b/src/leapflow/dashboard/static/index.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+ LeapBoard
+
+
+
+
+
+
+
+ Loading…
+
+
+
+
diff --git a/src/leapflow/dashboard/static/styles.css b/src/leapflow/dashboard/static/styles.css
new file mode 100644
index 0000000..f5663e6
--- /dev/null
+++ b/src/leapflow/dashboard/static/styles.css
@@ -0,0 +1,218 @@
+/* LeapBoard design system: Swiss International Typographic + minimalism +
+ academic paper. Light "paper" ground, near-black ink, one accent, hairline
+ rules, flat surfaces (no gradients / radius / shadow), tight modular grid,
+ tabular figures, and section/figure/table numbering for dense, structured
+ reading. Shared by every template lens (generic/finance/research/sentiment). */
+:root {
+ /* Fluid base type: gently scales with viewport, clamped for legibility. All
+ component sizes are rem-relative, so the whole board grows on large screens
+ and stays readable on small ones. */
+ font-size: clamp(14px, 0.3vw + 12px, 16.5px);
+ /* Paper palette */
+ --bg: #fafaf8; --panel: #ffffff; --panel-2: #f4f4f1; --line: #e3e3dd;
+ --ink: #17181c; --muted: #6b7280; --faint: #9aa0ab;
+ /* Single accent + semantic severities (muted for light-ground legibility) */
+ --accent: #b4322a; --alert: #b4322a; --notable: #9a6d15; --info: #3f5c7a;
+ --track: #ececE6;
+ /* Type roles: grotesque for structure/data, serif only for the abstract */
+ --sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
+ --serif: "Source Serif 4", Georgia, "Times New Roman", serif;
+ --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace;
+}
+* { box-sizing: border-box; }
+body {
+ margin: 0; background: var(--bg); color: var(--ink);
+ font: 1rem/1.5 var(--sans); -webkit-font-smoothing: antialiased;
+}
+
+/* ── Top bar ─────────────────────────────────────────────────────────────── */
+.topbar {
+ display: flex; align-items: center; gap: 16px; padding: 9px 18px;
+ background: var(--panel); border-bottom: 1px solid var(--line);
+ position: sticky; top: 0; z-index: 5;
+}
+.brand { display: flex; flex-direction: column; justify-content: center; line-height: 1.05; }
+.brand-mark { font-size: 1.23rem; font-weight: 500; letter-spacing: -0.01em; color: var(--ink); }
+.brand-mark-strong { font-weight: 800; }
+.brand-slogan {
+ font-size: 0.73rem; font-weight: 600; letter-spacing: .16em; text-transform: uppercase;
+ color: var(--faint); margin-top: 3px;
+}
+.brand-divider { width: 1px; height: 24px; background: var(--line); }
+.nav a {
+ color: var(--muted); text-decoration: none; margin-right: 14px; font-size: 0.92rem;
+ letter-spacing: .02em; padding-bottom: 2px; border-bottom: 2px solid transparent;
+}
+.nav a:hover { color: var(--ink); }
+.nav a.active { color: var(--ink); border-bottom-color: var(--accent); }
+.locale-label { color: var(--faint); font-size: 0.77rem; letter-spacing: .1em; text-transform: uppercase; margin-left: 4px; }
+.locale-switch {
+ background: var(--panel); color: var(--ink); border: 1px solid var(--line);
+ border-radius: 0; padding: 3px 7px; font-size: 0.85rem;
+}
+.status { margin-left: auto; color: var(--muted); font-size: 0.85rem; font-variant-numeric: tabular-nums; }
+
+/* ── Page scaffold + numbering counters ──────────────────────────────────── */
+.root { padding: 18px 22px 40px; max-width: 1480px; margin: 0 auto; }
+.empty { color: var(--muted); padding: 48px; text-align: center; }
+.page { counter-reset: sec fig tbl; }
+.page-title {
+ font-size: 1.62rem; font-weight: 800; letter-spacing: -0.015em; color: var(--ink);
+ margin: 2px 0 14px; padding-bottom: 8px; border-bottom: 2px solid var(--ink);
+}
+
+/* ── Sections (Swiss numeric index + uppercase label) ────────────────────── */
+.section { margin-bottom: 18px; }
+.section-title {
+ color: var(--ink); font-size: 0.85rem; letter-spacing: .13em; text-transform: uppercase;
+ font-weight: 700; margin: 16px 0 3px; padding-bottom: 4px; border-bottom: 1px solid var(--line);
+ display: flex; align-items: baseline;
+}
+.section-title::before {
+ counter-increment: sec; content: counter(sec, decimal-leading-zero);
+ color: var(--accent); margin-inline-end: 9px; font-variant-numeric: tabular-nums; font-weight: 800;
+}
+.section-subtitle { color: var(--muted); font-size: 0.88rem; margin: 0 0 10px; max-width: 92ch; }
+
+/* ── Layout primitives (dense modular grid) ──────────────────────────────── */
+.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 9px; align-items: start; }
+.grid.cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+.grid.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+.grid.cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+.span-2 { grid-column: span 2; }
+.span-3 { grid-column: span 3; }
+@media (max-width: 760px) {
+ .grid, .grid[class*="cols-"] { grid-template-columns: 1fr; }
+ [class*="span-"] { grid-column: auto; }
+}
+/* ── Large screens: widen the reading measure, keep a single top-to-bottom ──
+ column so numbered sections always read 01 -> 04 in order. Each section's
+ own grid still spreads its cards across the extra horizontal space. */
+@media (min-width: 1400px) { .root { max-width: 1640px; } }
+@media (min-width: 1680px) { .root { max-width: 1860px; } }
+@media (min-width: 2200px) { .root { max-width: 2080px; } }
+.row { display: flex; gap: 9px; flex-wrap: wrap; }
+.col { display: flex; flex-direction: column; gap: 9px; }
+.board { display: flex; flex-direction: column; gap: 8px; }
+.board-title, .card-title { font-weight: 700; font-size: 0.96rem; margin-bottom: 7px; letter-spacing: -0.005em; }
+.kicker { color: var(--muted); font-size: 0.77rem; letter-spacing: .12em; text-transform: uppercase; margin-bottom: 5px; }
+
+/* ── Flat surfaces (hairline rectangles, no radius/shadow/gradient) ──────── */
+.card, .finding {
+ background: var(--panel); border: 1px solid var(--line); border-radius: 0; padding: 10px 12px;
+}
+.finding { border-left: 3px solid var(--line); cursor: pointer; }
+.finding.sev-alert { border-left-color: var(--alert); }
+.finding.sev-notable { border-left-color: var(--notable); }
+.finding.sev-info { border-left-color: var(--info); }
+.finding .sev { font-size: 0.77rem; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); }
+.finding .summary { color: var(--muted); margin-top: 3px; }
+
+/* ── Metrics (KPI strip: a Row of Stats reads as a dense metric band) ────── */
+.stat { background: var(--panel); border: 1px solid var(--line); border-radius: 0; padding: 8px 11px; min-width: 96px; flex: 1 1 96px; }
+.stat .label { color: var(--muted); font-size: 0.77rem; letter-spacing: .08em; text-transform: uppercase; }
+.stat .value { font-size: 1.54rem; font-weight: 700; line-height: 1.15; margin-top: 2px; font-variant-numeric: tabular-nums; }
+.gauge-stat .value { color: var(--accent); }
+/* Metric band: a Row of Stats reads as one hairline-divided KPI strip. */
+.metric-strip { border: 1px solid var(--line); background: var(--panel); gap: 0; }
+.metric-strip .stat { border: 0; border-inline-start: 1px solid var(--line); border-radius: 0; flex: 1 1 120px; padding: 7px 13px; }
+.metric-strip .stat:first-child { border-inline-start: 0; }
+.metric-strip .stat .value { font-size: 1.38rem; }
+
+/* Meta line: subdued key:value observation info under the KPI strip. */
+.row-meta { gap: 16px; margin: -2px 0 12px; }
+.row-meta .stat { border: 0; background: none; padding: 0; min-width: 0; flex: 0 0 auto; }
+.row-meta .stat .label { font-size: 0.77rem; }
+.row-meta .stat .value { font-size: 0.88rem; font-weight: 600; color: var(--muted); }
+
+/* ── Abstract: the storyline rendered as an academic serif column ────────── */
+.story-panel { border-top: 2px solid var(--accent); }
+.abstract, .story-text { font-family: var(--serif); font-size: 0.94rem; line-height: 1.6; color: var(--ink); max-width: 74ch; }
+.abstract .lead, .story-text .lead { font-weight: 600; }
+
+/* ── Lists: compact bullets, or a definition list for object items ───────── */
+.insight-list { margin: 0; padding-inline-start: 15px; color: var(--ink); }
+.insight-list li { margin: 3px 0; }
+.dl { margin: 0; display: grid; grid-template-columns: max-content 1fr; column-gap: 12px; row-gap: 3px; }
+.dl dt { font-weight: 600; color: var(--ink); }
+.dl dd { margin: 0; color: var(--muted); }
+.empty-inline { color: var(--faint); font-size: 0.92rem; padding: 3px 0; }
+.chips { display: flex; gap: 6px; flex-wrap: wrap; }
+/* Card content text (prose, lists, summaries, cells): one step smaller than the
+ card titles/labels/KPIs for denser, calmer reading. */
+.insight-list, .dl, .citations, .summary, .prose, .md, .chips button { font-size: 0.9rem; }
+
+/* ── Figures + captions (academic numbering set by the renderer) ─────────── */
+.chart { min-height: 120px; }
+.chart-placeholder { color: var(--muted); border: 1px dashed var(--line); border-radius: 0; padding: 30px 14px; text-align: center; }
+.figcaption { color: var(--muted); font-size: 0.81rem; margin-top: 7px; padding-top: 5px; border-top: 1px solid var(--line); }
+.figcaption .fignum { color: var(--accent); font-weight: 700; margin-inline-end: 5px; }
+
+/* ── Bars / sparkline / pie (flat, single-hue accents) ───────────────────── */
+.bar-row { display: grid; grid-template-columns: 74px 1fr 30px; align-items: center; gap: 8px; margin: 7px 0; }
+.bar-label, .bar-value { color: var(--muted); font-size: 0.85rem; font-variant-numeric: tabular-nums; }
+.bar-track { height: 8px; background: var(--track); border-radius: 0; overflow: hidden; }
+.bar-fill { display: block; height: 100%; border-radius: 0; background: var(--info); }
+.bar-fill.sev-alert { background: var(--alert); }
+.bar-fill.sev-notable { background: var(--notable); }
+.bar-fill.sev-info { background: var(--info); }
+.sparkline { display: block; width: 100%; height: 86px; }
+.sparkline polyline { fill: none; stroke: var(--accent); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
+.pie-card { align-items: center; }
+.pie { width: 104px; height: 104px; border-radius: 50%; margin: 6px auto 12px; box-shadow: inset 0 0 0 16px var(--panel); }
+.legend { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; }
+.legend-item, .badge {
+ border: 1px solid var(--line); border-radius: 0; padding: 2px 7px; color: var(--muted);
+ font-size: 0.81rem; font-variant-numeric: tabular-nums;
+}
+.badge.sev-alert, .legend-item.sev-alert { border-color: var(--alert); color: var(--alert); }
+.badge.sev-notable, .legend-item.sev-notable { border-color: var(--notable); color: var(--notable); }
+.badge.sev-info, .legend-item.sev-info { border-color: var(--info); color: var(--info); }
+
+/* ── Timeline / progress ─────────────────────────────────────────────────── */
+.timeline { display: flex; flex-direction: column; gap: 8px; }
+.timeline-item { position: relative; padding: 6px 0 6px 16px; border-left: 1px solid var(--line); }
+.timeline-item::before { content: ""; position: absolute; left: -5px; top: 12px; width: 8px; height: 8px; border-radius: 50%; background: var(--info); }
+.timeline-item.sev-alert::before { background: var(--alert); }
+.timeline-item.sev-notable::before { background: var(--notable); }
+.timeline-title { font-weight: 600; }
+.progress { height: 8px; background: var(--track); border-radius: 0; overflow: hidden; margin-top: 9px; }
+.progress-fill { display: block; height: 100%; background: var(--accent); border-radius: 0; }
+
+/* ── Tables (dense academic rules + tabular figures) ─────────────────────── */
+table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
+.data-table { font-size: 0.85rem; }
+th, td { text-align: left; padding: 3px 7px; border-bottom: 1px solid var(--line); }
+thead th { border-bottom: 1.5px solid var(--ink); }
+th { color: var(--muted); font-weight: 600; font-size: 0.81rem; letter-spacing: .04em; text-transform: uppercase; position: sticky; top: 0; background: var(--panel); }
+caption { caption-side: top; text-align: left; color: var(--muted); font-size: 0.81rem; padding-bottom: 5px; }
+caption .fignum { color: var(--accent); font-weight: 700; margin-inline-end: 5px; }
+
+/* ── Evidence + entities ─────────────────────────────────────────────────── */
+.quote { margin: 0; padding: 8px 12px; border-left: 2px solid var(--accent); color: var(--ink); background: var(--panel-2); }
+.quote cite { display: block; color: var(--muted); margin-top: 5px; font-size: 0.85rem; }
+.citations { margin: 0; padding-inline-start: 18px; color: var(--ink); }
+.citations li { margin: 3px 0; }
+.entity-cloud { display: flex; flex-wrap: wrap; gap: 6px; }
+.mini-chart { min-height: 96px; }
+
+/* ── Controls ────────────────────────────────────────────────────────────── */
+.toolbar { display: flex; gap: 7px; margin-bottom: 10px; }
+button, .btn {
+ background: var(--panel); color: var(--ink); border: 1px solid var(--line);
+ border-radius: 0; padding: 5px 11px; cursor: pointer; font: inherit;
+}
+button:hover { border-color: var(--accent); color: var(--accent); }
+
+/* ── Prose fallback ──────────────────────────────────────────────────────── */
+.md { white-space: pre-wrap; }
+.prose { line-height: 1.55; color: var(--ink); }
+
+/* ── Toasts ──────────────────────────────────────────────────────────────── */
+.toasts { position: fixed; right: 18px; bottom: 18px; display: flex; flex-direction: column; gap: 8px; }
+.toast {
+ background: var(--panel); border: 1px solid var(--line); border-left: 3px solid var(--accent);
+ border-radius: 0; padding: 9px 13px; max-width: 340px; box-shadow: 0 2px 10px rgba(23,24,28,.12);
+}
+.toast.sev-alert { border-left-color: var(--alert); }
+.toast.sev-notable { border-left-color: var(--notable); }
diff --git a/src/leapflow/dashboard/templates.py b/src/leapflow/dashboard/templates.py
new file mode 100644
index 0000000..5208a24
--- /dev/null
+++ b/src/leapflow/dashboard/templates.py
@@ -0,0 +1,316 @@
+"""YAML template rendering into a validated ViewSpec (the SDUI authoring layer).
+
+Templates are authored in YAML per scenario and compiled at runtime into a
+ViewSpec by binding live data into a component tree. Binding is intentionally
+minimal and safe -- whitelisted dotted paths and ``{{ path }}`` interpolation,
+never arbitrary evaluation. A ``repeat`` directive expands one node per item in a
+bound list (e.g. one FindingCard per finding).
+"""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+from typing import Any, Mapping, Optional
+
+from leapflow.dashboard.viewspec import SCHEMA_VERSION, normalize_viewspec
+
+_INDEX_RE = re.compile(r"^(.*?)\[(\d+)\]$")
+_FULL_RE = re.compile(r"^\{\{\s*(.+?)\s*\}\}$")
+_PART_RE = re.compile(r"\{\{\s*(.+?)\s*\}\}")
+_TEMPLATE_ID_RE = re.compile(r"[^a-z0-9._-]+")
+
+
+def sanitize_template_id(raw: str) -> str:
+ """Normalize a user-supplied template id to a safe, filesystem-friendly slug."""
+ return _TEMPLATE_ID_RE.sub("-", str(raw).strip().lower()).strip("-._")
+
+# Inline fallback so rendering works even if no template files are present.
+_DEFAULT_GENERIC: dict[str, Any] = {
+ "template": "generic",
+ "title": "{{ title }}",
+ "layout": [
+ {
+ "type": "Board",
+ "props": {"title": "Findings"},
+ "children": [
+ {
+ "type": "FindingCard",
+ "repeat": "findings",
+ "as": "f",
+ "props": {
+ "title": "{{ f.title }}",
+ "summary": "{{ f.summary }}",
+ "severity": "{{ f.severity }}",
+ "bind": "f",
+ },
+ }
+ ],
+ }
+ ],
+}
+
+
+def resolve_path(data: Any, path: str) -> Any:
+ """Resolve a dotted path with optional ``[i]`` indices; None when missing."""
+ current = data
+ for raw_part in str(path).split("."):
+ if not raw_part:
+ continue
+ part, index = raw_part, None
+ match = _INDEX_RE.match(raw_part)
+ if match:
+ part, index = match.group(1), int(match.group(2))
+ if part:
+ if isinstance(current, Mapping) and part in current:
+ current = current[part]
+ else:
+ return None
+ if index is not None:
+ if isinstance(current, (list, tuple)) and 0 <= index < len(current):
+ current = current[index]
+ else:
+ return None
+ return current
+
+
+def _coerce_str(value: Any) -> str:
+ return "" if value is None else str(value)
+
+
+def bind_value(value: Any, data: Mapping[str, Any]) -> Any:
+ """Bind ``{{ path }}`` templates inside strings/dicts/lists against ``data``.
+
+ A string that is a *single* placeholder resolves to the bound value with its
+ native type preserved (e.g. an int stays an int). Any other string -- plain
+ text, or multiple placeholders such as ``"{{ a }}/{{ b }}"`` -- is rendered
+ by per-placeholder interpolation into a string.
+ """
+ if isinstance(value, str):
+ full = _FULL_RE.match(value.strip())
+ if full and "{{" not in full.group(1) and "}}" not in full.group(1):
+ return resolve_path(data, full.group(1))
+ return _PART_RE.sub(lambda m: _coerce_str(resolve_path(data, m.group(1))), value)
+ if isinstance(value, Mapping):
+ return {key: bind_value(val, data) for key, val in value.items()}
+ if isinstance(value, list):
+ return [bind_value(item, data) for item in value]
+ return value
+
+
+def _bind_props(props: Mapping[str, Any], data: Mapping[str, Any]) -> dict[str, Any]:
+ out: dict[str, Any] = {}
+ for key, value in props.items():
+ if key == "bind" and isinstance(value, str):
+ out["data"] = resolve_path(data, value)
+ else:
+ out[key] = bind_value(value, data)
+ return out
+
+
+def render_node(node: Any, data: Mapping[str, Any]) -> list[dict[str, Any]]:
+ """Render one template node into zero or more concrete nodes."""
+ if not isinstance(node, Mapping):
+ return [{"type": "Markdown", "props": {"text": str(node)}}]
+
+ # ``when: `` gates a node on non-empty data so empty panels (no file
+ # artifacts, no chart series, no process notes) are omitted entirely rather
+ # than rendered as blank cards.
+ when = node.get("when")
+ if isinstance(when, str) and when and not resolve_path(data, when):
+ return []
+
+ repeat = node.get("repeat")
+ if isinstance(repeat, str) and repeat:
+ items = resolve_path(data, repeat)
+ if not isinstance(items, (list, tuple)):
+ return []
+ as_name = str(node.get("as") or "item")
+ base = {key: value for key, value in node.items() if key not in ("repeat", "as", "when")}
+ expanded: list[dict[str, Any]] = []
+ for item in items:
+ scope = dict(data)
+ scope[as_name] = item
+ expanded.extend(render_node(base, scope))
+ return expanded
+
+ rendered: dict[str, Any] = {"type": str(node.get("type", ""))}
+ rendered["props"] = _bind_props(dict(node.get("props") or {}), data)
+ action = node.get("action")
+ if isinstance(action, Mapping):
+ rendered["action"] = bind_value(dict(action), data)
+ children = node.get("children")
+ if isinstance(children, list):
+ kids: list[dict[str, Any]] = []
+ for child in children:
+ kids.extend(render_node(child, data))
+ rendered["children"] = kids
+ return [rendered]
+
+
+def render_template(template: Any, data: Optional[Mapping[str, Any]] = None) -> dict[str, Any]:
+ """Compile a template dict + data into a normalized, render-safe ViewSpec."""
+ template = template if isinstance(template, Mapping) else {}
+ data = data or {}
+ layout = template.get("layout", template.get("root", []))
+ if not isinstance(layout, list):
+ layout = [layout]
+ root: list[dict[str, Any]] = []
+ for node in layout:
+ root.extend(render_node(node, data))
+ return normalize_viewspec({
+ "schema_version": template.get("schema_version", SCHEMA_VERSION),
+ "title": bind_value(template.get("title", ""), data),
+ "domain": template.get("domain", ""),
+ "root": root,
+ "meta": {
+ "template": template.get("template", ""),
+ "refresh": dict(template.get("refresh") or {}),
+ },
+ })
+
+
+class TemplateLibrary:
+ """Loads YAML templates from a builtin dir plus an optional override dir.
+
+ Profile-level override templates take precedence over builtin ones.
+ """
+
+ def __init__(
+ self,
+ builtin_dir: Optional[Path] = None,
+ override_dir: Optional[Path] = None,
+ ) -> None:
+ self._builtin = builtin_dir or (Path(__file__).parent / "templates")
+ self._override = override_dir
+
+ def _dirs(self) -> list[Path]:
+ return [d for d in (self._override, self._builtin) if d is not None]
+
+ def names(self) -> list[str]:
+ """Return available template names (override + builtin)."""
+ found: set[str] = set()
+ for directory in self._dirs():
+ if directory.exists():
+ for path in directory.glob("*.yaml"):
+ found.add(path.stem)
+ return sorted(found)
+
+ def load(self, name: str) -> Optional[dict[str, Any]]:
+ """Load a raw template dict by name, or None when not found."""
+ import yaml
+
+ for directory in self._dirs():
+ path = directory / f"{name}.yaml"
+ if path.exists():
+ loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
+ return loaded if isinstance(loaded, dict) else None
+ return None
+
+ def render(self, name: str, data: Optional[Mapping[str, Any]] = None) -> dict[str, Any]:
+ """Render a template by name, falling back to a builtin generic view."""
+ template = self.load(name) or self.load("generic") or _DEFAULT_GENERIC
+ return render_template(template, data or {})
+
+ def builtin_names(self) -> list[str]:
+ """Return template names shipped with the package."""
+ found: set[str] = set()
+ if self._builtin.exists():
+ for path in self._builtin.glob("*.yaml"):
+ found.add(path.stem)
+ return sorted(found)
+
+ def user_names(self) -> list[str]:
+ """Return template names added by the user (override dir)."""
+ found: set[str] = set()
+ if self._override is not None and self._override.exists():
+ for path in self._override.glob("*.yaml"):
+ found.add(path.stem)
+ return sorted(found)
+
+ def source_of(self, name: str) -> str:
+ """Return 'user' for override templates, 'builtin' for shipped, else ''."""
+ if name in self.user_names():
+ return "user"
+ if name in self.builtin_names():
+ return "builtin"
+ return ""
+
+ def describe(self, name: str) -> Optional[dict[str, Any]]:
+ """Return compact metadata for one template, or None when absent."""
+ raw = self.load(name)
+ if raw is None:
+ return None
+ meta = raw.get("meta") if isinstance(raw.get("meta"), dict) else {}
+ return {
+ "name": name,
+ "source": self.source_of(name),
+ "title": str(meta.get("title") or raw.get("title") or name),
+ "description": str(meta.get("description") or ""),
+ }
+
+ @staticmethod
+ def validate(raw: Any) -> Optional[str]:
+ """Return an error string when raw is not a renderable template, else None."""
+ if not isinstance(raw, dict):
+ return "template must be a YAML mapping"
+ if not any(key in raw for key in ("layout", "children", "type")):
+ return "template must define a 'layout'"
+ try:
+ render_template(raw, {})
+ except Exception as exc: # noqa: BLE001 - surface why it will not compile
+ return f"template does not compile: {exc}"
+ return None
+
+ def install(self, source: Path, *, name: str = "", force: bool = False) -> str:
+ """Validate a YAML template and copy it into the override dir; return its id.
+
+ Raises ValueError on missing/unreadable source, invalid content, builtin
+ name collision (without force), or when no writable dir is configured.
+ """
+ import shutil
+
+ import yaml
+
+ if self._override is None:
+ raise ValueError("no writable template directory is configured")
+ src = Path(source).expanduser()
+ if not src.is_file():
+ raise ValueError(f"template file not found: {source}")
+ try:
+ raw = yaml.safe_load(src.read_text(encoding="utf-8"))
+ except (OSError, yaml.YAMLError, ValueError) as exc:
+ raise ValueError(f"cannot read template: {exc}") from exc
+ error = self.validate(raw)
+ if error:
+ raise ValueError(error)
+ template_id = sanitize_template_id(name or src.stem)
+ if not template_id:
+ raise ValueError("could not derive a valid template name")
+ if template_id in self.builtin_names() and not force:
+ raise ValueError(
+ f"'{template_id}' shadows a builtin template; pass --force to override"
+ )
+ self._override.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(src, self._override / f"{template_id}.yaml")
+ return template_id
+
+ def uninstall(self, name: str) -> bool:
+ """Remove a user template by id. Returns True when a file was removed."""
+ if self._override is None:
+ return False
+ path = self._override / f"{sanitize_template_id(name)}.yaml"
+ if path.exists():
+ path.unlink()
+ return True
+ return False
+
+
+__all__ = [
+ "resolve_path",
+ "bind_value",
+ "render_node",
+ "render_template",
+ "sanitize_template_id",
+ "TemplateLibrary",
+]
diff --git a/src/leapflow/dashboard/templates/finance.yaml b/src/leapflow/dashboard/templates/finance.yaml
new file mode 100644
index 0000000..ab23dd5
--- /dev/null
+++ b/src/leapflow/dashboard/templates/finance.yaml
@@ -0,0 +1,86 @@
+template: finance
+version: 1
+title: "{{ title }}"
+domain: finance
+meta:
+ title: "Finance lens"
+ description: "Reframes the current-session analysis for financial decision-making: calls, actions, and exposures first."
+layout:
+ - type: Page
+ props:
+ title: "{{ title }}"
+ children:
+ - type: Section
+ props:
+ title: "Desk brief"
+ subtitle: "Storyline and signal strength before drilling into positions and actions."
+ children:
+ - type: Grid
+ children:
+ - type: StoryPanel
+ props:
+ title: "Narrative"
+ text: "{{ analysis.story }}"
+ - type: BarChart
+ props:
+ title: "Signal strength"
+ bind: analysis.insights
+ # Price action: real OHLC candlesticks, only when the session captured them.
+ - type: Section
+ when: analysis.charts.ohlc
+ props:
+ title: "Price action"
+ subtitle: "OHLC extracted from captured session market data."
+ children:
+ - type: CandlestickChart
+ props:
+ title: "Candlestick"
+ caption: "Open/high/low/close from captured tool output."
+ bind: analysis.charts.ohlc
+ - type: LineChart
+ when: analysis.charts.series
+ props:
+ title: "Series"
+ bind: analysis.charts.series
+ - type: Section
+ props:
+ title: "Positions & actions"
+ subtitle: "Decisions read as calls; action items as the execution checklist."
+ children:
+ - type: Grid
+ children:
+ - type: Card
+ props:
+ title: "Calls (decisions)"
+ children:
+ - type: List
+ props:
+ bind: analysis.decisions
+ - type: Card
+ props:
+ title: "Execution checklist"
+ children:
+ - type: List
+ props:
+ bind: analysis.action_items
+ - type: Section
+ props:
+ title: "Watchlist"
+ subtitle: "Entities in play and the open risks still to resolve."
+ children:
+ - type: Grid
+ children:
+ - type: Card
+ props:
+ title: "Instruments & counterparties"
+ children:
+ - type: EntityGraph
+ props:
+ bind: analysis.entities
+ - type: Card
+ props:
+ title: "Open risks"
+ children:
+ - type: List
+ props:
+ bind: analysis.open_questions
diff --git a/src/leapflow/dashboard/templates/generic.yaml b/src/leapflow/dashboard/templates/generic.yaml
new file mode 100644
index 0000000..eeca278
--- /dev/null
+++ b/src/leapflow/dashboard/templates/generic.yaml
@@ -0,0 +1,149 @@
+template: generic
+version: 1
+title: "{{ title }}"
+domain: generic
+meta:
+ title: "Session analysis"
+ description: "Default, general-purpose lens: user-facing conversation intelligence."
+# Refresh policy is consumed by the session producer; harmless elsewhere.
+refresh:
+ batch_turns: 6
+ use_model_salience: true
+layout:
+ - type: Page
+ props:
+ title: "{{ title }}"
+ children:
+ # KPI metric band: compact, hairline-divided, always-meaningful numbers.
+ - type: Row
+ props:
+ variant: metrics
+ children:
+ - type: Stat
+ props:
+ label: "Turns"
+ value: "{{ analysis.usage.turns }}"
+ - type: Stat
+ props:
+ label: "Tokens"
+ value: "{{ analysis.usage.tokens }}"
+ - type: Stat
+ props:
+ label: "Artifacts"
+ value: "{{ observation.artifact_count }}"
+ # 01 Executive brief: wide abstract beside the severity figure.
+ - type: Section
+ props:
+ title: "Executive brief"
+ subtitle: "Coverage · storyline · severity"
+ children:
+ - type: Grid
+ props:
+ cols: 3
+ children:
+ - type: StoryPanel
+ props:
+ title: "Abstract"
+ text: "{{ analysis.story }}"
+ span: 2
+ - type: BarChart
+ props:
+ title: "Severity mix"
+ caption: "Insight count by severity."
+ bind: analysis.insights
+ # File artifacts: only rendered when the session actually produced files.
+ - type: Card
+ when: artifact_context
+ props:
+ title: "File artifacts"
+ kicker: "Observation"
+ children:
+ - type: Table
+ props:
+ caption: "Session file artifacts."
+ bind: artifact_context
+ columns:
+ - key: name
+ label: File
+ - key: status
+ label: Status
+ - key: reason
+ label: Note
+ # Data series: its own titled section, only when the session captured
+ # numeric series. The chart names each line and cites its provenance.
+ - type: Section
+ when: analysis.charts.series
+ props:
+ title: "Series"
+ children:
+ - type: LineChart
+ props:
+ caption: "Extracted from this session's tool/file output (not model-generated)."
+ bind: analysis.charts.series
+ # Insights: key observations, one card each (grid fills the width).
+ - type: Section
+ props:
+ title: "Insights"
+ subtitle: "Key observations"
+ children:
+ - type: Grid
+ children:
+ - type: InsightCard
+ repeat: analysis.insights
+ as: i
+ props:
+ title: "{{ i.title }}"
+ summary: "{{ i.summary }}"
+ severity: "{{ i.severity }}"
+ bind: i
+ # Operating agenda: decisions, actions, open questions.
+ - type: Section
+ props:
+ title: "Operating agenda"
+ subtitle: "Decisions and actions"
+ children:
+ - type: Grid
+ children:
+ - type: Card
+ props:
+ title: "Action items"
+ children:
+ - type: List
+ props:
+ bind: analysis.action_items
+ - type: Card
+ props:
+ title: "Decisions"
+ children:
+ - type: List
+ props:
+ bind: analysis.decisions
+ - type: Card
+ props:
+ title: "Open questions"
+ children:
+ - type: List
+ props:
+ bind: analysis.open_questions
+ # Context map: entities and recommended follow-ups.
+ - type: Section
+ props:
+ title: "Context map"
+ subtitle: "Entities and follow-ups"
+ children:
+ - type: Grid
+ children:
+ - type: Card
+ props:
+ title: "Entities"
+ children:
+ - type: EntityGraph
+ props:
+ bind: analysis.entities
+ - type: Card
+ props:
+ title: "Next prompts"
+ children:
+ - type: SuggestionChips
+ props:
+ bind: analysis.next_prompts
diff --git a/src/leapflow/dashboard/templates/research.yaml b/src/leapflow/dashboard/templates/research.yaml
new file mode 100644
index 0000000..9c9aeb6
--- /dev/null
+++ b/src/leapflow/dashboard/templates/research.yaml
@@ -0,0 +1,66 @@
+template: research
+version: 1
+title: "{{ title }}"
+domain: research
+meta:
+ title: "Research lens"
+ description: "Reframes the current-session analysis for inquiry: open questions and follow-ups first, evidence and references alongside."
+layout:
+ - type: Page
+ props:
+ title: "{{ title }}"
+ children:
+ - type: Section
+ props:
+ title: "Inquiry brief"
+ subtitle: "The line of investigation and where the open questions concentrate."
+ children:
+ - type: Grid
+ children:
+ - type: StoryPanel
+ props:
+ title: "Line of inquiry"
+ text: "{{ analysis.story }}"
+ - type: Card
+ props:
+ title: "Open questions"
+ children:
+ - type: List
+ props:
+ bind: analysis.open_questions
+ - type: Section
+ props:
+ title: "Findings"
+ subtitle: "Insights carded as evidence, capped for fast review."
+ children:
+ - type: Grid
+ children:
+ - type: InsightCard
+ repeat: analysis.insights
+ as: i
+ props:
+ title: "{{ i.title }}"
+ summary: "{{ i.summary }}"
+ severity: "{{ i.severity }}"
+ bind: i
+ - type: Section
+ props:
+ title: "References & follow-ups"
+ subtitle: "Entities as references, and recommended next prompts to advance the work."
+ children:
+ - type: Grid
+ children:
+ - type: Card
+ props:
+ title: "References (entities)"
+ children:
+ - type: EntityGraph
+ props:
+ bind: analysis.entities
+ - type: Card
+ props:
+ title: "Follow-ups"
+ children:
+ - type: SuggestionChips
+ props:
+ bind: analysis.next_prompts
diff --git a/src/leapflow/dashboard/templates/sentiment.yaml b/src/leapflow/dashboard/templates/sentiment.yaml
new file mode 100644
index 0000000..7906621
--- /dev/null
+++ b/src/leapflow/dashboard/templates/sentiment.yaml
@@ -0,0 +1,63 @@
+template: sentiment
+version: 1
+title: "{{ title }}"
+domain: sentiment
+meta:
+ title: "Sentiment lens"
+ description: "Reframes the current-session analysis around tone and reception: narrative pulse, themes, and unresolved concerns."
+layout:
+ - type: Page
+ props:
+ title: "{{ title }}"
+ children:
+ - type: Section
+ props:
+ title: "Pulse"
+ subtitle: "The narrative arc and how strongly themes are trending."
+ children:
+ - type: Grid
+ children:
+ - type: StoryPanel
+ props:
+ title: "Narrative pulse"
+ text: "{{ analysis.story }}"
+ - type: BarChart
+ props:
+ title: "Theme intensity"
+ bind: analysis.insights
+ - type: Section
+ props:
+ title: "Themes"
+ subtitle: "Representative observations, capped for quick scanning."
+ children:
+ - type: Grid
+ children:
+ - type: InsightCard
+ repeat: analysis.insights
+ as: i
+ props:
+ title: "{{ i.title }}"
+ summary: "{{ i.summary }}"
+ severity: "{{ i.severity }}"
+ bind: i
+ - type: Section
+ props:
+ title: "Voices & concerns"
+ subtitle: "Who/what is in the conversation, and the concerns still open."
+ children:
+ - type: Grid
+ children:
+ - type: Card
+ props:
+ title: "Entities"
+ children:
+ - type: EntityGraph
+ props:
+ bind: analysis.entities
+ - type: Card
+ props:
+ title: "Concerns (open questions)"
+ children:
+ - type: List
+ props:
+ bind: analysis.open_questions
diff --git a/src/leapflow/dashboard/viewspec.py b/src/leapflow/dashboard/viewspec.py
new file mode 100644
index 0000000..f885b6d
--- /dev/null
+++ b/src/leapflow/dashboard/viewspec.py
@@ -0,0 +1,162 @@
+"""ViewSpec: the declarative, validated UI contract for the dashboard (SDUI).
+
+A ViewSpec is a JSON-serializable tree of components drawn from a fixed,
+versioned catalog. Templates and the engine may author ViewSpecs, but they can
+only reference catalog component types and a whitelisted action protocol -- never
+arbitrary HTML/JS. Unknown component types degrade to a Markdown node so a view
+never fails to render.
+
+Node shape::
+
+ {"type": "Card", "props": {...}, "children": [...], "action": {...}?}
+
+Action shape (bidirectional protocol)::
+
+ {"kind": "nav|rpc|intent|approval", "name": "...", "params": {...}}
+"""
+
+from __future__ import annotations
+
+from typing import Any, Mapping
+
+SCHEMA_VERSION = 1
+FALLBACK_TYPE = "Markdown"
+ACTION_KINDS = frozenset({"nav", "rpc", "intent", "approval"})
+
+# Fixed, versioned component vocabulary grouped by purpose. The frontend renderer
+# implements exactly these types; adding a scenario reuses them (or registers a
+# ``Custom`` escape-hatch renderer), never new core code.
+COMPONENT_CATALOG: dict[str, tuple[str, ...]] = {
+ "layout": ("Page", "Section", "Grid", "Row", "Col", "Tabs", "Tab", "Card", "Drawer", "Toolbar"),
+ "display": (
+ "Stat", "Table", "List", "Timeline", "Board", "Markdown", "EntityGraph",
+ "Gauge", "ProgressBar", "Badge",
+ ),
+ "chart": ("LineChart", "AreaChart", "BarChart", "CandlestickChart", "Sparkline", "Heatmap", "PieChart"),
+ "evidence": ("LinkCard", "Quote", "CitationList"),
+ "interactive": ("Button", "FilterBar", "Select", "DateRange", "Search", "Form", "Slider", "TagInput"),
+ "agent": ("FindingCard", "InsightCard", "StoryPanel", "ApprovalPrompt", "SuggestionChips", "AskBox"),
+ "escape": ("Custom", "Raw"),
+}
+
+COMPONENT_TYPES: frozenset[str] = frozenset(
+ name for group in COMPONENT_CATALOG.values() for name in group
+) | {FALLBACK_TYPE}
+
+
+class ViewSpecError(ValueError):
+ """Raised only by strict validation helpers, never by normalization."""
+
+
+def make_markdown(text: str, **props: Any) -> dict[str, Any]:
+ """Build a Markdown node (also the safe fallback for unknown types)."""
+ return {"type": "Markdown", "props": {"text": str(text), **props}}
+
+
+def _normalize_action(action: Any) -> dict[str, Any] | None:
+ if not isinstance(action, Mapping):
+ return None
+ kind = str(action.get("kind", ""))
+ if kind not in ACTION_KINDS:
+ return None
+ result: dict[str, Any] = {
+ "kind": kind,
+ "name": str(action.get("name", "")),
+ "params": dict(action.get("params") or {}),
+ }
+ if action.get("confirm"):
+ result["confirm"] = True
+ return result
+
+
+def normalize_node(node: Any) -> dict[str, Any]:
+ """Return a safe, catalog-valid node, degrading unknown types to Markdown."""
+ if not isinstance(node, Mapping):
+ return make_markdown(str(node))
+ ntype = str(node.get("type", ""))
+ if ntype not in COMPONENT_TYPES:
+ return make_markdown(
+ f"Unsupported component: {ntype or '(missing type)'}",
+ _unsupported=ntype,
+ )
+ out: dict[str, Any] = {"type": ntype, "props": dict(node.get("props") or {})}
+ action = _normalize_action(node.get("action"))
+ if action is not None:
+ out["action"] = action
+ children = node.get("children")
+ if isinstance(children, list):
+ out["children"] = [normalize_node(child) for child in children]
+ return out
+
+
+def normalize_viewspec(spec: Any) -> dict[str, Any]:
+ """Return a fully normalized, render-safe ViewSpec (never raises).
+
+ Accepts ``root`` or legacy ``layout`` for the node list.
+ """
+ data = spec if isinstance(spec, Mapping) else {}
+ root = data.get("root", data.get("layout", []))
+ if not isinstance(root, list):
+ root = [root]
+ return {
+ "schema_version": int(data.get("schema_version", SCHEMA_VERSION) or SCHEMA_VERSION),
+ "title": str(data.get("title", "")),
+ "domain": str(data.get("domain", "")),
+ "root": [normalize_node(node) for node in root],
+ "meta": dict(data.get("meta") or {}),
+ }
+
+
+def validate_viewspec(spec: Any) -> list[str]:
+ """Return a list of strict-validation error strings (empty when valid).
+
+ Used by templates/tests to catch authoring mistakes; runtime rendering uses
+ ``normalize_viewspec`` which degrades instead of failing.
+ """
+ errors: list[str] = []
+ if not isinstance(spec, Mapping):
+ return ["ViewSpec must be a mapping"]
+ version = spec.get("schema_version", SCHEMA_VERSION)
+ if int(version or 0) != SCHEMA_VERSION:
+ errors.append(f"unsupported schema_version: {version!r} (expected {SCHEMA_VERSION})")
+ root = spec.get("root", spec.get("layout"))
+ if not isinstance(root, list):
+ errors.append("ViewSpec.root must be a list of components")
+ root = []
+
+ def _walk(node: Any, path: str) -> None:
+ if not isinstance(node, Mapping):
+ errors.append(f"{path}: node must be a mapping")
+ return
+ ntype = str(node.get("type", ""))
+ if ntype not in COMPONENT_TYPES:
+ errors.append(f"{path}: unknown component type {ntype or '(missing)'!r}")
+ action = node.get("action")
+ if action is not None:
+ if not isinstance(action, Mapping) or str(action.get("kind")) not in ACTION_KINDS:
+ errors.append(f"{path}: invalid action (kind must be one of {sorted(ACTION_KINDS)})")
+ children = node.get("children")
+ if children is not None:
+ if not isinstance(children, list):
+ errors.append(f"{path}: children must be a list")
+ else:
+ for i, child in enumerate(children):
+ _walk(child, f"{path}.children[{i}]")
+
+ for i, node in enumerate(root):
+ _walk(node, f"root[{i}]")
+ return errors
+
+
+__all__ = [
+ "SCHEMA_VERSION",
+ "FALLBACK_TYPE",
+ "ACTION_KINDS",
+ "COMPONENT_CATALOG",
+ "COMPONENT_TYPES",
+ "ViewSpecError",
+ "make_markdown",
+ "normalize_node",
+ "normalize_viewspec",
+ "validate_viewspec",
+]
diff --git a/src/leapflow/engine/engine.py b/src/leapflow/engine/engine.py
index 7c22f9c..487c406 100644
--- a/src/leapflow/engine/engine.py
+++ b/src/leapflow/engine/engine.py
@@ -46,11 +46,10 @@
from leapflow.engine.stale_stream import StaleStreamError, stale_guarded_stream, build_continuation_prompt
from leapflow.engine.turn_recovery import TurnRecoveryState
from leapflow.engine.turn_usage import TurnUsageTracker
-from leapflow.engine.recovery_coordinator import RecoveryCoordinator, RecoveryState
+from leapflow.engine.recovery_coordinator import RecoveryCoordinator
from leapflow.engine.recovery_budget import RecoveryBudget
from leapflow.engine.unified_classifier import UnifiedErrorClassifier
-from leapflow.engine.failure_envelope import FailureEnvelope, FailureSource, Recoverability
-from leapflow.engine.recovery_decision import RecoveryAction
+from leapflow.engine.recovery_decision import RecoveryAction, RecoveryDecision
from leapflow.engine.recovery_strategies import default_strategies
from leapflow.engine.recovery_audit import JsonlAuditSink, create_audit_entry
from leapflow.engine.recovery_checkpoint import RecoveryCheckpoint, InMemoryCheckpointStore
@@ -2323,7 +2322,6 @@ async def _unified_tool_loop(
except Exception as exc:
_clear_indicator()
classified = self._error_classifier.classify(exc)
- rec = self._error_classifier.get_recovery(classified)
category_str = classified.value if hasattr(classified, 'value') else str(classified)
recovery.record_api_error(category_str)
@@ -2835,8 +2833,6 @@ async def _unified_tool_loop_stream(
self._record_provider_usage(resp.model or '', usage)
except Exception as exc:
_clear_indicator()
- classified = self._error_classifier.classify(exc)
- rec = self._error_classifier.get_recovery(classified)
turn_recovery.record_api_error()
# Classify through unified coordinator
@@ -3028,8 +3024,6 @@ async def _unified_tool_loop_stream(
break
except Exception as exc:
_clear_indicator()
- classified = self._error_classifier.classify(exc)
- rec = self._error_classifier.get_recovery(classified)
turn_recovery.record_api_error()
# Classify through unified coordinator
envelope = self._unified_classifier.classify_llm_error(
@@ -3089,8 +3083,6 @@ async def _unified_tool_loop_stream(
self._last_context_tokens = provider_prompt
except Exception as exc:
_clear_indicator()
- classified = self._error_classifier.classify(exc)
- rec = self._error_classifier.get_recovery(classified)
turn_recovery.record_api_error()
# Classify through unified coordinator
envelope = self._unified_classifier.classify_llm_error(
@@ -3846,7 +3838,8 @@ def _tool_execution_metadata(result: Any) -> Dict[str, Any]:
"execution_id", "idempotency_key", "execution_status", "execution_policy",
"already_executed", "duplicate_suppressed", "execution_reused", "execution_skipped",
"counts_as_failure", "counts_as_tool_attempt", "ui_hidden", "skipped_reason",
- "blocked_by_tool", "blocked_by_error", "tool_call_id",
+ "blocked_by_tool", "blocked_by_error", "tool_call_id", "path", "file_path",
+ "bytes_written",
):
if key in result:
metadata[key] = result[key]
diff --git a/src/leapflow/layout.py b/src/leapflow/layout.py
index d10a38c..1a29006 100644
--- a/src/leapflow/layout.py
+++ b/src/leapflow/layout.py
@@ -204,6 +204,25 @@ def ensure(self) -> None:
self.root.mkdir(parents=True, exist_ok=True)
+@dataclass(frozen=True)
+class DashboardLayout:
+ """LeapBoard durable content paths (custom templates).
+
+ Discovery/runtime state (port, token, pid) is transient and lives with the
+ daemon runtime files, not here; this layout owns only durable, user-authored
+ board content so it can be backed up/synced independently.
+ """
+
+ root: Path
+
+ @property
+ def templates_dir(self) -> Path:
+ return self.root / "templates"
+
+ def ensure(self) -> None:
+ self.templates_dir.mkdir(parents=True, exist_ok=True)
+
+
@dataclass(frozen=True)
class ProfileManifest:
"""First-class profile metadata."""
@@ -354,6 +373,16 @@ def gateway(self) -> GatewayLayout:
def approval(self) -> ApprovalLayout:
return ApprovalLayout(self.root / "approval")
+ @property
+ def dashboard(self) -> DashboardLayout:
+ return DashboardLayout(self.root / "dashboard")
+
+ @property
+ def dashboard_state_path(self) -> Path:
+ # Transient discovery state (port/token/pid/url), regenerated per launch
+ # and cleared on stop; grouped with the daemon runtime files.
+ return self.runtime_dir / "dashboard.json"
+
@property
def audit_dir(self) -> Path:
return self.root / "audit"
@@ -405,6 +434,7 @@ def ensure(self) -> None:
self.secrets.ensure()
self.gateway.ensure()
self.approval.ensure()
+ self.dashboard.ensure()
self.cache.ensure()
_write_yaml_if_missing(self.manifest_path, ProfileManifest.default(self.profile_id).to_dict())
for path in self.config_paths():
diff --git a/src/leapflow/monitor/__init__.py b/src/leapflow/monitor/__init__.py
new file mode 100644
index 0000000..211cf35
--- /dev/null
+++ b/src/leapflow/monitor/__init__.py
@@ -0,0 +1,57 @@
+"""Domain-neutral monitoring subsystem: Watch -> Finding contract and runtime.
+
+Public surface:
+- Contract types (``WatchSpec``, ``Finding``, ``Severity``, ``MonitorProducer``)
+- ``FindingStore`` for persistence
+- ``ProducerRegistry`` for per-domain observation logic
+- ``MonitorManager`` orchestrating watch lifecycle, persistence, and push
+"""
+
+from leapflow.monitor.finding_store import FindingStore
+from leapflow.monitor.manager import EmitFn, MonitorManager
+from leapflow.monitor.producers import ProducerRegistry
+from leapflow.monitor.session_producer import (
+ SessionAnalysisProducer,
+ SessionAnalysisServices,
+ ensure_session_watch,
+ session_watch_params,
+)
+from leapflow.monitor.types import (
+ EVENT_ERROR,
+ EVENT_FINDING,
+ EVENT_HEARTBEAT,
+ EVENT_WATCH_STATE,
+ WATCH_KIND,
+ Evidence,
+ Finding,
+ MonitorProducer,
+ ProducerContext,
+ Severity,
+ SuggestedAction,
+ WatchSpec,
+ WatchView,
+)
+
+__all__ = [
+ "EVENT_FINDING",
+ "EVENT_WATCH_STATE",
+ "EVENT_ERROR",
+ "EVENT_HEARTBEAT",
+ "WATCH_KIND",
+ "Severity",
+ "Evidence",
+ "SuggestedAction",
+ "Finding",
+ "WatchSpec",
+ "WatchView",
+ "ProducerContext",
+ "MonitorProducer",
+ "FindingStore",
+ "ProducerRegistry",
+ "MonitorManager",
+ "EmitFn",
+ "SessionAnalysisProducer",
+ "SessionAnalysisServices",
+ "ensure_session_watch",
+ "session_watch_params",
+]
diff --git a/src/leapflow/monitor/finding_store.py b/src/leapflow/monitor/finding_store.py
new file mode 100644
index 0000000..83f4742
--- /dev/null
+++ b/src/leapflow/monitor/finding_store.py
@@ -0,0 +1,200 @@
+"""DuckDB-backed persistence for monitor findings.
+
+Shares the daemon's single ``leap.duckdb`` connection via ``ConnectionHolder``
+(same pattern as ``TaskStore``). Findings are append-mostly with a dedup guard
+keyed by ``(watch_id, dedup_key)`` so producers can re-observe without spamming.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from pathlib import Path
+from typing import List, Optional, Union
+
+from leapflow.monitor.types import Finding, Severity
+from leapflow.storage.connection import ConnectionHolder, LocalConnectionHolder
+from leapflow.storage.write_buffer import execute_with_retry
+
+logger = logging.getLogger(__name__)
+
+
+class FindingStore:
+ """Atomic CRUD and filtered queries for findings.
+
+ Accepts a shared ``ConnectionHolder`` (daemon-owned) or a legacy ``Path``
+ for standalone use in tests.
+ """
+
+ def __init__(self, source: Union[ConnectionHolder, Path, str]) -> None:
+ self._owns_holder = isinstance(source, (str, Path))
+ if self._owns_holder:
+ source = LocalConnectionHolder(Path(source))
+ self._holder = source
+ self._con = self._holder.connection
+ self._ensure_table()
+
+ def close(self) -> None:
+ """Close the DuckDB connection if owned by this store."""
+ if self._owns_holder:
+ self._holder.close()
+
+ # ── Schema ───────────────────────────────────────────────────────────
+
+ def _ensure_table(self) -> None:
+ self._con.execute("""
+ CREATE TABLE IF NOT EXISTS monitor_findings (
+ finding_id TEXT PRIMARY KEY,
+ watch_id TEXT NOT NULL,
+ domain TEXT NOT NULL,
+ ts DOUBLE NOT NULL,
+ severity TEXT NOT NULL DEFAULT 'info',
+ score DOUBLE DEFAULT 0.0,
+ title TEXT NOT NULL,
+ summary TEXT DEFAULT '',
+ evidence TEXT DEFAULT '[]',
+ tags TEXT DEFAULT '[]',
+ suggested_actions TEXT DEFAULT '[]',
+ payload TEXT DEFAULT '{}',
+ dedup_key TEXT DEFAULT ''
+ )
+ """)
+
+ # ── Write ────────────────────────────────────────────────────────────
+
+ def save(self, finding: Finding) -> None:
+ """Insert or replace a finding by ``finding_id``."""
+ execute_with_retry(
+ self._con,
+ """
+ INSERT OR REPLACE INTO monitor_findings (
+ finding_id, watch_id, domain, ts, severity, score,
+ title, summary, evidence, tags, suggested_actions, payload, dedup_key
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ [
+ finding.finding_id,
+ finding.watch_id,
+ finding.domain,
+ finding.ts,
+ finding.severity.value,
+ finding.score,
+ finding.title,
+ finding.summary,
+ json.dumps([item.to_dict() for item in finding.evidence], ensure_ascii=False),
+ json.dumps(list(finding.tags), ensure_ascii=False),
+ json.dumps([a.to_dict() for a in finding.suggested_actions], ensure_ascii=False),
+ json.dumps(dict(finding.payload), ensure_ascii=False),
+ finding.dedup_key,
+ ],
+ )
+
+ def exists_dedup(self, watch_id: str, dedup_key: str) -> bool:
+ """Return True if a finding with this dedup key already exists."""
+ if not dedup_key:
+ return False
+ row = self._con.execute(
+ "SELECT 1 FROM monitor_findings WHERE watch_id = ? AND dedup_key = ? LIMIT 1",
+ [watch_id, dedup_key],
+ ).fetchone()
+ return row is not None
+
+ def delete_for_watch(self, watch_id: str) -> None:
+ """Remove all findings belonging to a watch."""
+ execute_with_retry(
+ self._con,
+ "DELETE FROM monitor_findings WHERE watch_id = ?",
+ [watch_id],
+ )
+
+ # ── Read ─────────────────────────────────────────────────────────────
+
+ def list(
+ self,
+ *,
+ watch_id: Optional[str] = None,
+ min_severity: Optional[Severity] = None,
+ since: Optional[float] = None,
+ limit: int = 50,
+ offset: int = 0,
+ ) -> List[Finding]:
+ """Return findings newest-first with optional filters."""
+ clauses: list[str] = []
+ params: list[object] = []
+ if watch_id:
+ clauses.append("watch_id = ?")
+ params.append(watch_id)
+ if since is not None:
+ clauses.append("ts >= ?")
+ params.append(since)
+ if min_severity is not None:
+ allowed = [s.value for s in Severity if s.rank >= min_severity.rank]
+ placeholders = ", ".join("?" for _ in allowed)
+ clauses.append(f"severity IN ({placeholders})")
+ params.extend(allowed)
+ where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
+ params.append(max(1, int(limit)))
+ params.append(max(0, int(offset)))
+ rows = self._con.execute(
+ f"""
+ SELECT finding_id, watch_id, domain, ts, severity, score,
+ title, summary, evidence, tags, suggested_actions, payload, dedup_key
+ FROM monitor_findings
+ {where}
+ ORDER BY ts DESC
+ LIMIT ? OFFSET ?
+ """,
+ params,
+ ).fetchall()
+ return [self._row_to_finding(row) for row in rows]
+
+ def count(self, *, watch_id: Optional[str] = None, min_severity: Optional[Severity] = None) -> int:
+ """Return the number of findings matching the filters."""
+ clauses: list[str] = []
+ params: list[object] = []
+ if watch_id:
+ clauses.append("watch_id = ?")
+ params.append(watch_id)
+ if min_severity is not None:
+ allowed = [s.value for s in Severity if s.rank >= min_severity.rank]
+ placeholders = ", ".join("?" for _ in allowed)
+ clauses.append(f"severity IN ({placeholders})")
+ params.extend(allowed)
+ where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
+ row = self._con.execute(
+ f"SELECT COUNT(*) FROM monitor_findings {where}", params
+ ).fetchone()
+ return int(row[0]) if row else 0
+
+ # ── Internal ─────────────────────────────────────────────────────────
+
+ def _row_to_finding(self, row: tuple) -> Finding:
+ return Finding.from_dict({
+ "finding_id": row[0],
+ "watch_id": row[1],
+ "domain": row[2],
+ "ts": row[3],
+ "severity": row[4],
+ "score": row[5],
+ "title": row[6],
+ "summary": row[7],
+ "evidence": self._safe_json(row[8], []),
+ "tags": self._safe_json(row[9], []),
+ "suggested_actions": self._safe_json(row[10], []),
+ "payload": self._safe_json(row[11], {}),
+ "dedup_key": row[12],
+ })
+
+ @staticmethod
+ def _safe_json(value: object, default: object) -> object:
+ if value is None:
+ return default
+ if isinstance(value, (list, dict)):
+ return value
+ try:
+ return json.loads(value)
+ except (json.JSONDecodeError, TypeError):
+ return default
+
+
+__all__ = ["FindingStore"]
diff --git a/src/leapflow/monitor/manager.py b/src/leapflow/monitor/manager.py
new file mode 100644
index 0000000..4d9fb08
--- /dev/null
+++ b/src/leapflow/monitor/manager.py
@@ -0,0 +1,404 @@
+"""Watch lifecycle orchestration for the monitoring subsystem.
+
+``MonitorManager`` wires the domain-neutral contract to the existing scheduler:
+watches are ``ArmedTask`` rows (``kind=watch``); each due tick runs the matching
+producer, persists findings, and pushes qualifying ones to view clients through
+an injected ``emit`` callback (the daemon NotificationBus). It owns no domain
+logic and no transport -- both are injected.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import time
+from dataclasses import replace
+from typing import Any, Callable, List, Optional
+
+from leapflow.monitor.finding_store import FindingStore
+from leapflow.monitor.producers import ProducerRegistry
+from leapflow.monitor.types import (
+ EVENT_ERROR,
+ EVENT_FINDING,
+ EVENT_WATCH_STATE,
+ METADATA_CLIENT_COUPLED_KEY,
+ METADATA_KIND_KEY,
+ METADATA_MUTED_KEY,
+ WATCH_KIND,
+ Finding,
+ ProducerContext,
+ WatchSpec,
+ WatchView,
+)
+from leapflow.scheduler.coordinator import TaskCoordinator
+from leapflow.scheduler.local_scheduler import LocalScheduler
+from leapflow.scheduler.store import TaskStore
+from leapflow.scheduler.types import ArmedTask, TaskState
+
+logger = logging.getLogger(__name__)
+
+# Emit signature: (event_type, payload_dict) -> None. Injected by the host.
+EmitFn = Callable[[str, dict], None]
+
+_ACTIVE_STATES = frozenset({
+ TaskState.ARMED.value,
+ TaskState.WATCHING.value,
+ TaskState.DUE.value,
+ TaskState.CONFIRMING.value,
+ TaskState.EXECUTING.value,
+})
+
+
+def _format_trigger(task: ArmedTask) -> str:
+ """Return a compact human-readable trigger label for a task."""
+ cfg = task.trigger_config if isinstance(task.trigger_config, dict) else {}
+ if task.trigger_type == "interval":
+ sec = float(cfg.get("interval_seconds", 0) or 0)
+ if sec < 60:
+ return f"every {int(sec)}s"
+ if sec < 3600:
+ return f"every {int(sec / 60)}m"
+ if sec < 86400:
+ return f"every {int(sec / 3600)}h"
+ return f"every {int(sec / 86400)}d"
+ if task.trigger_type == "cron":
+ return str(cfg.get("expression", "cron"))
+ if task.trigger_type == "event":
+ return f"event:{cfg.get('event_pattern', '?')}"
+ if task.trigger_type == "condition":
+ return f"cond:{str(cfg.get('expression', '?'))[:24]}"
+ return task.trigger_type
+
+
+def _is_watch(task: ArmedTask) -> bool:
+ """Return True when an ArmedTask row represents a monitor watch."""
+ meta = task.metadata if isinstance(task.metadata, dict) else {}
+ return meta.get(METADATA_KIND_KEY) == WATCH_KIND
+
+
+class _MonitorExecutor:
+ """SkillExecutor adapter: run a producer, persist + push its findings."""
+
+ def __init__(
+ self,
+ *,
+ task_store: TaskStore,
+ finding_store: FindingStore,
+ producers: ProducerRegistry,
+ emit: Optional[EmitFn],
+ services: Any,
+ ) -> None:
+ self._tasks = task_store
+ self._findings = finding_store
+ self._producers = producers
+ self._emit = emit
+ self._services = services
+
+ async def execute(self, skill_name: str, parameters: dict) -> dict:
+ """Run one observation cycle for a watch (skill_name == domain)."""
+ force = bool(parameters.get("_force", False))
+ spec = WatchSpec.from_params(parameters)
+ task = self._tasks.load(spec.watch_id) if spec.watch_id else None
+ muted = bool((task.metadata or {}).get(METADATA_MUTED_KEY)) if task else False
+ run_count = task.run_count if task else 0
+ last_run_at = task.last_run_at if task else 0.0
+
+ producer = self._producers.resolve(spec.domain)
+ if producer is None:
+ logger.debug(
+ "monitor: no producer for domain=%s (watch=%s)", spec.domain, spec.watch_id
+ )
+ return {"ok": False, "error": f"no producer registered for domain {spec.domain!r}"}
+
+ ctx = ProducerContext(
+ spec=replace(spec, muted=muted),
+ now=time.time(),
+ run_count=run_count,
+ last_run_at=last_run_at,
+ services=self._services,
+ force=force,
+ )
+ try:
+ findings = list(await producer.observe(ctx))
+ except Exception as exc: # noqa: BLE001 - producer errors are surfaced, not fatal
+ logger.warning("monitor: producer %s failed: %s", spec.domain, exc)
+ self._push(EVENT_ERROR, {"watch_id": spec.watch_id, "domain": spec.domain,
+ "error": str(exc)})
+ return {"ok": False, "error": str(exc)}
+
+ threshold = spec.push_threshold()
+ persisted = 0
+ emitted = 0
+ for finding in findings:
+ if not finding.watch_id:
+ finding = replace(finding, watch_id=spec.watch_id)
+ if finding.dedup_key and self._findings.exists_dedup(finding.watch_id, finding.dedup_key):
+ continue
+ self._findings.save(finding)
+ persisted += 1
+ if not muted and finding.severity.rank >= threshold.rank:
+ self._push(EVENT_FINDING, finding.to_dict())
+ emitted += 1
+ return {"ok": True, "findings": persisted, "emitted": emitted}
+
+ def _push(self, event_type: str, payload: dict) -> None:
+ if self._emit is None:
+ return
+ try:
+ self._emit(event_type, payload)
+ except Exception: # noqa: BLE001 - a failing sink must not break the tick
+ logger.debug("monitor: emit failed for %s", event_type, exc_info=True)
+
+
+class MonitorManager:
+ """Owns watch lifecycle: arm, list, control, and finding retrieval.
+
+ Local execution only in this phase; cloud dispatch remains available through
+ the scheduler for a later phase. Transport (``emit``) and domain producers
+ are injected, keeping this class domain- and platform-neutral.
+ """
+
+ def __init__(
+ self,
+ *,
+ holder: Any,
+ producers: Optional[ProducerRegistry] = None,
+ emit: Optional[EmitFn] = None,
+ services: Any = None,
+ tick_seconds: int = 60,
+ grace_seconds: float = 120.0,
+ ) -> None:
+ self._task_store = TaskStore(holder)
+ self._finding_store = FindingStore(holder)
+ self.producers = producers or ProducerRegistry()
+ self._emit = emit
+ self._executor = _MonitorExecutor(
+ task_store=self._task_store,
+ finding_store=self._finding_store,
+ producers=self.producers,
+ emit=emit,
+ services=services,
+ )
+ self._scheduler = LocalScheduler(
+ store=self._task_store,
+ executor=self._executor,
+ tick_seconds=tick_seconds,
+ grace_seconds=grace_seconds,
+ )
+ self._coordinator = TaskCoordinator(
+ store=self._task_store,
+ local_scheduler=self._scheduler,
+ cloud_dispatcher=None,
+ default_tier="local",
+ )
+ self._started = False
+ self._background_tasks: set[asyncio.Task[Any]] = set()
+
+ # ── Lifecycle ────────────────────────────────────────────────────────
+
+ async def start(self) -> None:
+ """Start the background tick loop (idempotent)."""
+ if self._started:
+ return
+ await self._scheduler.start()
+ self._started = True
+
+ async def stop(self) -> None:
+ """Stop the background tick loop (idempotent)."""
+ if not self._started:
+ return
+ await self._scheduler.stop()
+ self._started = False
+
+ @property
+ def finding_store(self) -> FindingStore:
+ """Expose the finding store for read-only queries by hosts."""
+ return self._finding_store
+
+ # ── Watch management ───────────────────────────────────────────────────
+
+ async def arm_watch(self, spec: WatchSpec) -> WatchView:
+ """Create and register a watch, returning its runtime view."""
+ task = await self._coordinator.arm(
+ skill_name=spec.domain,
+ trigger_expr=spec.trigger_expr,
+ execution_tier="local",
+ max_runs=spec.max_runs,
+ parameters=spec.to_task_parameters(),
+ )
+ # Backfill watch_id into parameters and stamp watch metadata so ticks
+ # and listings can identify the row without re-deriving it.
+ params = dict(task.parameters) if isinstance(task.parameters, dict) else {}
+ params["watch_id"] = task.task_id
+ task.parameters = params
+ task.metadata = {
+ METADATA_KIND_KEY: WATCH_KIND,
+ METADATA_MUTED_KEY: bool(spec.muted),
+ METADATA_CLIENT_COUPLED_KEY: bool(spec.client_coupled),
+ }
+ self._task_store.save(task)
+ self._emit_state(task)
+ return self._to_view(task)
+
+ def list_watches(self) -> List[WatchView]:
+ """Return runtime views for all watches (newest first)."""
+ watches = [t for t in self._task_store.load_all() if _is_watch(t)]
+ watches.sort(key=lambda t: t.created_at, reverse=True)
+ return [self._to_view(t) for t in watches]
+
+ def get_watch(self, watch_id: str) -> Optional[WatchView]:
+ """Return a single watch view, or None when absent/not a watch."""
+ task = self._task_store.load(watch_id)
+ if task is None or not _is_watch(task):
+ return None
+ return self._to_view(task)
+
+ def has_active_watches(self) -> bool:
+ """Return True when a standalone watch is armed/watching (keep-alive signal).
+
+ Client-coupled watches (e.g. session analysis) are excluded: they only
+ matter while an interactive client is present and must not, by themselves,
+ keep the daemon alive across idle periods.
+ """
+ for task in self._task_store.load_all():
+ if not _is_watch(task) or task.state not in _ACTIVE_STATES:
+ continue
+ meta = task.metadata if isinstance(task.metadata, dict) else {}
+ if meta.get(METADATA_CLIENT_COUPLED_KEY):
+ continue
+ return True
+ return False
+
+ def sweep_client_coupled_watches(self) -> int:
+ """Delete leftover client-coupled watches (e.g. session analysis).
+
+ Client-coupled watches only make sense while an interactive client owns
+ them. On a fresh daemon lifetime none can, so any persisted ones are
+ stale and must not surface as active monitors (status bar, keep-alive,
+ ``/board list``). Standalone watches are intentionally durable and are
+ left untouched. Returns the number of watches removed.
+ """
+ removed = 0
+ for task in self._task_store.load_all():
+ if not _is_watch(task):
+ continue
+ meta = task.metadata if isinstance(task.metadata, dict) else {}
+ if not meta.get(METADATA_CLIENT_COUPLED_KEY):
+ continue
+ self._task_store.delete(task.task_id)
+ self._finding_store.delete_for_watch(task.task_id)
+ removed += 1
+ return removed
+
+ def pause_watch(self, watch_id: str) -> Optional[WatchView]:
+ """Suspend a watch so it stops firing until resumed."""
+ return self._transition(watch_id, TaskState.SUSPENDED.value)
+
+ def resume_watch(self, watch_id: str) -> Optional[WatchView]:
+ """Re-arm a suspended watch."""
+ return self._transition(watch_id, TaskState.ARMED.value)
+
+ def stop_watch(self, watch_id: str) -> Optional[WatchView]:
+ """Terminally stop a watch (kept for history)."""
+ return self._transition(watch_id, TaskState.DONE.value)
+
+ def set_muted(self, watch_id: str, muted: bool) -> Optional[WatchView]:
+ """Toggle whether a watch's findings are pushed to view clients."""
+ task = self._task_store.load(watch_id)
+ if task is None or not _is_watch(task):
+ return None
+ meta = dict(task.metadata) if isinstance(task.metadata, dict) else {}
+ meta[METADATA_MUTED_KEY] = bool(muted)
+ meta.setdefault(METADATA_KIND_KEY, WATCH_KIND)
+ task.metadata = meta
+ self._task_store.save(task)
+ self._emit_state(task)
+ return self._to_view(task)
+
+ # ── Findings ───────────────────────────────────────────────────────────
+
+ def list_findings(
+ self,
+ *,
+ watch_id: Optional[str] = None,
+ limit: int = 50,
+ offset: int = 0,
+ since: Optional[float] = None,
+ ) -> List[Finding]:
+ """Return persisted findings newest-first with optional filters."""
+ return self._finding_store.list(
+ watch_id=watch_id, limit=limit, offset=offset, since=since
+ )
+
+ async def run_watch_once(self, watch_id: str, *, force: bool = False) -> dict:
+ """Run one observation cycle immediately (manual refresh/trigger).
+
+ Bypasses the tick timer while reusing the same producer -> persist ->
+ push path, so a user-triggered refresh is identical to a scheduled one.
+ ``force=True`` signals producers to re-analyze even without new input.
+ """
+ task = self._task_store.load(watch_id)
+ if task is None or not _is_watch(task):
+ return {"ok": False, "error": f"watch not found: {watch_id}"}
+ params = dict(task.parameters) if isinstance(task.parameters, dict) else {}
+ params.setdefault("watch_id", task.task_id)
+ if force:
+ params["_force"] = True
+ return await self._executor.execute(task.skill_name, params)
+
+ def schedule_watch_once(self, watch_id: str, *, force: bool = False) -> None:
+ """Fire one observation cycle in the background (non-blocking).
+
+ Producers may be slow (e.g. an LLM-backed session analysis). Callers
+ that only need to *trigger* a refresh -- such as opening a live board --
+ must not block on that latency, so the cycle runs as a tracked task and
+ its finding reaches clients over the normal push path when it completes.
+ """
+ task = asyncio.create_task(self._safe_run_once(watch_id, force=force))
+ self._background_tasks.add(task)
+ task.add_done_callback(self._background_tasks.discard)
+
+ async def _safe_run_once(self, watch_id: str, *, force: bool) -> None:
+ try:
+ await self.run_watch_once(watch_id, force=force)
+ except Exception: # noqa: BLE001 - background refresh must never crash the loop
+ logger.debug("monitor: background run_watch_once failed for %s", watch_id, exc_info=True)
+
+ # ── Internal ───────────────────────────────────────────────────────────
+
+ def _transition(self, watch_id: str, state: str) -> Optional[WatchView]:
+ task = self._task_store.load(watch_id)
+ if task is None or not _is_watch(task):
+ return None
+ self._task_store.update_state(watch_id, state)
+ task.state = state
+ self._emit_state(task)
+ return self._to_view(task)
+
+ def _to_view(self, task: ArmedTask) -> WatchView:
+ meta = task.metadata if isinstance(task.metadata, dict) else {}
+ params = task.parameters if isinstance(task.parameters, dict) else {}
+ return WatchView(
+ watch_id=task.task_id,
+ name=str(params.get("name") or task.task_id[:8]),
+ domain=str(params.get("domain") or task.skill_name),
+ trigger=_format_trigger(task),
+ state=task.state,
+ muted=bool(meta.get(METADATA_MUTED_KEY, False)),
+ run_count=task.run_count,
+ next_due_at=task.next_due_at,
+ last_run_at=task.last_run_at,
+ finding_count=self._finding_store.count(watch_id=task.task_id),
+ client_coupled=bool(meta.get(METADATA_CLIENT_COUPLED_KEY, False)),
+ )
+
+ def _emit_state(self, task: ArmedTask) -> None:
+ if self._emit is None:
+ return
+ try:
+ self._emit(EVENT_WATCH_STATE, self._to_view(task).to_dict())
+ except Exception: # noqa: BLE001 - state notification is best-effort
+ logger.debug("monitor: watch.state emit failed", exc_info=True)
+
+
+__all__ = ["MonitorManager", "EmitFn"]
diff --git a/src/leapflow/monitor/producers.py b/src/leapflow/monitor/producers.py
new file mode 100644
index 0000000..f41791b
--- /dev/null
+++ b/src/leapflow/monitor/producers.py
@@ -0,0 +1,45 @@
+"""Producer registry: resolve per-domain observation logic by ``domain`` key.
+
+The registry keeps the runtime domain-agnostic. A new scenario registers a
+``MonitorProducer`` for its ``domain``; core scheduling, persistence, and push
+code never branch on the domain. Unknown domains resolve to ``None`` so the
+manager can skip them gracefully instead of failing.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Dict, Optional
+
+from leapflow.monitor.types import MonitorProducer
+
+logger = logging.getLogger(__name__)
+
+
+class ProducerRegistry:
+ """Mutable registry mapping ``domain`` -> ``MonitorProducer``."""
+
+ def __init__(self) -> None:
+ self._producers: Dict[str, MonitorProducer] = {}
+
+ def register(self, producer: MonitorProducer) -> None:
+ """Register (or replace) a producer for its declared domain."""
+ domain = producer.domain
+ if not domain:
+ raise ValueError("MonitorProducer.domain must be a non-empty string")
+ self._producers[domain] = producer
+ logger.debug("monitor: registered producer for domain=%s", domain)
+
+ def resolve(self, domain: str) -> Optional[MonitorProducer]:
+ """Return the producer for a domain, or None when unregistered."""
+ return self._producers.get(domain)
+
+ def domains(self) -> list[str]:
+ """Return all registered domain keys."""
+ return sorted(self._producers.keys())
+
+ def __contains__(self, domain: object) -> bool:
+ return domain in self._producers
+
+
+__all__ = ["ProducerRegistry"]
diff --git a/src/leapflow/monitor/series_extractor.py b/src/leapflow/monitor/series_extractor.py
new file mode 100644
index 0000000..5dde315
--- /dev/null
+++ b/src/leapflow/monitor/series_extractor.py
@@ -0,0 +1,289 @@
+"""Extract structured chart data from a session's captured tool outputs.
+
+Signal-driven and anti-hallucination: this reads ONLY what the session already
+produced — tool-result message contents and ``file_write`` artifact excerpts —
+and never executes code or touches the network. Numeric values come from the
+captured data, never from the model. When nothing reliable is found it returns
+an empty mapping so the dashboard omits charts rather than drawing a fake line.
+
+Output contract (all keys optional; absent when no data):
+ {
+ "series": [{"id","label","kind":"line|area","points":[{"x","y"}]}],
+ "ohlc": [{"id","label","bars":[{"t","o","h","l","c","v"?}]}],
+ "distribution": [{"id","label","items":[{"label","value"}]}],
+ }
+"""
+
+from __future__ import annotations
+
+import csv as _csv
+import io
+import json
+import re
+from typing import Any, Mapping, Optional, Sequence
+
+_MAX_SERIES = 6
+_MAX_POINTS = 500
+_MAX_BLOCK_CHARS = 20000
+
+_OHLC_ALIASES = {
+ "o": "o", "open": "o",
+ "h": "h", "high": "h",
+ "l": "l", "low": "l",
+ "c": "c", "close": "c", "adj close": "c", "adj_close": "c",
+ "v": "v", "vol": "v", "volume": "v",
+}
+_TIME_KEYS = ("t", "time", "date", "datetime", "timestamp", "ts", "x", "period", "label")
+_VALUE_KEYS = ("value", "y", "close", "price", "count", "amount", "score", "total")
+
+
+def extract_charts(
+ *,
+ messages: Optional[Sequence[Mapping[str, Any]]] = None,
+ artifacts: Optional[Sequence[Mapping[str, Any]]] = None,
+ intents: Optional[Sequence[Mapping[str, Any]]] = None,
+ max_series: int = _MAX_SERIES,
+ max_points: int = _MAX_POINTS,
+) -> dict[str, Any]:
+ """Return a ``charts`` mapping extracted from captured session outputs."""
+ series: list[dict[str, Any]] = []
+ ohlc: list[dict[str, Any]] = []
+ distribution: list[dict[str, Any]] = []
+
+ for idx, (label, text) in enumerate(_candidate_blocks(messages or [], artifacts or [])):
+ parsed = _parse_block(text)
+ if parsed is None:
+ continue
+ rows, values = parsed
+ bars = _as_ohlc(rows, max_points)
+ if bars:
+ ohlc.append({"id": f"ohlc-{idx}", "label": label, "bars": bars})
+ continue
+ items = _as_distribution(rows)
+ if items:
+ distribution.append({"id": f"dist-{idx}", "label": label, "items": items})
+ continue
+ points = _as_series(rows, values, max_points)
+ if points:
+ series.append({"id": f"series-{idx}", "label": label, "kind": "line", "points": points})
+
+ _apply_intent_labels(series + ohlc + distribution, intents or [])
+ out: dict[str, Any] = {}
+ if series:
+ out["series"] = series[:max_series]
+ if ohlc:
+ out["ohlc"] = ohlc[:max_series]
+ if distribution:
+ out["distribution"] = distribution[:max_series]
+ return out
+
+
+def _candidate_blocks(
+ messages: Sequence[Mapping[str, Any]],
+ artifacts: Sequence[Mapping[str, Any]],
+) -> list[tuple[str, str]]:
+ """Collect (label, text) blocks worth parsing, newest tool output first."""
+ blocks: list[tuple[str, str]] = []
+ for artifact in artifacts:
+ if str(artifact.get("status", "")) != "included":
+ continue
+ excerpt = str(artifact.get("content_excerpt", ""))[:_MAX_BLOCK_CHARS]
+ if excerpt.strip():
+ name = str(artifact.get("name") or artifact.get("path") or "artifact")
+ blocks.append((name, excerpt))
+ for message in messages:
+ if str(message.get("role", "")) not in ("tool", "assistant"):
+ continue
+ content = message.get("content")
+ text = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False)
+ text = str(text)[:_MAX_BLOCK_CHARS]
+ if _looks_structured(text):
+ blocks.append((str(message.get("name") or "tool result"), text))
+ return blocks
+
+
+def _looks_structured(text: str) -> bool:
+ stripped = text.lstrip()
+ return stripped[:1] in ("{", "[") or "|" in text or "," in text
+
+
+def _parse_block(text: str) -> Optional[tuple[list[dict[str, Any]], list[float]]]:
+ """Return (rows, flat_values) parsed from one block, or None."""
+ for parser in (_parse_json, _parse_markdown_table, _parse_csv):
+ result = parser(text)
+ if result is not None:
+ return result
+ return None
+
+
+def _parse_json(text: str) -> Optional[tuple[list[dict[str, Any]], list[float]]]:
+ obj = _load_json_fragment(text)
+ if obj is None:
+ return None
+ if isinstance(obj, Mapping):
+ # A dict of label -> number reads as a distribution/series of pairs.
+ rows = [{"label": str(k), "value": v} for k, v in obj.items() if _num(v) is not None]
+ return (rows, []) if rows else None
+ if isinstance(obj, list):
+ if all(isinstance(item, Mapping) for item in obj):
+ return ([dict(item) for item in obj], [])
+ values = [n for item in obj if (n := _num(item)) is not None]
+ return ([], values) if values else None
+ return None
+
+
+def _load_json_fragment(text: str) -> Any:
+ stripped = text.strip()
+ for opener, closer in (("[", "]"), ("{", "}")):
+ start, end = stripped.find(opener), stripped.rfind(closer)
+ if start != -1 and end > start:
+ try:
+ return json.loads(stripped[start:end + 1])
+ except (ValueError, TypeError):
+ continue
+ return None
+
+
+def _parse_markdown_table(text: str) -> Optional[tuple[list[dict[str, Any]], list[float]]]:
+ lines = [ln.strip() for ln in text.splitlines() if ln.strip().startswith("|")]
+ if len(lines) < 2 or not re.match(r"^\|[\s:\-|]+\|?$", lines[1]):
+ return None
+ header = [c.strip() for c in lines[0].strip("|").split("|")]
+ rows: list[dict[str, Any]] = []
+ for line in lines[2:]:
+ cells = [c.strip() for c in line.strip("|").split("|")]
+ if len(cells) == len(header):
+ rows.append({header[i]: cells[i] for i in range(len(header))})
+ return (rows, []) if rows else None
+
+
+def _parse_csv(text: str) -> Optional[tuple[list[dict[str, Any]], list[float]]]:
+ sample = "\n".join(text.splitlines()[:_MAX_POINTS + 1]).strip()
+ if "," not in sample or "\n" not in sample:
+ return None
+ try:
+ reader = list(_csv.reader(io.StringIO(sample)))
+ except _csv.Error:
+ return None
+ if len(reader) < 2:
+ return None
+ header = [h.strip() for h in reader[0]]
+ if not any(_looks_headerish(h) for h in header):
+ return None
+ rows = [
+ {header[i]: row[i] for i in range(min(len(header), len(row)))}
+ for row in reader[1:] if row
+ ]
+ return (rows, []) if rows else None
+
+
+def _looks_headerish(cell: str) -> bool:
+ return bool(cell) and _num(cell) is None
+
+
+def _as_ohlc(rows: list[dict[str, Any]], max_points: int) -> list[dict[str, Any]]:
+ if not rows:
+ return []
+ keymap = _column_map(rows[0], _OHLC_ALIASES)
+ if not {"o", "h", "l", "c"}.issubset(keymap.values()):
+ return []
+ bars: list[dict[str, Any]] = []
+ for row in rows[:max_points]:
+ bar: dict[str, Any] = {}
+ for src, role in keymap.items():
+ num = _num(row.get(src))
+ if num is not None:
+ bar[role] = num
+ if {"o", "h", "l", "c"}.issubset(bar):
+ bar["t"] = _time_of(row)
+ bars.append(bar)
+ return bars if len(bars) >= 2 else []
+
+
+def _as_series(rows: list[dict[str, Any]], values: list[float], max_points: int) -> list[dict[str, Any]]:
+ if values:
+ return [{"x": i, "y": v} for i, v in enumerate(values[:max_points])]
+ if not rows:
+ return []
+ value_key = _pick_value_key(rows[0])
+ if value_key is None:
+ return []
+ points: list[dict[str, Any]] = []
+ for i, row in enumerate(rows[:max_points]):
+ y = _num(row.get(value_key))
+ if y is None:
+ continue
+ x = _time_of(row)
+ points.append({"x": x if x is not None else i, "y": y})
+ return points if len(points) >= 2 else []
+
+
+def _as_distribution(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ if not rows or len(rows) > 40:
+ return []
+ # A categorical label column (not a time axis) + a value column reads as a
+ # distribution; time/numeric x-axes fall through to a line series instead.
+ label_key = next(
+ (k for k in rows[0]
+ if str(k).strip().lower() not in _TIME_KEYS and _num(rows[0][k]) is None),
+ None,
+ )
+ value_key = _pick_value_key(rows[0])
+ if label_key is None or value_key is None:
+ return []
+ items = [
+ {"label": str(row.get(label_key, "")), "value": v}
+ for row in rows if (v := _num(row.get(value_key))) is not None
+ ]
+ return items if items else []
+
+
+def _column_map(row: Mapping[str, Any], aliases: Mapping[str, str]) -> dict[str, str]:
+ out: dict[str, str] = {}
+ for key in row:
+ role = aliases.get(str(key).strip().lower())
+ if role and role not in out.values():
+ out[key] = role
+ return out
+
+
+def _pick_value_key(row: Mapping[str, Any]) -> Optional[str]:
+ for hint in _VALUE_KEYS:
+ for key in row:
+ if str(key).strip().lower() == hint and _num(row[key]) is not None:
+ return key
+ return next((k for k in row if _num(row[k]) is not None), None)
+
+
+def _time_of(row: Mapping[str, Any]) -> Any:
+ for key in row:
+ if str(key).strip().lower() in _TIME_KEYS:
+ return str(row[key])
+ return None
+
+
+def _apply_intent_labels(charts: list[dict[str, Any]], intents: Sequence[Mapping[str, Any]]) -> None:
+ labels = [str(it.get("label")) for it in intents if isinstance(it, Mapping) and it.get("label")]
+ for chart, label in zip(charts, labels):
+ if label:
+ chart["label"] = label
+
+
+def _num(value: Any) -> Optional[float]:
+ if isinstance(value, bool):
+ return None
+ if isinstance(value, (int, float)):
+ return float(value)
+ if isinstance(value, str):
+ cleaned = value.strip().replace(",", "").replace("%", "").replace("$", "")
+ # Accept ints, decimals, leading-dot (.5) and trailing-dot (1.) floats,
+ # with an optional exponent; reject inf/nan/garbage before float().
+ if re.fullmatch(r"[-+]?(\d+(\.\d*)?|\.\d+)([eE][-+]?\d+)?", cleaned or ""):
+ try:
+ return float(cleaned)
+ except ValueError:
+ return None
+ return None
+
+
+__all__ = ["extract_charts"]
diff --git a/src/leapflow/monitor/session_producer.py b/src/leapflow/monitor/session_producer.py
new file mode 100644
index 0000000..89ce189
--- /dev/null
+++ b/src/leapflow/monitor/session_producer.py
@@ -0,0 +1,308 @@
+"""Session-analysis producer: model the current conversation as a Watch.
+
+``SessionAnalysisProducer`` reuses the generic Watch -> Finding machinery: on each
+cycle it reads the conversation transcript (through an injected services facade),
+decides whether a refresh is warranted (batch threshold, model salience, or a
+forced manual refresh), and emits one insight ``Finding`` carrying a structured
+analysis payload (story / insights / decisions / action items / entities / ...).
+
+The producer holds only in-memory checkpoints keyed by watch id; on daemon
+restart it re-analyzes once, which is acceptable and self-correcting.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Protocol, Sequence, runtime_checkable
+
+from leapflow.monitor.series_extractor import extract_charts
+from leapflow.monitor.types import Finding, ProducerContext, Severity
+
+logger = logging.getLogger(__name__)
+
+DOMAIN = "session"
+
+_DEFAULT_BATCH_TURNS = 6
+_DEFAULT_BATCH_TOKENS = 4000
+_DEFAULT_DEBOUNCE_S = 15.0
+_DEFAULT_MAX_PER_MIN = 4
+
+
+@runtime_checkable
+class SessionAnalysisServices(Protocol):
+ """Daemon-provided capabilities the session producer needs.
+
+ Kept as a protocol so the producer is testable with a fake and never imports
+ engine/LLM internals directly.
+ """
+
+ async def session_history(self) -> dict[str, Any]:
+ """Return {session_id, turn_count, token_count, messages:[{role,content}]}."""
+ ...
+
+ async def analyze_session(
+ self,
+ messages: list[dict[str, Any]],
+ *,
+ prior: dict[str, Any] | None = None,
+ artifacts: list[dict[str, Any]] | None = None,
+ ) -> dict[str, Any]:
+ """Return a structured analysis payload for the transcript and artifacts."""
+ ...
+
+ async def should_refresh(self, messages: list[dict[str, Any]]) -> bool:
+ """Return True when the model judges a refresh worthwhile (salience)."""
+ ...
+
+
+def _first_line(text: str, limit: int = 180) -> str:
+ line = " ".join(str(text).split())
+ return line if len(line) <= limit else line[: limit - 1] + "\u2026"
+
+
+def _artifact_fingerprint(artifacts: list[dict[str, Any]]) -> str:
+ """Return a stable fingerprint for artifact state changes."""
+ parts: list[str] = []
+ for artifact in artifacts:
+ path = str(artifact.get("path", ""))
+ mtime = str(artifact.get("mtime", ""))
+ size = str(artifact.get("size", ""))
+ status = str(artifact.get("status", ""))
+ parts.append(f"{path}:{mtime}:{size}:{status}")
+ return "|".join(sorted(parts))
+
+
+def _refresh_reason(
+ *,
+ force: bool,
+ first: bool,
+ artifact_changed: bool,
+ turn_delta: int,
+ token_delta: int,
+ batch_turns: int,
+ batch_tokens: int,
+) -> str:
+ if force:
+ return "manual_refresh"
+ if first:
+ return "first_observation"
+ if artifact_changed:
+ return "artifact_changed"
+ if turn_delta >= batch_turns:
+ return "batch_turns"
+ if token_delta >= batch_tokens:
+ return "batch_tokens"
+ return "model_salience"
+
+
+def _observation_status(
+ *,
+ artifacts: list[dict[str, Any]],
+ reason: str,
+ now: float,
+ turn_count: int,
+ token_count: int,
+ batch_turns: int,
+ batch_tokens: int,
+ last_turn: int,
+) -> dict[str, Any]:
+ included = [a for a in artifacts if a.get("status") == "included"]
+ skipped = [a for a in artifacts if a.get("status") == "skipped"]
+ # A discrete, honest context label instead of an opaque coverage percentage.
+ # The client localizes the enum; values never leak untranslated into the UI.
+ if not artifacts:
+ context_scope = "text_only"
+ elif skipped:
+ context_scope = "partial_artifacts"
+ else:
+ context_scope = "text_and_artifacts"
+ return {
+ "state": "watching",
+ "refresh_reason": reason,
+ "last_refresh_at": now,
+ "context_scope": context_scope,
+ "artifact_count": len(artifacts),
+ "artifacts_included": len(included),
+ "artifacts_skipped": len(skipped),
+ "next_threshold": {
+ "turns": batch_turns,
+ "tokens": batch_tokens,
+ "turns_since_last": max(0, turn_count - max(last_turn, 0)),
+ "current_turns": turn_count,
+ "current_tokens": token_count,
+ },
+ }
+
+
+class SessionAnalysisProducer:
+ """Produce session-analysis findings from the live conversation transcript."""
+
+ def __init__(self) -> None:
+ self._last_turn: dict[str, int] = {}
+ self._last_tokens: dict[str, int] = {}
+ self._last_artifact_fingerprint: dict[str, str] = {}
+ self._last_run_at: dict[str, float] = {}
+ self._recent_runs: dict[str, list[float]] = {}
+
+ @property
+ def domain(self) -> str:
+ return DOMAIN
+
+ async def observe(self, ctx: ProducerContext) -> Sequence[Finding]:
+ services = ctx.services
+ if services is None:
+ return []
+ watch_id = ctx.spec.watch_id
+ params = ctx.spec.params or {}
+ batch_turns = int(params.get("batch_turns", _DEFAULT_BATCH_TURNS))
+ batch_tokens = int(params.get("batch_tokens", _DEFAULT_BATCH_TOKENS))
+ use_salience = bool(params.get("use_model_salience", False))
+ debounce_s = float(params.get("debounce_s", _DEFAULT_DEBOUNCE_S))
+ max_per_min = int(params.get("max_refresh_per_min", _DEFAULT_MAX_PER_MIN))
+
+ try:
+ history = await services.session_history()
+ except Exception as exc: # noqa: BLE001 - degrade if history unavailable
+ logger.debug("session producer: history unavailable: %s", exc)
+ return []
+ turn_count = int(history.get("turn_count", 0) or 0)
+ token_count = int(history.get("token_count", 0) or 0)
+ session_id = str(history.get("session_id", "") or "")
+ messages = list(history.get("messages") or [])
+ artifacts = list(history.get("artifacts") or [])
+ artifact_fingerprint = _artifact_fingerprint(artifacts)
+
+ last_turn = self._last_turn.get(watch_id, -1)
+ first = last_turn < 0
+ artifact_changed = bool(artifact_fingerprint and artifact_fingerprint != self._last_artifact_fingerprint.get(watch_id, ""))
+
+ if not ctx.force and not first and turn_count <= last_turn and not artifact_changed:
+ return [] # no new turns or artifacts since last analysis
+
+ if not ctx.force and not first:
+ if (ctx.now - self._last_run_at.get(watch_id, 0.0)) < debounce_s:
+ return []
+ recent = [t for t in self._recent_runs.get(watch_id, []) if ctx.now - t < 60.0]
+ if len(recent) >= max_per_min:
+ return []
+
+ should = (
+ ctx.force
+ or first
+ or artifact_changed
+ or (turn_count - last_turn >= batch_turns)
+ or (token_count - self._last_tokens.get(watch_id, 0) >= batch_tokens)
+ )
+ if not should and use_salience and turn_count > last_turn:
+ try:
+ should = bool(await services.should_refresh(messages))
+ except Exception: # noqa: BLE001 - salience is best-effort
+ should = False
+ if not should:
+ return []
+
+ reason = _refresh_reason(
+ force=ctx.force,
+ first=first,
+ artifact_changed=artifact_changed,
+ turn_delta=turn_count - last_turn,
+ token_delta=token_count - self._last_tokens.get(watch_id, 0),
+ batch_turns=batch_turns,
+ batch_tokens=batch_tokens,
+ )
+ try:
+ analysis = dict(await services.analyze_session(messages, artifacts=artifacts))
+ except TypeError:
+ analysis = dict(await services.analyze_session(messages))
+ except Exception as exc: # noqa: BLE001 - surface as no-op, not crash
+ logger.debug("session producer: analysis failed: %s", exc)
+ return []
+
+ self._last_turn[watch_id] = turn_count
+ self._last_tokens[watch_id] = token_count
+ self._last_artifact_fingerprint[watch_id] = artifact_fingerprint
+ self._last_run_at[watch_id] = ctx.now
+ self._recent_runs.setdefault(watch_id, []).append(ctx.now)
+
+ analysis.setdefault("usage", {})
+ analysis["usage"].update({"turns": turn_count, "tokens": token_count})
+ analysis["artifact_context"] = artifacts
+ # Signal-driven, anti-hallucination charts: numbers come from captured
+ # tool/artifact outputs (never the model), keyed to the model's intents.
+ analysis["charts"] = extract_charts(
+ messages=messages,
+ artifacts=artifacts,
+ intents=analysis.get("series_intents"),
+ )
+ analysis["observation_status"] = _observation_status(
+ artifacts=artifacts,
+ reason=reason,
+ now=ctx.now,
+ turn_count=turn_count,
+ token_count=token_count,
+ batch_turns=batch_turns,
+ batch_tokens=batch_tokens,
+ last_turn=last_turn,
+ )
+ story = str(analysis.get("story") or "")
+ summary = _first_line(story) if story else f"{turn_count} turns analyzed"
+ return [
+ Finding(
+ watch_id=watch_id,
+ domain=DOMAIN,
+ title=f"Session analysis \u00b7 {turn_count} turns",
+ summary=summary,
+ severity=Severity.NOTABLE,
+ score=0.5,
+ tags=("session",),
+ payload=analysis,
+ dedup_key=f"{watch_id}:{session_id}:{turn_count}",
+ )
+ ]
+
+
+_SESSION_TRIGGER = "2m"
+
+
+def session_watch_params(settings: Any) -> dict[str, Any]:
+ """Build session-watch params from settings (falls back to producer defaults)."""
+ if settings is None:
+ return {}
+ return {
+ "batch_turns": int(getattr(settings, "monitor_session_batch_turns", _DEFAULT_BATCH_TURNS)),
+ "batch_tokens": int(getattr(settings, "monitor_session_batch_tokens", _DEFAULT_BATCH_TOKENS)),
+ "use_model_salience": bool(getattr(settings, "monitor_session_use_model_salience", False)),
+ "debounce_s": float(getattr(settings, "monitor_session_debounce_s", _DEFAULT_DEBOUNCE_S)),
+ "max_refresh_per_min": int(getattr(settings, "monitor_session_max_refresh_per_min", _DEFAULT_MAX_PER_MIN)),
+ }
+
+
+async def ensure_session_watch(monitors: Any, *, params: dict[str, Any] | None = None) -> str:
+ """Find the active session watch, or arm a new client-coupled one; return its id.
+
+ Single source of truth for the session watch shape (trigger, coupling) so the
+ daemon RPC and the TUI slash handler cannot drift apart.
+ """
+ for view in monitors.list_watches():
+ if view.domain == DOMAIN and view.state in ("armed", "watching"):
+ return view.watch_id
+ from leapflow.monitor.types import WatchSpec
+
+ view = await monitors.arm_watch(WatchSpec(
+ name="Session",
+ domain=DOMAIN,
+ trigger_expr=_SESSION_TRIGGER,
+ sensitivity="notable",
+ params=dict(params or {}),
+ client_coupled=True,
+ ))
+ return view.watch_id
+
+
+__all__ = [
+ "SessionAnalysisProducer",
+ "SessionAnalysisServices",
+ "ensure_session_watch",
+ "session_watch_params",
+ "DOMAIN",
+]
diff --git a/src/leapflow/monitor/types.py b/src/leapflow/monitor/types.py
new file mode 100644
index 0000000..9219763
--- /dev/null
+++ b/src/leapflow/monitor/types.py
@@ -0,0 +1,337 @@
+"""Domain-neutral contract for the monitoring subsystem.
+
+A ``Watch`` is a persistent, proactive monitor that periodically observes a
+source and emits ``Finding`` objects. The contract is domain-agnostic: finance,
+sentiment, research, and session analysis differ only by ``domain`` and the
+shape of ``Finding.payload`` -- never by branching in core logic.
+
+Watches are persisted as scheduler ``ArmedTask`` rows (``kind=watch``); this
+module owns only the domain vocabulary and the serialization helpers that map a
+``WatchSpec`` to/from an ``ArmedTask``.
+"""
+
+from __future__ import annotations
+
+import time
+import uuid
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Any, Mapping, Protocol, Sequence, runtime_checkable
+
+# ── Event names broadcast on the daemon NotificationBus ──────────────────
+EVENT_FINDING = "monitor.finding"
+EVENT_WATCH_STATE = "watch.state"
+EVENT_ERROR = "monitor.error"
+EVENT_HEARTBEAT = "monitor.heartbeat"
+
+# ── ArmedTask.metadata markers ───────────────────────────────────────────
+WATCH_KIND = "watch"
+METADATA_KIND_KEY = "kind"
+METADATA_MUTED_KEY = "muted"
+# Client-coupled watches (e.g. session analysis) only make sense while an
+# interactive client is present; they must NOT keep the daemon alive on their own.
+METADATA_CLIENT_COUPLED_KEY = "client_coupled"
+
+_SEVERITY_RANK = {"info": 0, "notable": 1, "alert": 2}
+
+
+class Severity(str, Enum):
+ """Finding importance, driving disclosure and push gating."""
+
+ INFO = "info" # persist only (memory-level)
+ NOTABLE = "notable" # persist + passive push
+ ALERT = "alert" # persist + push + eligible for escalation
+
+ @property
+ def rank(self) -> int:
+ """Return an ordinal for threshold comparisons."""
+ return _SEVERITY_RANK[self.value]
+
+ @classmethod
+ def coerce(cls, value: Any, default: "Severity | None" = None) -> "Severity":
+ """Return a Severity from a loose string, falling back to ``default``.
+
+ ``default`` resolves to ``Severity.INFO`` when not supplied (it cannot be
+ referenced as a class-body default before the enum members exist).
+ """
+ if isinstance(value, cls):
+ return value
+ try:
+ return cls(str(value).strip().lower())
+ except ValueError:
+ return default if default is not None else cls.INFO
+
+
+@dataclass(frozen=True)
+class Evidence:
+ """A single citation/link/snippet backing a finding."""
+
+ kind: str = "text" # text | link | quote | metric
+ label: str = ""
+ value: str = ""
+ url: str = ""
+
+ def to_dict(self) -> dict[str, Any]:
+ return {"kind": self.kind, "label": self.label, "value": self.value, "url": self.url}
+
+ @classmethod
+ def from_dict(cls, data: Mapping[str, Any]) -> "Evidence":
+ return cls(
+ kind=str(data.get("kind", "text")),
+ label=str(data.get("label", "")),
+ value=str(data.get("value", "")),
+ url=str(data.get("url", "")),
+ )
+
+
+@dataclass(frozen=True)
+class SuggestedAction:
+ """A proposed next action a user can take on a finding.
+
+ ``kind`` mirrors the dashboard action protocol so a suggested action can be
+ dispatched directly by a view client (nav | rpc | intent | approval).
+ """
+
+ name: str
+ label: str = ""
+ kind: str = "intent" # nav | rpc | intent | approval
+ params: Mapping[str, Any] = field(default_factory=dict)
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "name": self.name,
+ "label": self.label or self.name,
+ "kind": self.kind,
+ "params": dict(self.params),
+ }
+
+ @classmethod
+ def from_dict(cls, data: Mapping[str, Any]) -> "SuggestedAction":
+ return cls(
+ name=str(data.get("name", "")),
+ label=str(data.get("label", "")),
+ kind=str(data.get("kind", "intent")),
+ params=dict(data.get("params") or {}),
+ )
+
+
+@dataclass(frozen=True)
+class Finding:
+ """An immutable unit of observation produced by a watch.
+
+ ``payload`` is a domain-private escape hatch (e.g. OHLC series for finance,
+ author/abstract for papers). Core code never inspects it; only the matching
+ dashboard renderer does.
+ """
+
+ watch_id: str
+ domain: str
+ title: str
+ summary: str = ""
+ severity: Severity = Severity.INFO
+ score: float = 0.0
+ ts: float = field(default_factory=time.time)
+ finding_id: str = field(default_factory=lambda: uuid.uuid4().hex)
+ evidence: tuple[Evidence, ...] = ()
+ tags: tuple[str, ...] = ()
+ suggested_actions: tuple[SuggestedAction, ...] = ()
+ payload: Mapping[str, Any] = field(default_factory=dict)
+ dedup_key: str = ""
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "finding_id": self.finding_id,
+ "watch_id": self.watch_id,
+ "domain": self.domain,
+ "title": self.title,
+ "summary": self.summary,
+ "severity": self.severity.value,
+ "score": self.score,
+ "ts": self.ts,
+ "evidence": [item.to_dict() for item in self.evidence],
+ "tags": list(self.tags),
+ "suggested_actions": [action.to_dict() for action in self.suggested_actions],
+ "payload": dict(self.payload),
+ "dedup_key": self.dedup_key,
+ }
+
+ @classmethod
+ def from_dict(cls, data: Mapping[str, Any]) -> "Finding":
+ return cls(
+ finding_id=str(data.get("finding_id") or uuid.uuid4().hex),
+ watch_id=str(data.get("watch_id", "")),
+ domain=str(data.get("domain", "")),
+ title=str(data.get("title", "")),
+ summary=str(data.get("summary", "")),
+ severity=Severity.coerce(data.get("severity")),
+ score=float(data.get("score", 0.0) or 0.0),
+ ts=float(data.get("ts", 0.0) or time.time()),
+ evidence=tuple(Evidence.from_dict(item) for item in (data.get("evidence") or [])),
+ tags=tuple(str(tag) for tag in (data.get("tags") or [])),
+ suggested_actions=tuple(
+ SuggestedAction.from_dict(item) for item in (data.get("suggested_actions") or [])
+ ),
+ payload=dict(data.get("payload") or {}),
+ dedup_key=str(data.get("dedup_key", "")),
+ )
+
+
+@dataclass(frozen=True)
+class WatchSpec:
+ """Declarative definition of a monitor, persisted inside an ArmedTask.
+
+ ``sensitivity`` is the minimum severity that will be pushed to view clients;
+ lower-severity findings are still persisted (memory-level).
+ """
+
+ name: str
+ domain: str
+ trigger_expr: str = "10m"
+ source: Mapping[str, Any] = field(default_factory=dict)
+ lens: Mapping[str, Any] = field(default_factory=dict)
+ sensitivity: str = "notable" # info | notable | alert
+ params: Mapping[str, Any] = field(default_factory=dict)
+ max_runs: int = -1
+ execution_tier: str = "local"
+ watch_id: str = ""
+ muted: bool = False
+ client_coupled: bool = False
+
+ def push_threshold(self) -> Severity:
+ """Return the minimum severity that should be pushed to clients."""
+ return Severity.coerce(self.sensitivity, default=Severity.NOTABLE)
+
+ def to_task_parameters(self) -> dict[str, Any]:
+ """Serialize spec content into ``ArmedTask.parameters``."""
+ return {
+ "watch_id": self.watch_id,
+ "name": self.name,
+ "domain": self.domain,
+ "trigger_expr": self.trigger_expr,
+ "source": dict(self.source),
+ "lens": dict(self.lens),
+ "sensitivity": self.sensitivity,
+ "params": dict(self.params),
+ }
+
+ @classmethod
+ def from_params(cls, parameters: Mapping[str, Any], *, muted: bool = False) -> "WatchSpec":
+ """Reconstruct a WatchSpec from ``ArmedTask.parameters``."""
+ params = parameters if isinstance(parameters, Mapping) else {}
+ return cls(
+ watch_id=str(params.get("watch_id", "")),
+ name=str(params.get("name", "")),
+ domain=str(params.get("domain", "")),
+ trigger_expr=str(params.get("trigger_expr", "10m")),
+ source=dict(params.get("source") or {}),
+ lens=dict(params.get("lens") or {}),
+ sensitivity=str(params.get("sensitivity", "notable")),
+ params=dict(params.get("params") or {}),
+ muted=bool(muted),
+ )
+
+ @classmethod
+ def from_dict(cls, data: Mapping[str, Any]) -> "WatchSpec":
+ """Build a spec from a client request dict (RPC/CLI/natural language)."""
+ data = data if isinstance(data, Mapping) else {}
+ return cls(
+ name=str(data.get("name", "")).strip() or "watch",
+ domain=str(data.get("domain", "")).strip(),
+ trigger_expr=str(data.get("trigger_expr") or data.get("trigger") or "10m").strip(),
+ source=dict(data.get("source") or {}),
+ lens=dict(data.get("lens") or {}),
+ sensitivity=str(data.get("sensitivity", "notable")).strip() or "notable",
+ params=dict(data.get("params") or {}),
+ max_runs=int(data.get("max_runs", -1) or -1),
+ execution_tier=str(data.get("execution_tier", "local")).strip() or "local",
+ watch_id=str(data.get("watch_id", "")),
+ muted=bool(data.get("muted", False)),
+ client_coupled=bool(data.get("client_coupled", False)),
+ )
+
+
+@dataclass(frozen=True)
+class WatchView:
+ """Runtime snapshot of a watch for listing and status reporting."""
+
+ watch_id: str
+ name: str
+ domain: str
+ trigger: str
+ state: str
+ muted: bool
+ run_count: int
+ next_due_at: float
+ last_run_at: float
+ finding_count: int = 0
+ client_coupled: bool = False
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "watch_id": self.watch_id,
+ "name": self.name,
+ "domain": self.domain,
+ "trigger": self.trigger,
+ "state": self.state,
+ "muted": self.muted,
+ "run_count": self.run_count,
+ "next_due_at": self.next_due_at,
+ "last_run_at": self.last_run_at,
+ "finding_count": self.finding_count,
+ "client_coupled": self.client_coupled,
+ }
+
+
+@dataclass(frozen=True)
+class ProducerContext:
+ """Inputs handed to a producer for one observation cycle.
+
+ ``services`` is an opaque, daemon-provided facade exposing capabilities a
+ producer may need (skill execution, session history, LLM). It is optional so
+ producers and tests can run without a live runtime.
+ """
+
+ spec: WatchSpec
+ now: float
+ run_count: int = 0
+ last_run_at: float = 0.0
+ services: Any = None
+ force: bool = False
+
+
+@runtime_checkable
+class MonitorProducer(Protocol):
+ """Per-domain observation logic: observe a source, emit findings.
+
+ Producers own the domain-specific observe -> normalize -> score -> dedup
+ steps; the ``Finding`` schema and the emit/persist path stay universal.
+ """
+
+ @property
+ def domain(self) -> str:
+ """Domain key this producer serves (matches ``WatchSpec.domain``)."""
+ ...
+
+ async def observe(self, ctx: ProducerContext) -> Sequence[Finding]:
+ """Return findings for this cycle (possibly empty)."""
+ ...
+
+
+__all__ = [
+ "EVENT_FINDING",
+ "EVENT_WATCH_STATE",
+ "EVENT_ERROR",
+ "EVENT_HEARTBEAT",
+ "WATCH_KIND",
+ "METADATA_KIND_KEY",
+ "METADATA_MUTED_KEY",
+ "METADATA_CLIENT_COUPLED_KEY",
+ "Severity",
+ "Evidence",
+ "SuggestedAction",
+ "Finding",
+ "WatchSpec",
+ "WatchView",
+ "ProducerContext",
+ "MonitorProducer",
+]
diff --git a/src/leapflow/version.py b/src/leapflow/version.py
index c3cf270..68aa1da 100644
--- a/src/leapflow/version.py
+++ b/src/leapflow/version.py
@@ -1,3 +1,3 @@
"""Version information for leapflow."""
-__version__ = "0.0.3+main"
+__version__ = "0.0.5+main"
diff --git a/tests/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py
index 9f1926e..5ad4ec8 100644
--- a/tests/test_cli_entrypoint.py
+++ b/tests/test_cli_entrypoint.py
@@ -1079,6 +1079,73 @@ def fail_stop(*args, **kwargs):
assert any("kept running" in message for message in console.systems)
+@pytest.mark.asyncio
+async def test_daemon_tui_exit_stops_all_board_watches(
+ monkeypatch,
+ tmp_path,
+) -> None:
+ from leapflow.cli.commands import interactive as interactive_module
+ import leapflow.daemon.lifecycle as lifecycle_module
+
+ class Client:
+ async def status(self):
+ return {"pid": 1234, "connected_clients": 0}
+
+ async def watch_list(self):
+ return [
+ {"watch_id": "session-watch", "name": "Session", "domain": "session", "state": "armed", "client_coupled": True},
+ {"watch_id": "market-watch", "name": "Market", "domain": "finance", "state": "executing", "client_coupled": False},
+ ]
+
+ async def watch_stop(self, watch_id: str):
+ calls.append(("watch_stop", watch_id))
+ return {"watch_id": watch_id, "state": "done"}
+
+ async def shutdown(self):
+ calls.append(("shutdown",))
+
+ class Console:
+ def __init__(self) -> None:
+ self.systems: list[str] = []
+ self.warnings: list[str] = []
+
+ def system(self, message: str) -> None:
+ self.systems.append(message)
+
+ def warning(self, message: str) -> None:
+ self.warnings.append(message)
+
+ class Settings:
+ profile_dir = tmp_path
+ runtime_dir = tmp_path / "runtime"
+
+ async def yes(prompt: str) -> bool:
+ prompts.append(prompt)
+ return True
+
+ def record_stop(runtime_dir, **kwargs):
+ calls.append(("stop_daemon", runtime_dir, kwargs))
+ return lifecycle_module.StopDaemonResult(pid=1234, stopped=True, forced=True)
+
+ calls = []
+ prompts: list[str] = []
+ monkeypatch.setattr(interactive_module, "_ask_yes_no_default_yes", yes)
+ monkeypatch.setattr(lifecycle_module, "stop_daemon", record_stop)
+ console = Console()
+
+ await interactive_module._prompt_stop_daemon_on_exit(Client(), Settings(), console)
+
+ # Every active board watch is stopped on exit, regardless of coupling.
+ assert ("watch_stop", "session-watch") in calls
+ assert ("watch_stop", "market-watch") in calls
+ assert any("Stopped 2 board watch(es) on exit." in message for message in console.systems)
+ stop_call = next(call for call in calls if call[0] == "stop_daemon")
+ assert stop_call[2]["force"] is True
+ assert prompts == ["Stop leapd now (pid=1234)? [Y/n]: "]
+ assert console.systems[-1] == "leapd stopped (forced with SIGKILL)."
+
+
+
def test_leap_prompt_uses_daemon_chat_route(monkeypatch) -> None:
from leapflow.cli import cli
@@ -1095,6 +1162,60 @@ async def fake_daemon_main(args):
assert captured == {"command": "chat", "prompt": "hello world"}
+def test_leap_typo_command_suggests_instead_of_chatting(monkeypatch, capsys) -> None:
+ from leapflow.cli import cli
+
+ called = {"chat": False}
+
+ async def fake_daemon_main(args): # pragma: no cover - must not run
+ called["chat"] = True
+ return 0
+
+ monkeypatch.setattr(cli, "_async_daemon_main", fake_daemon_main)
+
+ code = cli.main(["deamon", "status"])
+
+ assert code == 2 # usage error, not a chat turn
+ assert called["chat"] is False # never spawned a daemon or asked the LLM
+ err = capsys.readouterr().err
+ assert "Did you mean 'daemon'" in err
+ assert "leap daemon status" in err
+
+
+def test_leap_typo_suggestion_preserves_leading_global_flags(monkeypatch, capsys) -> None:
+ from leapflow.cli import cli
+
+ async def fake_daemon_main(args): # pragma: no cover - must not run
+ return 0
+
+ monkeypatch.setattr(cli, "_async_daemon_main", fake_daemon_main)
+
+ # Replacing only the typo token keeps leading global flags in the suggestion
+ # (the old join dropped everything before the command).
+ code = cli.main(["--debug", "deamon", "status"])
+
+ assert code == 2
+ assert "leap --debug daemon status" in capsys.readouterr().err
+
+
+def test_leap_long_freetext_near_miss_still_chats(monkeypatch) -> None:
+ from leapflow.cli import cli
+
+ captured = {}
+
+ async def fake_daemon_main(args):
+ captured["command"] = args.command
+ captured["prompt"] = args.prompt
+ return 0
+
+ monkeypatch.setattr(cli, "_async_daemon_main", fake_daemon_main)
+
+ # First word is a near-miss of `daemon`, but a full sentence is genuine chat
+ # and must not be hijacked by the did-you-mean guard.
+ assert cli.main(["deamon", "is", "a", "background", "process"]) == 0
+ assert captured == {"command": "chat", "prompt": "deamon is a background process"}
+
+
@pytest.mark.asyncio
async def test_teach_start_without_session_returns_structured_error() -> None:
from types import SimpleNamespace
diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py
index 7eca7f0..af3980b 100644
--- a/tests/test_config_loader.py
+++ b/tests/test_config_loader.py
@@ -77,3 +77,19 @@ def test_config_loader_warns_on_missing_secret_ref_and_bad_section(monkeypatch,
assert "LEAPFLOW_LLM_API_KEY" not in bundle.env
assert any("Missing secret ref" in warning for warning in bundle.warnings)
assert any("section 'cache' must be a mapping" in warning for warning in bundle.warnings)
+
+
+def test_mask_secret_reveals_only_a_short_suffix() -> None:
+ from leapflow.config_service import _mask_secret
+
+ assert _mask_secret("") == "missing"
+ assert _mask_secret(" ") == "missing"
+ # Long enough to reveal the last 3 characters as a recognizable hint.
+ assert _mask_secret("sk-1234567890abc") == "***abc"
+ # Too short to safely reveal any characters.
+ assert _mask_secret("short") == "***"
+ # Hardened threshold: even an 8-char secret is fully masked (revealing 3 of
+ # 8 would expose too much of a short secret).
+ assert _mask_secret("12345678") == "***"
+ # Never leaks the full value.
+ assert "1234567890" not in _mask_secret("sk-1234567890abc")
diff --git a/tests/test_daemon_rpc.py b/tests/test_daemon_rpc.py
index 1528b2c..8200e6d 100644
--- a/tests/test_daemon_rpc.py
+++ b/tests/test_daemon_rpc.py
@@ -1028,6 +1028,72 @@ def test_stop_daemon_force_escalates_after_timeout(monkeypatch, tmp_path) -> Non
assert signals == [lifecycle_module.signal.SIGTERM, lifecycle_module.signal.SIGKILL]
+def test_stop_daemon_narrates_progress(monkeypatch, tmp_path) -> None:
+ import leapflow.daemon.lifecycle as lifecycle_module
+
+ running = lifecycle_module.DaemonInfo(
+ pid=4885,
+ sock_path=tmp_path / "runtime" / "leapd.sock",
+ start_time=None,
+ is_running=True,
+ is_healthy=False,
+ )
+ monkeypatch.setattr(lifecycle_module.DaemonInfo, "discover", staticmethod(lambda runtime_dir: running))
+ monkeypatch.setattr(lifecycle_module, "send_signal", lambda runtime_dir, sig: True)
+
+ messages: list[str] = []
+ result = lifecycle_module.stop_daemon(
+ tmp_path / "runtime",
+ timeout_s=0.05,
+ force=True,
+ grace_timeout_s=0.05,
+ force_timeout_s=0.05,
+ poll_interval_s=0.01,
+ on_progress=messages.append,
+ )
+
+ assert result.timed_out is True
+ # Every escalation step is narrated so the wait is transparent, not silent.
+ assert any("graceful" in m.lower() for m in messages)
+ assert any("SIGTERM" in m for m in messages)
+ assert any("SIGKILL" in m for m in messages)
+
+
+def test_process_alive_reaps_exited_child_as_dead() -> None:
+ import subprocess
+ import sys
+ import time
+
+ import leapflow.daemon.lifecycle as lifecycle_module
+
+ # A child spawned by this process becomes an unreaped zombie once it exits;
+ # os.kill(pid, 0) still succeeds for zombies. _process_alive must reap it and
+ # report it as dead so a SIGKILL'd daemon is not seen as "still running".
+ proc = subprocess.Popen([sys.executable, "-c", "pass"]) # noqa: S603 - trusted argv
+ deadline = time.time() + 5.0
+ alive = True
+ while time.time() < deadline:
+ alive = lifecycle_module._process_alive(proc.pid)
+ if not alive:
+ break
+ time.sleep(0.05)
+ proc.returncode = 0 # already reaped by _process_alive; silence Popen.__del__
+
+ assert alive is False
+
+
+def test_process_alive_false_for_reaped_pid() -> None:
+ import subprocess
+ import sys
+
+ import leapflow.daemon.lifecycle as lifecycle_module
+
+ proc = subprocess.Popen([sys.executable, "-c", "pass"]) # noqa: S603 - trusted argv
+ proc.wait() # fully reaped; pid no longer exists
+
+ assert lifecycle_module._process_alive(proc.pid) is False
+
+
def test_daemon_restart_stops_and_starts(monkeypatch, tmp_path) -> None:
from leapflow.cli.commands import daemon as daemon_module
diff --git a/tests/test_dashboard_domains.py b/tests/test_dashboard_domains.py
new file mode 100644
index 0000000..964a68c
--- /dev/null
+++ b/tests/test_dashboard_domains.py
@@ -0,0 +1,81 @@
+"""Hermetic tests for P4 domain templates and the custom-component escape hatch."""
+
+from __future__ import annotations
+
+from leapflow.dashboard import TemplateLibrary, normalize_viewspec, select_template
+from leapflow.dashboard.viewspec import COMPONENT_TYPES
+
+
+def _flatten(spec: dict) -> list[dict]:
+ flat: list[dict] = []
+
+ def _walk(nodes: list) -> None:
+ for node in nodes:
+ flat.append(node)
+ _walk(node.get("children") or [])
+
+ _walk(spec["root"])
+ return flat
+
+
+def _types(spec: dict) -> set[str]:
+ return {n["type"] for n in _flatten(spec)}
+
+
+_ANALYSIS = {
+ "title": "Session Analysis",
+ "analysis": {
+ "story": "the arc",
+ "insights": [{"title": "i", "summary": "s", "severity": "notable"}],
+ "decisions": ["chose y"], "action_items": ["do x"], "open_questions": ["why?"],
+ "entities": ["Alice"], "next_prompts": ["ask z"],
+ },
+ "observation": {"refresh_reason": "manual_refresh", "context_scope": "text_only"}, "artifact_context": [],
+}
+
+
+def test_builtin_template_lenses_are_available() -> None:
+ names = TemplateLibrary().names()
+ for name in ("finance", "sentiment", "research", "generic"):
+ assert name in names
+ # Legacy watch-detail templates are gone; there is one target (the session).
+ for gone in ("finance.market", "sentiment.topic", "research.paper", "session.analysis", "overview"):
+ assert gone not in names
+
+
+def test_select_template_returns_requested_lens_or_generic() -> None:
+ names = TemplateLibrary().names()
+ assert select_template("finance", names) == "finance"
+ assert select_template("sentiment", names) == "sentiment"
+ assert select_template("research", names) == "research"
+ assert select_template("", names) == "generic"
+ assert select_template("nope", names) == "generic"
+
+
+def test_finance_lens_reframes_session_analysis() -> None:
+ spec = TemplateLibrary().render("finance", _ANALYSIS)
+ types = _types(spec)
+ # Same session analysis, reframed as calls/actions/exposures.
+ assert {"StoryPanel", "BarChart", "EntityGraph", "List"}.issubset(types)
+
+
+def test_sentiment_lens_reframes_session_analysis() -> None:
+ spec = TemplateLibrary().render("sentiment", _ANALYSIS)
+ types = _types(spec)
+ assert {"StoryPanel", "EntityGraph"}.issubset(types)
+ assert len([n for n in _flatten(spec) if n["type"] == "InsightCard"]) == 1
+
+
+def test_research_lens_reframes_session_analysis() -> None:
+ spec = TemplateLibrary().render("research", _ANALYSIS)
+ types = _types(spec)
+ assert {"StoryPanel", "EntityGraph", "SuggestionChips"}.issubset(types)
+ assert len([n for n in _flatten(spec) if n["type"] == "InsightCard"]) == 1
+
+
+def test_custom_component_is_in_catalog_and_survives_normalize() -> None:
+ assert "Custom" in COMPONENT_TYPES
+ for component in ("BarChart", "PieChart", "LineChart", "Sparkline", "Timeline", "EntityGraph"):
+ assert component in COMPONENT_TYPES
+ spec = normalize_viewspec({"root": [{"type": "Custom", "props": {"render": "candlestick"}}]})
+ assert spec["root"][0]["type"] == "Custom"
diff --git a/tests/test_dashboard_launcher.py b/tests/test_dashboard_launcher.py
new file mode 100644
index 0000000..3b63c9c
--- /dev/null
+++ b/tests/test_dashboard_launcher.py
@@ -0,0 +1,193 @@
+"""Hermetic tests for the dashboard launcher and server action dispatch.
+
+No aiohttp required: the launcher is dependency-free and DashboardServer's
+action dispatch only touches the injected client. The aiohttp app wiring is
+covered by an importorskip guard.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+from leapflow.dashboard import launcher
+from leapflow.dashboard.server import DashboardServer
+
+
+def _settings(tmp_path: Path) -> SimpleNamespace:
+ return SimpleNamespace(
+ runtime_dir=tmp_path,
+ dashboard_bind="127.0.0.1",
+ dashboard_port=8765,
+ dashboard_auto_open=True,
+ )
+
+
+# ── launcher ────────────────────────────────────────────────────────────────
+
+
+def test_build_url_includes_token_and_host() -> None:
+ url = launcher.build_url("0.0.0.0", 9000, "abc")
+ assert url == "http://127.0.0.1:9000/?token=abc"
+
+
+def test_generate_token_is_unique() -> None:
+ assert launcher.generate_token() != launcher.generate_token()
+
+
+def test_state_roundtrip(tmp_path: Path) -> None:
+ settings = _settings(tmp_path)
+ assert launcher.load_state(settings) is None
+ launcher.write_state(settings, {"port": 1, "bind": "127.0.0.1", "token": "t"})
+ assert launcher.load_state(settings)["token"] == "t"
+ launcher.clear_state(settings)
+ assert launcher.load_state(settings) is None
+
+
+def test_server_running_requires_open_port_and_valid_token(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ settings = _settings(tmp_path)
+
+ # No discovery state -> not running.
+ assert launcher.server_running(settings) is None
+
+ launcher.write_state(settings, {"port": 8765, "bind": "127.0.0.1", "token": "t"})
+
+ # Port closed -> not running (the token is never probed).
+ monkeypatch.setattr(launcher, "is_port_open", lambda *a, **k: False)
+ assert launcher.server_running(settings) is None
+
+ # Port open but the server rejects the token (stale/foreign): not running,
+ # so callers never build a URL that renders as 'missing or invalid token'.
+ monkeypatch.setattr(launcher, "is_port_open", lambda *a, **k: True)
+ monkeypatch.setattr(launcher, "probe_token", lambda *a, **k: False)
+ assert launcher.server_running(settings) is None
+
+ # Port open and the token is accepted: usable.
+ monkeypatch.setattr(launcher, "probe_token", lambda *a, **k: True)
+ state = launcher.server_running(settings)
+ assert state is not None and state["port"] == 8765
+
+
+def test_ensure_server_reuses_token_valid_server(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ settings = _settings(tmp_path)
+ valid = {"port": 8765, "bind": "127.0.0.1", "token": "T"}
+ monkeypatch.setattr(launcher, "server_running", lambda s: valid)
+ # A validated existing server is reused as-is; no spawn is attempted.
+ assert launcher.ensure_server(settings) is valid
+
+
+def test_open_in_browser_handles_failure(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(launcher.webbrowser, "open", lambda url, new=0: True)
+ assert launcher.open_in_browser("http://x") is True
+
+ def _boom(url, new=0):
+ raise RuntimeError("no display")
+
+ monkeypatch.setattr(launcher.webbrowser, "open", _boom)
+ assert launcher.open_in_browser("http://x") is False
+
+
+def test_ensure_server_requires_aiohttp(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ settings = _settings(tmp_path)
+ monkeypatch.setattr(launcher, "aiohttp_available", lambda: False)
+ with pytest.raises(RuntimeError, match="aiohttp"):
+ launcher.ensure_server(settings)
+
+
+def test_retire_stale_server_skips_kill_when_pid_unverified(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ # Guards PID reuse: without a positive identity match the recorded pid is
+ # never signaled (it may now be an unrelated process); state is still cleared.
+ settings = _settings(tmp_path)
+ launcher.write_state(settings, {"port": 8765, "bind": "127.0.0.1", "token": "t", "pid": 4242})
+ monkeypatch.setattr(launcher, "is_port_open", lambda *a, **k: True)
+ monkeypatch.setattr(launcher, "_pid_is_dashboard_server", lambda pid: False)
+ killed: list[int] = []
+ monkeypatch.setattr(launcher.os, "kill", lambda pid, sig: killed.append(pid))
+ launcher._retire_stale_server(settings)
+ assert killed == []
+ assert launcher.load_state(settings) is None
+
+
+def test_retire_stale_server_signals_verified_dashboard_pid(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ # A positively identified dashboard pid is signaled exactly once to free the port.
+ settings = _settings(tmp_path)
+ launcher.write_state(settings, {"port": 8765, "bind": "127.0.0.1", "token": "t", "pid": 4242})
+ opens = iter([True, False]) # open for the guard, closed right after the signal
+ monkeypatch.setattr(launcher, "is_port_open", lambda *a, **k: next(opens, False))
+ monkeypatch.setattr(launcher, "_pid_is_dashboard_server", lambda pid: True)
+ killed: list[int] = []
+ monkeypatch.setattr(launcher.os, "kill", lambda pid, sig: killed.append(pid))
+ launcher._retire_stale_server(settings)
+ assert killed == [4242]
+ assert launcher.load_state(settings) is None
+
+
+def test_check_origin_matches_loopback_host_exactly() -> None:
+ # A substring test would accept attacker127.0.0.1.com / localhost.evil.com;
+ # we parse the Origin and match its hostname exactly against the loopback set.
+ check = DashboardServer._check_origin
+ assert check(SimpleNamespace(headers={})) is True # no Origin -> allow
+ for origin in ("http://127.0.0.1:8765", "http://localhost:8765", "http://[::1]:8765"):
+ assert check(SimpleNamespace(headers={"Origin": origin})) is True
+ for origin in ("http://attacker127.0.0.1.com", "http://localhost.attacker.com", "https://evil.com"):
+ assert check(SimpleNamespace(headers={"Origin": origin})) is False
+
+
+# ── DashboardServer.dispatch_action (allow-listed, transport-free) ───────────
+
+
+class _FakeClient:
+ def __init__(self) -> None:
+ self.calls: list[tuple] = []
+
+ async def watch_pause(self, watch_id: str) -> dict:
+ self.calls.append(("pause", watch_id))
+ return {"state": "suspended"}
+
+ async def watch_mute(self, watch_id: str, *, muted: bool = True) -> dict:
+ self.calls.append(("mute", watch_id, muted))
+ return {"muted": muted}
+
+ async def approval_resolve(self, pending_id: str, decision: str) -> dict:
+ self.calls.append(("approval", pending_id, decision))
+ return {"ok": True}
+
+
+async def test_dispatch_action_rpc_allowlist_and_kinds() -> None:
+ client = _FakeClient()
+ server = DashboardServer(client=client, token="t")
+
+ ok = await server.dispatch_action({"kind": "rpc", "name": "watch.pause", "params": {"watch_id": "w1"}})
+ assert ok["ok"] is True and ("pause", "w1") in client.calls
+
+ denied = await server.dispatch_action({"kind": "rpc", "name": "daemon.shutdown"})
+ assert denied["ok"] is False
+
+ nav = await server.dispatch_action({"kind": "nav", "name": "filter"})
+ assert nav["ok"] is True
+
+ intent = await server.dispatch_action({"kind": "intent", "name": "drilldown"})
+ assert intent["queued"] is True
+
+ await server.dispatch_action({"kind": "approval", "params": {"pending_id": "p", "decision": "allow"}})
+ assert ("approval", "p", "allow") in client.calls
+
+ unknown = await server.dispatch_action({"kind": "weird"})
+ assert unknown["ok"] is False
+
+
+def test_server_build_app_requires_aiohttp() -> None:
+ pytest.importorskip("aiohttp")
+ server = DashboardServer(client=_FakeClient(), token="t")
+ app = server.build_app()
+ assert app is not None
diff --git a/tests/test_dashboard_sdui.py b/tests/test_dashboard_sdui.py
new file mode 100644
index 0000000..79b7fc2
--- /dev/null
+++ b/tests/test_dashboard_sdui.py
@@ -0,0 +1,164 @@
+"""Hermetic tests for the dashboard SDUI core: ViewSpec, templates, intent."""
+
+from __future__ import annotations
+
+from leapflow.dashboard import (
+ DashboardIntent,
+ TemplateLibrary,
+ normalize_viewspec,
+ render_template,
+ validate_viewspec,
+)
+from leapflow.dashboard.templates import bind_value, resolve_path
+
+
+# ── ViewSpec catalog + validation + fallback ───────────────────────────────
+
+
+def test_normalize_degrades_unknown_component_to_markdown() -> None:
+ spec = normalize_viewspec({
+ "title": "T",
+ "root": [
+ {"type": "Card", "children": [{"type": "Nonexistent", "props": {"x": 1}}]},
+ ],
+ })
+ assert spec["schema_version"] == 1
+ card = spec["root"][0]
+ assert card["type"] == "Card"
+ child = card["children"][0]
+ assert child["type"] == "Markdown"
+ assert "Unsupported component" in child["props"]["text"]
+
+
+def test_normalize_keeps_valid_action_and_drops_invalid() -> None:
+ spec = normalize_viewspec({
+ "root": [
+ {"type": "Button", "action": {"kind": "rpc", "name": "watch.pause", "params": {"id": "x"}}},
+ {"type": "Button", "action": {"kind": "evil", "name": "hack"}},
+ ],
+ })
+ assert spec["root"][0]["action"]["kind"] == "rpc"
+ assert "action" not in spec["root"][1]
+
+
+def test_validate_reports_unknown_type_and_bad_action() -> None:
+ errors = validate_viewspec({
+ "schema_version": 1,
+ "root": [
+ {"type": "Bogus"},
+ {"type": "Button", "action": {"kind": "nope"}},
+ ],
+ })
+ assert any("unknown component type" in e for e in errors)
+ assert any("invalid action" in e for e in errors)
+
+
+def test_validate_accepts_clean_spec() -> None:
+ assert validate_viewspec({
+ "schema_version": 1,
+ "root": [{"type": "Card", "children": [{"type": "Markdown", "props": {"text": "hi"}}]}],
+ }) == []
+
+
+# ── Template binding ────────────────────────────────────────────────────────
+
+
+def test_resolve_path_supports_dots_and_indices() -> None:
+ data = {"a": {"b": [{"c": 7}]}}
+ assert resolve_path(data, "a.b[0].c") == 7
+ assert resolve_path(data, "a.missing") is None
+ assert resolve_path(data, "a.b[5]") is None
+
+
+def test_bind_value_full_and_interpolated() -> None:
+ data = {"finding": {"title": "Spike", "score": 0.9}}
+ assert bind_value("{{ finding.score }}", data) == 0.9 # full match preserves type
+ assert bind_value("T: {{ finding.title }}", data) == "T: Spike" # interpolation -> str
+
+
+def test_bind_value_multiple_placeholders_interpolate() -> None:
+ # Two placeholders + a literal must interpolate to a string, not be misread
+ # as a single bogus dotted path (which previously resolved to None).
+ data = {"observation": {"artifacts_included": 2, "artifact_count": 3}}
+ assert (
+ bind_value(
+ "{{ observation.artifacts_included }}/{{ observation.artifact_count }}", data
+ )
+ == "2/3"
+ )
+
+
+def test_render_template_repeat_and_bind() -> None:
+ template = {
+ "template": "demo",
+ "title": "Watch {{ name }}",
+ "layout": [
+ {
+ "type": "Board",
+ "children": [
+ {
+ "type": "FindingCard",
+ "repeat": "findings",
+ "as": "f",
+ "props": {"title": "{{ f.title }}", "bind": "f"},
+ }
+ ],
+ }
+ ],
+ }
+ data = {"name": "AAPL", "findings": [{"title": "a"}, {"title": "b"}]}
+ spec = render_template(template, data)
+ assert spec["title"] == "Watch AAPL"
+ cards = spec["root"][0]["children"]
+ assert [c["props"]["title"] for c in cards] == ["a", "b"]
+ assert cards[0]["props"]["data"] == {"title": "a"} # bind -> data
+
+
+def test_render_template_repeat_missing_list_yields_no_children() -> None:
+ template = {"layout": [{"type": "Board", "children": [
+ {"type": "FindingCard", "repeat": "nope", "props": {}}]}]}
+ spec = render_template(template, {})
+ assert spec["root"][0]["children"] == []
+
+
+def test_template_library_generic_renders_session_analysis() -> None:
+ lib = TemplateLibrary()
+ assert "generic" in lib.names()
+ spec = lib.render("generic", {"title": "Session Analysis", "analysis": {
+ "story": "arc",
+ "insights": [{"title": "i", "summary": "s", "severity": "notable"}],
+ "action_items": [], "decisions": [], "open_questions": [],
+ "entities": ["Alice"], "next_prompts": ["ask"],
+ }, "observation": {"refresh_reason": "manual_refresh", "context_scope": "text_only"}, "artifact_context": []})
+ assert spec["title"] == "Session Analysis"
+ flat: list[dict] = []
+
+ def _walk(nodes: list) -> None:
+ for n in nodes:
+ flat.append(n)
+ _walk(n.get("children") or [])
+
+ _walk(spec["root"])
+ types = {n["type"] for n in flat}
+ assert "StoryPanel" in types
+ assert len([n for n in flat if n["type"] == "InsightCard"]) == 1
+
+
+def test_template_library_unknown_falls_back_to_generic() -> None:
+ lib = TemplateLibrary()
+ spec = lib.render("does-not-exist", {"title": "F", "findings": []})
+ assert spec["meta"]["template"] == "generic"
+
+
+# ── DashboardIntent (dual entry) ────────────────────────────────────────────
+
+
+def test_intent_from_args_first_token_is_template() -> None:
+ assert DashboardIntent.from_args("finance").template == "finance"
+ assert DashboardIntent.from_args("research extra tokens").template == "research"
+ assert DashboardIntent.from_args("").template == ""
+
+
+def test_intent_from_params_reads_template() -> None:
+ assert DashboardIntent.from_params({"template": "research"}).template == "research"
+ assert DashboardIntent.from_params({}).to_dict() == {"template": ""}
diff --git a/tests/test_dashboard_view.py b/tests/test_dashboard_view.py
new file mode 100644
index 0000000..b0670d3
--- /dev/null
+++ b/tests/test_dashboard_view.py
@@ -0,0 +1,122 @@
+"""Hermetic tests for the dashboard view builder and WebSocket fan-out hub."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from leapflow.dashboard import (
+ DashboardIntent,
+ DashboardViewBuilder,
+ TemplateLibrary,
+ ViewHub,
+ select_template,
+)
+
+
+class _FakeProvider:
+ def __init__(self, watches: list[dict], findings: list[dict]) -> None:
+ self._watches = watches
+ self._findings = findings
+
+ async def watches(self) -> list[dict[str, Any]]:
+ return list(self._watches)
+
+ async def findings(self, *, watch_id: str = "", limit: int = 50) -> list[dict[str, Any]]:
+ items = [f for f in self._findings if f.get("watch_id") == watch_id] if watch_id else list(self._findings)
+ return items[:limit]
+
+
+def _flatten(spec: dict) -> list[dict]:
+ flat: list[dict] = []
+
+ def _walk(nodes: list) -> None:
+ for node in nodes:
+ flat.append(node)
+ _walk(node.get("children") or [])
+
+ _walk(spec["root"])
+ return flat
+
+
+def _session_provider() -> _FakeProvider:
+ return _FakeProvider(
+ watches=[{"watch_id": "s", "domain": "session", "state": "armed",
+ "last_run_at": 10.0, "next_due_at": 20.0, "run_count": 2}],
+ findings=[
+ {"finding_id": "s1", "watch_id": "s", "domain": "session", "title": "analysis",
+ "severity": "notable", "payload": {
+ "story": "the arc",
+ "insights": [{"title": "i", "summary": "s", "severity": "notable"}],
+ "next_prompts": ["p"],
+ "observation_status": {"refresh_reason": "artifact_changed", "context_scope": "text_and_artifacts"},
+ "artifact_context": [{"name": "report.md", "status": "included"}],
+ }},
+ {"finding_id": "x1", "watch_id": "w", "domain": "finance", "title": "noise", "severity": "info"},
+ ],
+ )
+
+
+# -- select_template: requested lens, else generic fallback -------------------
+
+
+def test_select_template_returns_requested_or_generic_fallback() -> None:
+ names = ["generic", "finance", "research"]
+ assert select_template("finance", names) == "finance"
+ assert select_template("", names) == "generic"
+ assert select_template("unknown", names) == "generic"
+
+
+# -- DashboardViewBuilder: one target (current session), template = lens ------
+
+
+async def test_builder_default_template_renders_session_analysis() -> None:
+ builder = DashboardViewBuilder(TemplateLibrary())
+ spec = await builder.build(DashboardIntent(template=""), _session_provider())
+ assert spec["title"] == "Session Analysis"
+ types = {n["type"] for n in _flatten(spec)}
+ assert {"StoryPanel", "BarChart", "EntityGraph", "Table"}.issubset(types)
+ assert len([n for n in _flatten(spec) if n["type"] == "InsightCard"]) == 1
+
+
+async def test_builder_named_template_reframes_same_session() -> None:
+ builder = DashboardViewBuilder(TemplateLibrary())
+ spec = await builder.build(DashboardIntent(template="finance"), _session_provider())
+ types = {n["type"] for n in _flatten(spec)}
+ # The finance lens renders the same session analysis, reframed.
+ assert "StoryPanel" in types and "EntityGraph" in types
+
+
+async def test_builder_unknown_template_falls_back_to_generic() -> None:
+ builder = DashboardViewBuilder(TemplateLibrary())
+ spec = await builder.build(DashboardIntent(template="does-not-exist"), _session_provider())
+ assert spec["title"] == "Session Analysis"
+
+
+async def test_builder_exposes_template_switcher_meta() -> None:
+ # The web client renders its lens switcher from this meta (no hardcoding).
+ builder = DashboardViewBuilder(TemplateLibrary())
+ spec = await builder.build(DashboardIntent(template="finance"), _session_provider())
+ assert spec["meta"]["active_template"] == "finance"
+ assert {"generic", "finance", "sentiment", "research"}.issubset(set(spec["meta"]["templates"]))
+
+
+# -- ViewHub fan-out ----------------------------------------------------------
+
+
+async def test_view_hub_broadcast_and_unsubscribe() -> None:
+ hub = ViewHub()
+ queue = hub.subscribe("a")
+ assert hub.broadcast({"type": "monitor.finding", "payload": {"x": 1}}) == 1
+ assert (await queue.get())["type"] == "monitor.finding"
+ hub.unsubscribe("a")
+ assert hub.broadcast({"type": "x"}) == 0
+ assert hub.subscriber_count == 0
+
+
+async def test_view_hub_backpressure_drops_when_full() -> None:
+ hub = ViewHub(maxsize=1)
+ hub.subscribe("slow")
+ assert hub.broadcast({"n": 1}) == 1
+ assert hub.broadcast({"n": 2}) == 0 # queue full -> dropped, not blocked
+ await hub.shutdown()
+ assert hub.subscriber_count == 0
diff --git a/tests/test_dashboard_watch_rpc.py b/tests/test_dashboard_watch_rpc.py
new file mode 100644
index 0000000..5eb80aa
--- /dev/null
+++ b/tests/test_dashboard_watch_rpc.py
@@ -0,0 +1,367 @@
+"""Hermetic tests for the watch RPC surface and the /board command handler.
+
+No network, no LLM, no full Context: the daemon service and slash handler are
+exercised with an in-memory MonitorManager and a fake producer.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+from types import SimpleNamespace
+
+from leapflow.cli.commands.slash_handlers import command_execute
+from leapflow.daemon.service import RuntimeLeapService
+from leapflow.monitor import Finding, MonitorManager, ProducerRegistry, Severity, WatchSpec
+from leapflow.monitor.types import ProducerContext
+from leapflow.storage.connection import LocalConnectionHolder
+
+
+class _DemoProducer:
+ @property
+ def domain(self) -> str:
+ return "demo"
+
+ async def observe(self, ctx: ProducerContext) -> list[Finding]:
+ return [Finding(watch_id="", domain="demo", title="hit", severity=Severity.ALERT, dedup_key="d1")]
+
+
+def _manager(tmp_path: Path, emit=None) -> MonitorManager:
+ producers = ProducerRegistry()
+ producers.register(_DemoProducer())
+ return MonitorManager(
+ holder=LocalConnectionHolder(tmp_path / "leap.duckdb"),
+ producers=producers,
+ emit=emit,
+ )
+
+
+# -- Service-level watch RPCs (transport for the monitor subsystem) -----------
+
+
+async def test_service_watch_rpc_roundtrip(tmp_path: Path) -> None:
+ emitted: list[tuple[str, dict]] = []
+ service = RuntimeLeapService(SimpleNamespace())
+ service._monitors = _manager(tmp_path, emit=lambda et, p: emitted.append((et, p)))
+
+ view = await service.watch_arm({"name": "D", "domain": "demo", "sensitivity": "notable"})
+ watch_id = view["watch_id"]
+ assert service.has_active_watches() is True
+
+ assert len(await service.watch_list()) == 1
+ assert (await service.watch_get(watch_id))["domain"] == "demo"
+ assert (await service.watch_get(watch_id))["client_coupled"] is False
+
+ result = await service.watch_refresh(watch_id)
+ assert result["ok"] is True and result["findings"] == 1
+ assert any(et == "monitor.finding" for et, _ in emitted)
+ assert len(await service.watch_findings(watch_id)) == 1
+
+ assert (await service.watch_pause(watch_id))["state"] == "suspended"
+ assert service.has_active_watches() is False
+ assert (await service.watch_resume(watch_id))["state"] == "armed"
+ assert (await service.watch_mute(watch_id, muted=True))["muted"] is True
+ assert (await service.watch_stop(watch_id))["state"] == "done"
+
+
+async def test_service_watch_summary_separates_keepalive_watches(tmp_path: Path) -> None:
+ service = RuntimeLeapService(SimpleNamespace())
+ service._monitors = _manager(tmp_path)
+
+ await service.watch_arm({"name": "Session", "domain": "demo", "client_coupled": True})
+ await service.watch_arm({"name": "Market", "domain": "demo"})
+ summary = service._watch_runtime_summary()
+
+ assert summary["active"] == 2
+ assert summary["client_coupled_active"] == 1
+ assert summary["standalone_active"] == 1
+ assert summary["active_samples"][0]["state"] == "armed"
+
+
+async def test_service_watch_unavailable_is_graceful() -> None:
+ service = RuntimeLeapService(SimpleNamespace())
+ # No monitor runtime attached (scheduler disabled).
+ assert service.has_active_watches() is False
+ assert await service.watch_list() == []
+ assert await service.watch_findings() == []
+
+
+async def test_schedule_watch_once_runs_in_background(tmp_path: Path) -> None:
+ """schedule_watch_once must not block the caller yet still produce findings."""
+ manager = _manager(tmp_path)
+ view = await manager.arm_watch(WatchSpec(name="D", domain="demo", sensitivity="notable"))
+
+ manager.schedule_watch_once(view.watch_id, force=True)
+
+ tasks = list(manager._background_tasks)
+ assert len(tasks) == 1 # scheduled, not awaited inline
+ assert manager.finding_store.count(watch_id=view.watch_id) == 0 # not run yet
+ await asyncio.gather(*tasks)
+ assert manager.finding_store.count(watch_id=view.watch_id) >= 1
+
+
+# -- /board command: one target (current session), template = view lens -------
+
+
+async def test_board_bare_opens_session_with_default_template(tmp_path: Path) -> None:
+ """Bare /board analyzes the current session with the generic default and
+ schedules the (LLM-backed) analysis in the background."""
+ manager = _manager(tmp_path)
+ ctx = SimpleNamespace(monitors=manager, settings=None, engine=None)
+
+ payload = await command_execute(ctx, "board", "")
+
+ assert payload["view"] == "dashboard" and payload["mode"] == "open"
+ assert payload["template"] == "generic"
+ assert payload.get("watch_id") # session watch armed on demand
+ assert len(manager._background_tasks) == 1 # analysis deferred, RPC returns fast
+ await asyncio.gather(*list(manager._background_tasks))
+
+
+async def test_board_named_template_opens_session_with_lens(tmp_path: Path) -> None:
+ manager = _manager(tmp_path)
+ ctx = SimpleNamespace(monitors=manager, settings=None, engine=None)
+
+ payload = await command_execute(ctx, "board", "finance")
+
+ assert payload["mode"] == "open"
+ assert payload["template"] == "finance"
+ assert "note" not in payload # finance is a real builtin lens
+ await asyncio.gather(*list(manager._background_tasks))
+
+
+async def test_board_unknown_command_is_rejected(tmp_path: Path) -> None:
+ manager = _manager(tmp_path)
+ ctx = SimpleNamespace(monitors=manager, settings=None, engine=None)
+
+ payload = await command_execute(ctx, "board", "nope")
+
+ assert payload["ok"] is False
+ assert "nope" in payload["message"] and "Unknown board command" in payload["message"]
+ # A rejected command must not arm a session watch (no silent side effect).
+ assert not manager._background_tasks
+
+
+async def test_board_status_and_templates_are_discoverable(tmp_path: Path) -> None:
+ ctx = SimpleNamespace(monitors=_manager(tmp_path), settings=None, engine=None)
+
+ status_payload = await command_execute(ctx, "board status", "")
+ assert status_payload["mode"] == "status"
+ assert "generic" in status_payload["templates"]
+ assert status_payload["default"] == "generic"
+ assert isinstance(status_payload["watches"], list)
+ assert isinstance(status_payload["findings"], list)
+
+ templates = await command_execute(ctx, "board templates", "")
+ assert templates["mode"] == "templates"
+ names = {t["name"] for t in templates["templates"]}
+ assert {"generic", "finance", "sentiment", "research"}.issubset(names)
+
+
+async def test_board_refresh_reanalyzes_session(tmp_path: Path) -> None:
+ manager = _manager(tmp_path)
+ ctx = SimpleNamespace(monitors=manager, settings=None, engine=None)
+
+ refreshed = await command_execute(ctx, "board refresh", "")
+ assert refreshed["mode"] == "control"
+ assert refreshed["action"] == "refresh" and refreshed["ok"] is True
+ await asyncio.gather(*list(manager._background_tasks))
+
+
+async def test_board_control_requires_monitor_runtime() -> None:
+ ctx = SimpleNamespace(monitors=None, settings=None, engine=None)
+ disabled = await command_execute(ctx, "board refresh", "")
+ assert disabled["ok"] is False
+ assert "unavailable" in disabled["message"].lower()
+
+
+async def test_board_stop_defaults_to_current_session(tmp_path: Path) -> None:
+ manager = _manager(tmp_path)
+ ctx = SimpleNamespace(monitors=manager, settings=None, engine=None)
+ session = await manager.arm_watch(WatchSpec(name="Session", domain="session", client_coupled=True))
+
+ stopped = await command_execute(ctx, "board stop", "")
+
+ assert stopped["ok"] is True and stopped["action"] == "stop"
+ assert stopped["watch"]["watch_id"] == session.watch_id
+ assert {w.watch_id: w.state for w in manager.list_watches()}[session.watch_id] == "done"
+
+
+async def test_board_stop_by_id_targets_that_watch(tmp_path: Path) -> None:
+ manager = _manager(tmp_path)
+ ctx = SimpleNamespace(monitors=manager, settings=None, engine=None)
+ view = await manager.arm_watch(WatchSpec(name="Market", domain="demo"))
+
+ # Prefix id, exactly as /board status renders it.
+ stopped = await command_execute(ctx, "board stop", view.watch_id[:8])
+
+ assert stopped["ok"] is True and stopped["action"] == "stop"
+ assert stopped["watch"]["watch_id"] == view.watch_id
+ assert {w.watch_id: w.state for w in manager.list_watches()}[view.watch_id] == "done"
+
+
+async def test_board_stop_unknown_id_is_rejected(tmp_path: Path) -> None:
+ manager = _manager(tmp_path)
+ ctx = SimpleNamespace(monitors=manager, settings=None, engine=None)
+
+ result = await command_execute(ctx, "board stop", "does-not-exist")
+
+ assert result["ok"] is False and "does-not-exist" in result["message"]
+
+
+async def test_board_stop_without_session_reports_nothing_to_stop(tmp_path: Path) -> None:
+ ctx = SimpleNamespace(monitors=_manager(tmp_path), settings=None, engine=None)
+
+ result = await command_execute(ctx, "board stop", "")
+
+ assert result["ok"] is False and "No active session board" in result["message"]
+
+
+async def test_board_opens_without_monitor_runtime() -> None:
+ """Bare /board and /board open the web board even with no local
+ monitor runtime (the in-process fallback), never leaking back to chat."""
+ ctx = SimpleNamespace(monitors=None, settings=None, engine=None)
+ for args in ("", "finance"):
+ payload = await command_execute(ctx, "board", args)
+ assert payload["ok"] is True, args
+ assert payload["view"] == "dashboard"
+ assert payload["mode"] == "open"
+
+
+def test_build_view_url_carries_template() -> None:
+ from leapflow.dashboard import launcher
+
+ assert (
+ launcher.build_view_url("127.0.0.1", 8765, "tok", template="finance")
+ == "http://127.0.0.1:8765/?token=tok&template=finance"
+ )
+ # Default view needs no template query param.
+ assert launcher.build_view_url("127.0.0.1", 8765, "tok") == "http://127.0.0.1:8765/?token=tok"
+
+
+def _templates_ctx(templates_dir: Path) -> SimpleNamespace:
+ layout = SimpleNamespace(dashboard=SimpleNamespace(templates_dir=templates_dir))
+ return SimpleNamespace(monitors=None, engine=None,
+ settings=SimpleNamespace(profile_layout=layout))
+
+
+_VALID_TEMPLATE = (
+ "template: crypto\n"
+ "title: '{{ title }}'\n"
+ "layout:\n"
+ " - type: Page\n"
+ " props:\n"
+ " title: '{{ title }}'\n"
+ " children:\n"
+ " - type: StoryPanel\n"
+ " props:\n"
+ " title: Narrative\n"
+ " text: '{{ analysis.story }}'\n"
+)
+
+
+async def test_board_templates_add_list_remove_flow(tmp_path: Path) -> None:
+ """A user points at any local YAML; it validates, installs into the profile
+ templates dir, becomes discoverable, and is removable."""
+ tpl_dir = tmp_path / "tpl"
+ ctx = _templates_ctx(tpl_dir)
+ src = tmp_path / "crypto.yaml"
+ src.write_text(_VALID_TEMPLATE, encoding="utf-8")
+
+ added = await command_execute(ctx, "board templates", f"add {src}")
+ assert added["ok"] is True and added["template"] == "crypto"
+ assert (tpl_dir / "crypto.yaml").exists() # installed into the managed dir
+
+ listed = await command_execute(ctx, "board templates", "")
+ entry = next(t for t in listed["templates"] if t["name"] == "crypto")
+ assert entry["source"] == "user"
+
+ # The freshly added lens is immediately openable.
+ opened = await command_execute(ctx, "board", "crypto")
+ assert opened["mode"] == "open" and opened["template"] == "crypto" and "note" not in opened
+
+ removed = await command_execute(ctx, "board templates", "remove crypto")
+ assert removed["ok"] is True
+ assert not (tpl_dir / "crypto.yaml").exists()
+
+
+async def test_board_templates_add_rejects_invalid_and_reserved(tmp_path: Path) -> None:
+ ctx = _templates_ctx(tmp_path / "tpl")
+
+ bad = tmp_path / "bad.yaml"
+ bad.write_text("just a string, not a template mapping", encoding="utf-8")
+ invalid = await command_execute(ctx, "board templates", f"add {bad}")
+ assert invalid["ok"] is False
+
+ reserved = await command_execute(ctx, "board templates", f"add {bad} --name refresh")
+ assert reserved["ok"] is False and "reserved" in reserved["message"].lower()
+
+
+async def test_board_templates_cannot_remove_builtin(tmp_path: Path) -> None:
+ ctx = _templates_ctx(tmp_path / "tpl")
+ result = await command_execute(ctx, "board templates", "remove generic")
+ assert result["ok"] is False and "builtin" in result["message"].lower()
+
+
+class _CaptureConsole:
+ def __init__(self) -> None:
+ self.lines: list[str] = []
+ self.printed: list[object] = []
+
+ def system(self, message: str) -> None:
+ self.lines.append(str(message))
+
+ def success(self, message: str) -> None:
+ self.lines.append(str(message))
+
+ def warning(self, message: str) -> None:
+ self.lines.append(str(message))
+
+ def print(self, obj: object) -> None:
+ self.printed.append(obj)
+
+
+def test_render_dashboard_payload_covers_new_modes() -> None:
+ """The TUI renderer must produce readable output for every new board mode
+ (regression guard: payload-only tests missed the stale renderer)."""
+ from leapflow.cli.commands.slash_handlers import render_command_payload
+
+ status = _CaptureConsole()
+ render_command_payload(status, {
+ "ok": True, "view": "dashboard", "mode": "status",
+ "templates": ["generic", "finance"], "default": "generic",
+ "watches": [{"watch_id": "abc12345", "name": "Session", "domain": "session",
+ "state": "armed", "run_count": 2, "finding_count": 1, "last_run_at": 0}],
+ "findings": [{"ts": 0, "severity": "notable", "title": "Spike detected", "summary": "x"}],
+ })
+ assert any("generic" in line and "finance" in line for line in status.lines) # templates line
+ assert len(status.printed) == 1 # watches table
+ headers = [str(c.header) for c in status.printed[0].columns]
+ assert "muted" not in headers # muted column removed
+ assert headers[-1] == "url" # copyable board url added after 'last run'
+ assert any("Spike detected" in line for line in status.lines) # findings detail
+
+ control = _CaptureConsole()
+ render_command_payload(control, {
+ "ok": True, "view": "dashboard", "mode": "control", "action": "refresh",
+ "message": "Re-analyzing the current session; the board updates when it completes.",
+ })
+ assert any("Re-analyzing" in line for line in control.lines)
+
+ listing = _CaptureConsole()
+ render_command_payload(listing, {
+ "ok": True, "view": "dashboard", "mode": "templates",
+ "templates": [{"name": "generic", "source": "builtin", "title": "Session analysis"}],
+ })
+ assert len(listing.printed) == 1 # rendered as a table
+
+ added = _CaptureConsole()
+ render_command_payload(added, {
+ "ok": True, "view": "dashboard", "mode": "templates", "action": "add",
+ "message": "Registered template 'crypto'.",
+ })
+ assert any("crypto" in line for line in added.lines)
+
+ failed = _CaptureConsole()
+ render_command_payload(failed, {"ok": False, "message": "Could not add template: bad"})
+ assert any("Could not add" in line for line in failed.lines)
diff --git a/tests/test_monitor_subsystem.py b/tests/test_monitor_subsystem.py
new file mode 100644
index 0000000..532b3a7
--- /dev/null
+++ b/tests/test_monitor_subsystem.py
@@ -0,0 +1,244 @@
+"""Hermetic tests for the domain-neutral monitor subsystem (Watch -> Finding).
+
+No network, no LLM: uses a temporary DuckDB and a fake in-process producer.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from leapflow.monitor import (
+ EVENT_FINDING,
+ Evidence,
+ Finding,
+ MonitorManager,
+ ProducerRegistry,
+ Severity,
+ SuggestedAction,
+ WatchSpec,
+)
+from leapflow.monitor.finding_store import FindingStore
+from leapflow.monitor.types import ProducerContext
+from leapflow.storage.connection import LocalConnectionHolder
+
+
+class _FakeProducer:
+ """Deterministic producer returning a fixed list of findings per cycle."""
+
+ def __init__(self, domain: str, findings: list[Finding]) -> None:
+ self._domain = domain
+ self._findings = findings
+ self.calls = 0
+
+ @property
+ def domain(self) -> str:
+ return self._domain
+
+ async def observe(self, ctx: ProducerContext) -> list[Finding]:
+ self.calls += 1
+ return list(self._findings)
+
+
+def _holder(tmp_path: Path) -> LocalConnectionHolder:
+ return LocalConnectionHolder(tmp_path / "leap.duckdb")
+
+
+# ── Contract serialization ────────────────────────────────────────────────
+
+
+def test_finding_roundtrip_preserves_fields() -> None:
+ finding = Finding(
+ watch_id="w1",
+ domain="finance",
+ title="AAPL spike",
+ summary="Unusual volume",
+ severity=Severity.ALERT,
+ score=0.87,
+ evidence=(Evidence(kind="link", label="chart", url="http://x/y"),),
+ tags=("volume", "equity"),
+ suggested_actions=(SuggestedAction(name="drilldown", label="Open", kind="nav"),),
+ payload={"ohlc": [[1, 2, 3, 4]]},
+ dedup_key="aapl-2026-07-16",
+ )
+ restored = Finding.from_dict(finding.to_dict())
+ assert restored.watch_id == "w1"
+ assert restored.domain == "finance"
+ assert restored.severity is Severity.ALERT
+ assert restored.tags == ("volume", "equity")
+ assert restored.evidence[0].url == "http://x/y"
+ assert restored.suggested_actions[0].kind == "nav"
+ assert restored.payload == {"ohlc": [[1, 2, 3, 4]]}
+ assert restored.dedup_key == "aapl-2026-07-16"
+
+
+def test_watchspec_params_roundtrip() -> None:
+ spec = WatchSpec(
+ name="ArXiv NLP",
+ domain="research",
+ trigger_expr="30m",
+ source={"feed": "arxiv:cs.CL"},
+ lens={"keywords": ["agent"]},
+ sensitivity="alert",
+ watch_id="wid",
+ )
+ restored = WatchSpec.from_params(spec.to_task_parameters())
+ assert restored.name == "ArXiv NLP"
+ assert restored.domain == "research"
+ assert restored.trigger_expr == "30m"
+ assert restored.source == {"feed": "arxiv:cs.CL"}
+ assert restored.push_threshold() is Severity.ALERT
+
+
+def test_severity_coerce_and_rank() -> None:
+ assert Severity.coerce("alert") is Severity.ALERT
+ assert Severity.coerce("bogus") is Severity.INFO
+ assert Severity.ALERT.rank > Severity.NOTABLE.rank > Severity.INFO.rank
+
+
+# ── FindingStore ────────────────────────────────────────────────────────────
+
+
+def test_finding_store_crud_dedup_and_filters(tmp_path: Path) -> None:
+ store = FindingStore(_holder(tmp_path))
+ store.save(Finding(watch_id="w1", domain="d", title="a", severity=Severity.INFO, ts=100.0, dedup_key="k1"))
+ store.save(Finding(watch_id="w1", domain="d", title="b", severity=Severity.ALERT, ts=200.0, dedup_key="k2"))
+ store.save(Finding(watch_id="w2", domain="d", title="c", severity=Severity.NOTABLE, ts=150.0))
+
+ assert store.exists_dedup("w1", "k1") is True
+ assert store.exists_dedup("w1", "missing") is False
+
+ all_w1 = store.list(watch_id="w1")
+ assert [f.title for f in all_w1] == ["b", "a"] # newest-first by ts
+
+ alerts = store.list(min_severity=Severity.ALERT)
+ assert [f.title for f in alerts] == ["b"]
+
+ since = store.list(since=150.0)
+ assert {f.title for f in since} == {"b", "c"}
+
+ assert store.count() == 3
+ assert store.count(watch_id="w1") == 2
+ assert store.count(min_severity=Severity.NOTABLE) == 2
+
+ store.delete_for_watch("w1")
+ assert store.count(watch_id="w1") == 0
+
+
+# ── MonitorManager lifecycle ────────────────────────────────────────────────
+
+
+async def test_manager_arm_list_and_state_transitions(tmp_path: Path) -> None:
+ manager = MonitorManager(holder=_holder(tmp_path))
+ view = await manager.arm_watch(WatchSpec(name="Market", domain="finance", trigger_expr="5m"))
+ assert view.domain == "finance"
+ assert view.state == "armed"
+ assert view.client_coupled is False
+ assert view.to_dict()["client_coupled"] is False
+
+ assert [v.watch_id for v in manager.list_watches()] == [view.watch_id]
+ assert manager.has_active_watches() is True
+
+ assert manager.pause_watch(view.watch_id).state == "suspended"
+ assert manager.has_active_watches() is False
+ assert manager.resume_watch(view.watch_id).state == "armed"
+
+ muted = manager.set_muted(view.watch_id, True)
+ assert muted.muted is True
+
+ stopped = manager.stop_watch(view.watch_id)
+ assert stopped.state == "done"
+ assert manager.has_active_watches() is False
+ assert manager.get_watch("nonexistent") is None
+
+
+async def test_manager_run_once_persists_and_gates_push(tmp_path: Path) -> None:
+ emitted: list[tuple[str, dict]] = []
+ producers = ProducerRegistry()
+ producers.register(_FakeProducer("finance", [
+ Finding(watch_id="", domain="finance", title="quiet", severity=Severity.INFO, dedup_key="i"),
+ Finding(watch_id="", domain="finance", title="move", severity=Severity.NOTABLE, dedup_key="n"),
+ Finding(watch_id="", domain="finance", title="spike", severity=Severity.ALERT, dedup_key="a"),
+ ]))
+ manager = MonitorManager(
+ holder=_holder(tmp_path),
+ producers=producers,
+ emit=lambda et, payload: emitted.append((et, payload)),
+ )
+ view = await manager.arm_watch(WatchSpec(name="M", domain="finance", sensitivity="notable"))
+
+ result = await manager.run_watch_once(view.watch_id)
+ assert result["ok"] is True
+ assert result["findings"] == 3 # all persisted
+ finding_events = [p for et, p in emitted if et == EVENT_FINDING]
+ assert {p["title"] for p in finding_events} == {"move", "spike"} # info gated out
+
+ # Second cycle: dedup keys already present -> nothing new persisted/pushed.
+ before = len(emitted)
+ result2 = await manager.run_watch_once(view.watch_id)
+ assert result2["findings"] == 0
+ assert len(emitted) == before
+
+ assert manager.finding_store.count(watch_id=view.watch_id) == 3
+
+
+async def test_manager_muted_watch_persists_without_push(tmp_path: Path) -> None:
+ emitted: list[tuple[str, dict]] = []
+ producers = ProducerRegistry()
+ producers.register(_FakeProducer("sentiment", [
+ Finding(watch_id="", domain="sentiment", title="surge", severity=Severity.ALERT, dedup_key="s"),
+ ]))
+ manager = MonitorManager(
+ holder=_holder(tmp_path),
+ producers=producers,
+ emit=lambda et, payload: emitted.append((et, payload)),
+ )
+ view = await manager.arm_watch(WatchSpec(name="S", domain="sentiment"))
+ manager.set_muted(view.watch_id, True)
+ emitted.clear()
+
+ result = await manager.run_watch_once(view.watch_id)
+ assert result["findings"] == 1
+ assert [et for et, _ in emitted if et == EVENT_FINDING] == [] # muted -> no push
+
+
+async def test_manager_unknown_domain_is_graceful(tmp_path: Path) -> None:
+ manager = MonitorManager(holder=_holder(tmp_path))
+ view = await manager.arm_watch(WatchSpec(name="X", domain="unregistered"))
+ result = await manager.run_watch_once(view.watch_id)
+ assert result["ok"] is False
+ assert "no producer" in result["error"]
+
+
+async def test_has_active_watches_excludes_client_coupled(tmp_path: Path) -> None:
+ manager = MonitorManager(holder=_holder(tmp_path))
+ coupled = await manager.arm_watch(WatchSpec(name="S", domain="session", client_coupled=True))
+ assert coupled.client_coupled is True
+ assert manager.has_active_watches() is False # client-coupled must not keep leapd alive
+ standalone = await manager.arm_watch(WatchSpec(name="F", domain="finance"))
+ assert manager.has_active_watches() is True
+ manager._task_store.update_state(standalone.watch_id, "executing")
+ assert manager.has_active_watches() is True
+
+
+async def test_sweep_client_coupled_watches_removes_only_session_watches(tmp_path: Path) -> None:
+ manager = MonitorManager(holder=_holder(tmp_path))
+ session = await manager.arm_watch(WatchSpec(name="S", domain="session", client_coupled=True))
+ standalone = await manager.arm_watch(WatchSpec(name="F", domain="finance"))
+ manager.finding_store.save(
+ Finding(watch_id=session.watch_id, domain="session", title="stale", severity=Severity.INFO)
+ )
+
+ removed = manager.sweep_client_coupled_watches()
+
+ assert removed == 1
+ remaining = [v.watch_id for v in manager.list_watches()]
+ assert remaining == [standalone.watch_id] # standalone watch is durable
+ assert manager.get_watch(session.watch_id) is None
+ assert manager.finding_store.count(watch_id=session.watch_id) == 0
+
+
+async def test_sweep_client_coupled_watches_is_noop_without_session_watches(tmp_path: Path) -> None:
+ manager = MonitorManager(holder=_holder(tmp_path))
+ await manager.arm_watch(WatchSpec(name="F", domain="finance"))
+ assert manager.sweep_client_coupled_watches() == 0
+ assert len(manager.list_watches()) == 1
diff --git a/tests/test_series_extractor.py b/tests/test_series_extractor.py
new file mode 100644
index 0000000..7ec1dea
--- /dev/null
+++ b/tests/test_series_extractor.py
@@ -0,0 +1,91 @@
+"""Hermetic tests for the anti-hallucination chart extractor.
+
+Pure text parsing: no network, no code execution, no invented numbers.
+"""
+
+from __future__ import annotations
+
+from leapflow.dashboard.templates import render_template
+from leapflow.monitor.series_extractor import _num, extract_charts
+
+
+def _artifact(text: str) -> dict:
+ return {"status": "included", "name": "data", "content_excerpt": text}
+
+
+def test_num_parses_floats_including_trailing_dot() -> None:
+ # Trailing-dot floats (e.g. '1.') are valid and must parse; inf/nan/garbage reject.
+ assert _num("1.") == 1.0
+ assert _num("1.5") == 1.5
+ assert _num(".5") == 0.5
+ assert _num("-3") == -3.0
+ assert _num("1e3") == 1000.0
+ assert _num("1,234.5") == 1234.5
+ assert _num("abc") is None
+ assert _num("") is None
+ assert _num("inf") is None
+
+
+def test_json_number_array_becomes_a_series() -> None:
+ out = extract_charts(artifacts=[_artifact("[10, 12, 11, 15, 14]")])
+ points = out["series"][0]["points"]
+ assert len(points) == 5
+ assert points[0] == {"x": 0, "y": 10.0}
+
+
+def test_ohlc_rows_become_candlesticks() -> None:
+ rows = (
+ '[{"date":"d1","open":1,"high":3,"low":0.5,"close":2},'
+ '{"date":"d2","open":2,"high":4,"low":1.5,"close":3}]'
+ )
+ out = extract_charts(artifacts=[_artifact(rows)])
+ bars = out["ohlc"][0]["bars"]
+ assert bars[0]["o"] == 1.0 and bars[0]["c"] == 2.0 and bars[0]["t"] == "d1"
+ assert "series" not in out # OHLC classified as candlesticks, not a line
+
+
+def test_markdown_table_of_labels_becomes_a_distribution() -> None:
+ md = "| name | value |\n| --- | --- |\n| A | 3 |\n| B | 5 |\n"
+ out = extract_charts(artifacts=[_artifact(md)])
+ assert out["distribution"][0]["items"] == [
+ {"label": "A", "value": 3.0},
+ {"label": "B", "value": 5.0},
+ ]
+
+
+def test_csv_time_and_close_becomes_a_series() -> None:
+ csv = "date,close\n2026-07-08,10.0\n2026-07-09,10.5\n2026-07-10,11.2\n"
+ out = extract_charts(artifacts=[_artifact(csv)])
+ points = out["series"][0]["points"]
+ assert points[0] == {"x": "2026-07-08", "y": 10.0}
+
+
+def test_prose_only_session_yields_no_charts() -> None:
+ # No structured numbers anywhere -> nothing is drawn (no fake line ever).
+ out = extract_charts(
+ messages=[{"role": "assistant", "content": "just prose without any structured data"}],
+ artifacts=[],
+ )
+ assert out == {}
+
+
+def test_model_intents_only_relabel_extracted_series() -> None:
+ # The label comes from the model; the numbers come strictly from the data.
+ out = extract_charts(artifacts=[_artifact("[1, 2, 3]")], intents=[{"label": "BABA close"}])
+ assert out["series"][0]["label"] == "BABA close"
+ assert [p["y"] for p in out["series"][0]["points"]] == [1.0, 2.0, 3.0]
+
+
+def test_when_conditional_omits_nodes_bound_to_empty_data() -> None:
+ template = {
+ "layout": [
+ {"type": "Card", "when": "charts.series", "props": {"title": "Series"}},
+ {"type": "Card", "props": {"title": "Always"}},
+ ]
+ }
+ empty = render_template(template, {"charts": {}})
+ present = render_template(template, {"charts": {"series": [{"points": [{"x": 0, "y": 1}]}]}})
+ empty_titles = [n["props"].get("title") for n in empty["root"]]
+ present_titles = [n["props"].get("title") for n in present["root"]]
+ assert empty_titles == ["Always"]
+ assert present_titles == ["Series", "Always"]
diff --git a/tests/test_session_analysis.py b/tests/test_session_analysis.py
new file mode 100644
index 0000000..96ecfd3
--- /dev/null
+++ b/tests/test_session_analysis.py
@@ -0,0 +1,229 @@
+"""Hermetic tests for the session-analysis dashboard (domain=session watch).
+
+Fakes the analysis services facade (no LLM); exercises producer gating,
+the manager path, the session.history RPC, and the session template render.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from types import SimpleNamespace
+
+from leapflow.dashboard import TemplateLibrary
+from leapflow.daemon.service import RuntimeLeapService
+from leapflow.monitor import MonitorManager, ProducerRegistry, SessionAnalysisProducer, WatchSpec
+from leapflow.monitor.types import ProducerContext
+from leapflow.storage.connection import LocalConnectionHolder
+from leapflow.storage.conversation_store import ConversationMessage
+
+
+class _FakeServices:
+ def __init__(self, history: dict, analysis: dict, salient: bool = False) -> None:
+ self._history = history
+ self._analysis = analysis
+ self._salient = salient
+ self.analyze_calls = 0
+
+ async def session_history(self) -> dict:
+ return dict(self._history)
+
+ async def analyze_session(self, messages, *, prior=None, artifacts=None) -> dict:
+ self.analyze_calls += 1
+ data = dict(self._analysis)
+ if artifacts is not None:
+ data["seen_artifacts"] = list(artifacts)
+ return data
+
+ async def should_refresh(self, messages) -> bool:
+ return self._salient
+
+
+def _ctx(spec: WatchSpec, services, *, now: float = 1000.0, force: bool = False) -> ProducerContext:
+ return ProducerContext(spec=spec, now=now, services=services, force=force)
+
+
+# ── Producer gating ─────────────────────────────────────────────────────────
+
+
+async def test_session_producer_first_run_analyzes() -> None:
+ prod = SessionAnalysisProducer()
+ svc = _FakeServices(
+ {"turn_count": 2, "token_count": 100, "messages": [{"role": "user", "content": "hi"}]},
+ {"story": "The arc", "insights": [{"title": "a"}]},
+ )
+ spec = WatchSpec(name="Session", watch_id="w", domain="session", params={"use_model_salience": False, "debounce_s": 0})
+ out = list(await prod.observe(_ctx(spec, svc)))
+ assert len(out) == 1
+ assert out[0].payload["story"] == "The arc"
+ assert out[0].payload["usage"]["turns"] == 2
+ assert svc.analyze_calls == 1
+
+ # No new turns since last analysis -> skip.
+ again = list(await prod.observe(_ctx(spec, svc, now=1001.0)))
+ assert again == []
+ assert svc.analyze_calls == 1
+
+
+async def test_session_producer_batch_threshold() -> None:
+ prod = SessionAnalysisProducer()
+ spec = WatchSpec(name="Session", watch_id="w", domain="session", params={"batch_turns": 6, "use_model_salience": False, "debounce_s": 0})
+ await prod.observe(_ctx(spec, _FakeServices({"turn_count": 2, "token_count": 10, "messages": [{"role": "u", "content": "x"}]}, {"story": "s"})))
+ svc = _FakeServices({"turn_count": 8, "token_count": 20, "messages": [{"role": "u", "content": "x"}]}, {"story": "s2"})
+ out = list(await prod.observe(_ctx(spec, svc, now=2000.0)))
+ assert len(out) == 1 and svc.analyze_calls == 1
+
+
+async def test_session_producer_salience_below_batch() -> None:
+ prod = SessionAnalysisProducer()
+ spec = WatchSpec(name="Session", watch_id="w2", domain="session", params={"batch_turns": 100, "batch_tokens": 10**9, "use_model_salience": True, "debounce_s": 0})
+ await prod.observe(_ctx(spec, _FakeServices({"turn_count": 1, "token_count": 5, "messages": [{"role": "u", "content": "x"}]}, {"story": "s"}, salient=True), now=1.0))
+ svc = _FakeServices({"turn_count": 2, "token_count": 8, "messages": [{"role": "u", "content": "y"}]}, {"story": "s"}, salient=True)
+ out = list(await prod.observe(_ctx(spec, svc, now=100.0)))
+ assert len(out) == 1 # salience forced a refresh below the batch threshold
+
+
+async def test_session_producer_force_without_new_turns() -> None:
+ prod = SessionAnalysisProducer()
+ spec = WatchSpec(name="Session", watch_id="w3", domain="session", params={"use_model_salience": False, "debounce_s": 999})
+ await prod.observe(_ctx(spec, _FakeServices({"turn_count": 2, "token_count": 10, "messages": [{"role": "u", "content": "x"}]}, {"story": "s"})))
+ svc = _FakeServices({"turn_count": 2, "token_count": 10, "messages": [{"role": "u", "content": "x"}]}, {"story": "forced"})
+ out = list(await prod.observe(_ctx(spec, svc, now=5.0, force=True)))
+ assert len(out) == 1 and out[0].payload["story"] == "forced"
+
+
+async def test_session_producer_artifact_change_refreshes_without_new_turns() -> None:
+ prod = SessionAnalysisProducer()
+ spec = WatchSpec(name="Session", watch_id="w-art", domain="session", params={"use_model_salience": False, "debounce_s": 0})
+ base_history = {"session_id": "s", "turn_count": 2, "token_count": 10, "messages": [{"role": "u", "content": "x"}]}
+ await prod.observe(_ctx(spec, _FakeServices({**base_history, "artifacts": []}, {"story": "initial"}), now=1.0))
+ artifact = {"path": "/workspace/report.md", "status": "included", "mtime": 2.0, "size": 12, "content_excerpt": "new report"}
+ svc = _FakeServices({**base_history, "artifacts": [artifact]}, {"story": "with artifact"})
+ out = list(await prod.observe(_ctx(spec, svc, now=2.0)))
+ assert len(out) == 1
+ assert out[0].payload["artifact_context"][0]["content_excerpt"] == "new report"
+ assert out[0].payload["observation_status"]["refresh_reason"] == "artifact_changed"
+ assert out[0].payload["observation_status"]["artifacts_included"] == 1
+
+
+async def test_session_dedup_key_is_session_scoped() -> None:
+ prod = SessionAnalysisProducer()
+ spec = WatchSpec(name="Session", watch_id="w", domain="session", params={"use_model_salience": False, "debounce_s": 0})
+ a = list(await prod.observe(_ctx(spec, _FakeServices(
+ {"session_id": "A", "turn_count": 2, "token_count": 10, "messages": [{"role": "u", "content": "x"}]},
+ {"story": "a"}), now=1.0)))
+ # A different session with the SAME turn_count must not be deduped away.
+ b = list(await prod.observe(_ctx(spec, _FakeServices(
+ {"session_id": "B", "turn_count": 2, "token_count": 10, "messages": [{"role": "u", "content": "y"}]},
+ {"story": "b"}), now=2.0, force=True)))
+ assert a[0].dedup_key == "w:A:2"
+ assert b[0].dedup_key == "w:B:2"
+ assert a[0].dedup_key != b[0].dedup_key
+
+
+# ── Full manager path ────────────────────────────────────────────────────────
+
+
+async def test_session_watch_via_manager(tmp_path: Path) -> None:
+ producers = ProducerRegistry()
+ producers.register(SessionAnalysisProducer())
+ svc = _FakeServices(
+ {"turn_count": 3, "token_count": 50, "messages": [{"role": "user", "content": "hi"}]},
+ {"story": "Story", "insights": [{"title": "i", "summary": "s", "severity": "notable"}]},
+ )
+ mgr = MonitorManager(
+ holder=LocalConnectionHolder(tmp_path / "leap.duckdb"),
+ producers=producers,
+ services=svc,
+ )
+ view = await mgr.arm_watch(WatchSpec(name="Session", domain="session", params={"use_model_salience": False, "debounce_s": 0}))
+ result = await mgr.run_watch_once(view.watch_id, force=True)
+ assert result["ok"] is True and result["findings"] == 1
+ findings = mgr.list_findings(watch_id=view.watch_id)
+ assert findings[0].payload["story"] == "Story"
+
+
+# ── session.history RPC ──────────────────────────────────────────────────────
+
+
+async def test_session_history_reads_engine_transcript() -> None:
+ class _WM:
+ def as_chat_messages(self):
+ return [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "yo"}]
+
+ engine = SimpleNamespace(_wm=_WM(), _current_session_id="sid", turn_count=2, context_token_count=42)
+ service = RuntimeLeapService(SimpleNamespace(workspace_root="."))
+ service._ctx = SimpleNamespace(engine=engine, _conversation_store=None, settings=SimpleNamespace(workspace_root="."))
+ history = await service.session_history()
+ assert history["turn_count"] == 2
+ assert history["token_count"] == 42
+ assert history["session_id"] == "sid"
+ assert [m["role"] for m in history["messages"]] == ["user", "assistant"]
+
+
+async def test_session_history_collects_file_write_artifact(tmp_path: Path) -> None:
+ artifact = tmp_path / "china_ecommerce_overseas_analysis.md"
+ artifact.write_text("# China ecommerce\n\nKey findings from the research.", encoding="utf-8")
+
+ class _Store:
+ def get_messages(self, session_id, *, limit=200):
+ assert session_id == "sid"
+ return [ConversationMessage(
+ message_id="m1",
+ session_id="sid",
+ role="tool",
+ content='{"ok": true, "path": "china_ecommerce_overseas_analysis.md", "bytes_written": 45}',
+ tool_name="file_write",
+ tool_call_id="t1",
+ )]
+
+ engine = SimpleNamespace(_wm=None, _current_session_id="sid", turn_count=1, context_token_count=10)
+ settings = SimpleNamespace(workspace_root=str(tmp_path))
+ service = RuntimeLeapService(settings)
+ service._ctx = SimpleNamespace(engine=engine, _conversation_store=_Store(), settings=settings)
+ history = await service.session_history()
+ assert history["artifacts"][0]["status"] == "included"
+ assert history["artifacts"][0]["name"] == "china_ecommerce_overseas_analysis.md"
+ assert "Key findings" in history["artifacts"][0]["content_excerpt"]
+
+
+async def test_session_history_empty_without_context() -> None:
+ service = RuntimeLeapService(SimpleNamespace())
+ history = await service.session_history()
+ assert history["turn_count"] == 0 and history["messages"] == []
+
+
+# ── session template render ──────────────────────────────────────────────────
+
+
+def test_session_template_renders_analysis() -> None:
+ lib = TemplateLibrary()
+ assert "generic" in lib.names()
+ spec = lib.render("generic", {"analysis": {
+ "story": "the arc",
+ "insights": [{"title": "t", "summary": "s", "severity": "notable"}],
+ "action_items": ["do x"],
+ "decisions": ["chose y"],
+ "open_questions": [],
+ "entities": ["Alice"],
+ "next_prompts": ["ask z"],
+ }, "observation": {
+ "refresh_reason": "artifact_changed",
+ "context_scope": "text_and_artifacts",
+ "artifacts_included": 1,
+ "artifact_count": 1,
+ }, "artifact_context": [{"name": "report.md", "status": "included", "reason": ""}]})
+ flat: list[dict] = []
+
+ def _walk(nodes: list) -> None:
+ for n in nodes:
+ flat.append(n)
+ _walk(n.get("children") or [])
+
+ _walk(spec["root"])
+ types = {n["type"] for n in flat}
+ assert "StoryPanel" in types
+ # The severity BarChart is the primary in-view visualization; the artifact
+ # Table appears only because artifact_context is non-empty here.
+ assert "BarChart" in types
+ assert "Table" in types
+ assert len([n for n in flat if n["type"] == "InsightCard"]) == 1
diff --git a/tests/test_slash_command_router.py b/tests/test_slash_command_router.py
index 94dab0c..24407a6 100644
--- a/tests/test_slash_command_router.py
+++ b/tests/test_slash_command_router.py
@@ -53,6 +53,24 @@ def test_command_router_client_local_commands() -> None:
assert inv is not None, f"parse failed for {cmd_text}"
assert inv.command.client_local is False, f"{cmd_text} should NOT be client_local"
+def test_board_commands_resolve_as_engine_routed() -> None:
+ """Board commands must resolve as non-client-local so the in-process REPL
+ routes them through command_execute instead of leaking to the LLM chat."""
+ router = CommandRouter("daemon")
+
+ for cmd_text in ("/board", "/board finance", "/board templates", "/board refresh", "/board status"):
+ inv = router.parse(cmd_text)
+ assert inv is not None, f"parse failed for {cmd_text}"
+ assert inv.command.name.startswith("board"), cmd_text
+ assert inv.command.client_local is False, f"{cmd_text} must be engine-routed"
+
+ # A bare template name resolves to the base `board` command (template = arg).
+ finance = router.parse("/board finance")
+ assert finance is not None and finance.command.name == "board" and finance.args == "finance"
+ # Reserved verbs resolve to their dedicated command.
+ assert router.parse("/board templates").command.name == "board templates"
+
+
def test_plural_skill_tool_task_commands_are_not_registered() -> None:
router = CommandRouter("daemon")
diff --git a/tests/test_tui_command_queue.py b/tests/test_tui_command_queue.py
index 86aabde..9aace0b 100644
--- a/tests/test_tui_command_queue.py
+++ b/tests/test_tui_command_queue.py
@@ -32,6 +32,11 @@ def __init__(self) -> None:
def command_card(self, command: TuiCommand) -> None:
self.cards.append(command)
+ def command_footer(self, command: TuiCommand) -> None:
+ # Terminal states render as a flat footer; record them alongside cards so
+ # lifecycle assertions track the command/status flow regardless of style.
+ self.cards.append(command)
+
def error(self, message: str) -> None:
self.errors.append(message)
@@ -296,6 +301,26 @@ def test_slash_completer_guides_config_subcommands_keys_and_values() -> None:
assert [completion.text for completion in values] == ["true", "false"]
+def test_slash_completer_guides_config_llm_and_secret_subcommands() -> None:
+ completer = SlashCommandCompleter((("config", "View or update runtime configuration"),))
+
+ llm_actions = list(completer.get_completions(Document("/config llm ", len("/config llm ")), None))
+ assert {"show", "set"} <= {completion.text for completion in llm_actions}
+
+ llm_flags = list(completer.get_completions(Document("/config llm set ", len("/config llm set ")), None))
+ flag_texts = {completion.text for completion in llm_flags}
+ assert {"--model", "--base-url", "--api-key"} <= flag_texts
+
+ llm_flag_prefix = list(completer.get_completions(Document("/config llm set --mo", len("/config llm set --mo")), None))
+ assert [completion.text for completion in llm_flag_prefix] == ["--model"]
+
+ secret_actions = list(completer.get_completions(Document("/config secret ", len("/config secret ")), None))
+ assert {"list", "set", "get", "delete"} <= {completion.text for completion in secret_actions}
+
+ secret_prefix = list(completer.get_completions(Document("/config secret de", len("/config secret de")), None))
+ assert [completion.text for completion in secret_prefix] == ["delete"]
+
+
def test_slash_completer_does_not_pollute_natural_language() -> None:
completer = SlashCommandCompleter((("help", "Show available commands"),))
@@ -460,6 +485,37 @@ def print(self, renderable) -> None:
assert "summarize the current project layout" in body
+def test_command_footer_renders_flat_leap_line(monkeypatch) -> None:
+ class CapturingConsole:
+ width = 100
+
+ def __init__(self) -> None:
+ self.rendered = []
+
+ def print(self, renderable) -> None:
+ self.rendered.append(renderable)
+
+ capture = CapturingConsole()
+ leap_console = LeapConsole(resolve_theme(_LIGHT, terminal_bg="#FFFFFF"))
+ monkeypatch.setattr(leap_console, "_console", capture)
+
+ done = TuiCommand.create(command_id=13, text="/board stop 71e2ae20").mark_running().mark_done()
+ leap_console.command_footer(done)
+
+ # Flat footer, not a boxed Panel; mirrors the agent response label.
+ assert len(capture.rendered) == 1
+ from rich.panel import Panel
+ assert not isinstance(capture.rendered[0], Panel)
+ plain = capture.rendered[0].plain
+ assert plain.startswith(" |-- LEAP #13 done")
+
+ # A never-run terminal state (e.g. skipped) omits the elapsed suffix.
+ capture.rendered.clear()
+ skipped = TuiCommand.create(command_id=14, text="/board status").mark_skipped("duplicate")
+ leap_console.command_footer(skipped)
+ assert capture.rendered[0].plain == " |-- LEAP #14 skipped"
+
+
def test_response_label_merges_done_command_status(monkeypatch) -> None:
class CapturingConsole:
width = 100
diff --git a/uv.lock b/uv.lock
index 4a6d295..12bd8b2 100644
--- a/uv.lock
+++ b/uv.lock
@@ -8,6 +8,146 @@ resolution-markers = [
"python_full_version < '3.14' and sys_platform != 'win32'",
]
+[[package]]
+name = "aiohappyeyeballs"
+version = "2.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038 },
+]
+
+[[package]]
+name = "aiohttp"
+version = "3.14.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiohappyeyeballs" },
+ { name = "aiosignal" },
+ { name = "attrs" },
+ { name = "frozenlist" },
+ { name = "multidict" },
+ { name = "propcache" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+ { name = "yarl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225 },
+ { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743 },
+ { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139 },
+ { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088 },
+ { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835 },
+ { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801 },
+ { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992 },
+ { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989 },
+ { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129 },
+ { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576 },
+ { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668 },
+ { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019 },
+ { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638 },
+ { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660 },
+ { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698 },
+ { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386 },
+ { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406 },
+ { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987 },
+ { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402 },
+ { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310 },
+ { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448 },
+ { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854 },
+ { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884 },
+ { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034 },
+ { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054 },
+ { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278 },
+ { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795 },
+ { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397 },
+ { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504 },
+ { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806 },
+ { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707 },
+ { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121 },
+ { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580 },
+ { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771 },
+ { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873 },
+ { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073 },
+ { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882 },
+ { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270 },
+ { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841 },
+ { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088 },
+ { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564 },
+ { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998 },
+ { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918 },
+ { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657 },
+ { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907 },
+ { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565 },
+ { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018 },
+ { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416 },
+ { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881 },
+ { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572 },
+ { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137 },
+ { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953 },
+ { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479 },
+ { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077 },
+ { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688 },
+ { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094 },
+ { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662 },
+ { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748 },
+ { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723 },
+ { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531 },
+ { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718 },
+ { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918 },
+ { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014 },
+ { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398 },
+ { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018 },
+ { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462 },
+ { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824 },
+ { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898 },
+ { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114 },
+ { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541 },
+ { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776 },
+ { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329 },
+ { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293 },
+ { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756 },
+ { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052 },
+ { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888 },
+ { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679 },
+ { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021 },
+ { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574 },
+ { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773 },
+ { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001 },
+ { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809 },
+ { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320 },
+ { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077 },
+ { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476 },
+ { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347 },
+ { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465 },
+ { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423 },
+ { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906 },
+ { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095 },
+ { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222 },
+ { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922 },
+ { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035 },
+ { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512 },
+ { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571 },
+ { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159 },
+ { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409 },
+ { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166 },
+ { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255 },
+ { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640 },
+]
+
+[[package]]
+name = "aiosignal"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "frozenlist" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490 },
+]
+
[[package]]
name = "annotated-types"
version = "0.7.0"
@@ -338,6 +478,111 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757 },
]
+[[package]]
+name = "frozenlist"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912 },
+ { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046 },
+ { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119 },
+ { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067 },
+ { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160 },
+ { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544 },
+ { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797 },
+ { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923 },
+ { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886 },
+ { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731 },
+ { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544 },
+ { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806 },
+ { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382 },
+ { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647 },
+ { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064 },
+ { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937 },
+ { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782 },
+ { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594 },
+ { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448 },
+ { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411 },
+ { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014 },
+ { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909 },
+ { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049 },
+ { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485 },
+ { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619 },
+ { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320 },
+ { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820 },
+ { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518 },
+ { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096 },
+ { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985 },
+ { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591 },
+ { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102 },
+ { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717 },
+ { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651 },
+ { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417 },
+ { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391 },
+ { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048 },
+ { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549 },
+ { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833 },
+ { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363 },
+ { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314 },
+ { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365 },
+ { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763 },
+ { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110 },
+ { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717 },
+ { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628 },
+ { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882 },
+ { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676 },
+ { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235 },
+ { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742 },
+ { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725 },
+ { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533 },
+ { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506 },
+ { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161 },
+ { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676 },
+ { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638 },
+ { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067 },
+ { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101 },
+ { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901 },
+ { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395 },
+ { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659 },
+ { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492 },
+ { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034 },
+ { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749 },
+ { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127 },
+ { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698 },
+ { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749 },
+ { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298 },
+ { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015 },
+ { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038 },
+ { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130 },
+ { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845 },
+ { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131 },
+ { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542 },
+ { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308 },
+ { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210 },
+ { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972 },
+ { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536 },
+ { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330 },
+ { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627 },
+ { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238 },
+ { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738 },
+ { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739 },
+ { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186 },
+ { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196 },
+ { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830 },
+ { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289 },
+ { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318 },
+ { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814 },
+ { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762 },
+ { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470 },
+ { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042 },
+ { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148 },
+ { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676 },
+ { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451 },
+ { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507 },
+ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409 },
+]
+
[[package]]
name = "gnureadline"
version = "8.3.3"
@@ -557,6 +802,9 @@ dependencies = [
]
[package.optional-dependencies]
+dashboard = [
+ { name = "aiohttp" },
+]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
@@ -568,6 +816,7 @@ hub = [
[package.metadata]
requires-dist = [
+ { name = "aiohttp", marker = "extra == 'dashboard'", specifier = ">=3.9" },
{ name = "cryptography", specifier = ">=42.0" },
{ name = "duckdb", specifier = ">=1.0.0" },
{ name = "gnureadline", marker = "sys_platform == 'darwin'", specifier = ">=8.0" },
@@ -584,7 +833,7 @@ requires-dist = [
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" },
{ name = "watchdog", specifier = ">=3.0" },
]
-provides-extras = ["dev", "hub"]
+provides-extras = ["dev", "hub", "dashboard"]
[[package]]
name = "markdown-it-py"
@@ -700,6 +949,123 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868 },
]
+[[package]]
+name = "multidict"
+version = "6.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626 },
+ { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706 },
+ { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356 },
+ { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355 },
+ { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433 },
+ { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376 },
+ { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365 },
+ { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747 },
+ { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293 },
+ { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962 },
+ { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360 },
+ { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940 },
+ { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502 },
+ { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065 },
+ { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870 },
+ { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302 },
+ { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981 },
+ { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159 },
+ { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893 },
+ { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456 },
+ { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872 },
+ { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018 },
+ { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883 },
+ { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413 },
+ { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404 },
+ { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456 },
+ { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322 },
+ { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955 },
+ { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254 },
+ { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059 },
+ { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588 },
+ { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642 },
+ { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377 },
+ { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887 },
+ { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053 },
+ { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307 },
+ { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174 },
+ { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116 },
+ { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524 },
+ { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368 },
+ { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952 },
+ { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317 },
+ { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132 },
+ { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140 },
+ { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277 },
+ { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291 },
+ { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156 },
+ { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742 },
+ { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221 },
+ { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664 },
+ { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490 },
+ { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695 },
+ { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884 },
+ { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122 },
+ { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175 },
+ { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460 },
+ { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930 },
+ { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582 },
+ { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031 },
+ { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596 },
+ { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492 },
+ { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899 },
+ { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970 },
+ { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060 },
+ { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888 },
+ { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554 },
+ { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341 },
+ { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391 },
+ { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422 },
+ { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770 },
+ { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109 },
+ { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573 },
+ { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190 },
+ { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486 },
+ { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219 },
+ { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132 },
+ { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420 },
+ { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510 },
+ { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094 },
+ { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786 },
+ { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483 },
+ { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403 },
+ { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315 },
+ { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528 },
+ { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784 },
+ { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980 },
+ { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602 },
+ { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930 },
+ { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074 },
+ { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471 },
+ { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401 },
+ { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143 },
+ { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507 },
+ { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358 },
+ { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884 },
+ { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878 },
+ { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542 },
+ { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403 },
+ { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889 },
+ { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982 },
+ { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415 },
+ { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337 },
+ { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788 },
+ { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842 },
+ { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237 },
+ { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008 },
+ { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542 },
+ { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719 },
+ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319 },
+]
+
[[package]]
name = "openai"
version = "2.36.0"
@@ -836,6 +1202,117 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431 },
]
+[[package]]
+name = "propcache"
+version = "0.5.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744 },
+ { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033 },
+ { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754 },
+ { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573 },
+ { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645 },
+ { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563 },
+ { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888 },
+ { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253 },
+ { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558 },
+ { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007 },
+ { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355 },
+ { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057 },
+ { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938 },
+ { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731 },
+ { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966 },
+ { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135 },
+ { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381 },
+ { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887 },
+ { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654 },
+ { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190 },
+ { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995 },
+ { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422 },
+ { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342 },
+ { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639 },
+ { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588 },
+ { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029 },
+ { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774 },
+ { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532 },
+ { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592 },
+ { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788 },
+ { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514 },
+ { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018 },
+ { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322 },
+ { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172 },
+ { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457 },
+ { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835 },
+ { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545 },
+ { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886 },
+ { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261 },
+ { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184 },
+ { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534 },
+ { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500 },
+ { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994 },
+ { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884 },
+ { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464 },
+ { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588 },
+ { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667 },
+ { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463 },
+ { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621 },
+ { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649 },
+ { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636 },
+ { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872 },
+ { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257 },
+ { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696 },
+ { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378 },
+ { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283 },
+ { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616 },
+ { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773 },
+ { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664 },
+ { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643 },
+ { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595 },
+ { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711 },
+ { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247 },
+ { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102 },
+ { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964 },
+ { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546 },
+ { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330 },
+ { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521 },
+ { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662 },
+ { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928 },
+ { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650 },
+ { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912 },
+ { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300 },
+ { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208 },
+ { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633 },
+ { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724 },
+ { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069 },
+ { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099 },
+ { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391 },
+ { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626 },
+ { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781 },
+ { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570 },
+ { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436 },
+ { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373 },
+ { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554 },
+ { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395 },
+ { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653 },
+ { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914 },
+ { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567 },
+ { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542 },
+ { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845 },
+ { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985 },
+ { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999 },
+ { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779 },
+ { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796 },
+ { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023 },
+ { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448 },
+ { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329 },
+ { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172 },
+ { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813 },
+ { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764 },
+ { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140 },
+ { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036 },
+]
+
[[package]]
name = "pycparser"
version = "3.0"
@@ -1438,3 +1915,102 @@ sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288be
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166 },
]
+
+[[package]]
+name = "yarl"
+version = "1.24.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "multidict" },
+ { name = "propcache" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c5/c5/1ce244152ff2839645e7cae92f90e7bafcb2c52bea7ff586ac714f14f5df/yarl-1.24.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1", size = 128971 },
+ { url = "https://files.pythonhosted.org/packages/87/5a/00f36967203ed89cb3acd2c8ed526cc3fed9418eb70ce128160a911c8499/yarl-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c", size = 91507 },
+ { url = "https://files.pythonhosted.org/packages/31/d0/1fb0c1cd27288f39f6974da4318c32768d72c9890984541fdf1e2e32a51d/yarl-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d", size = 91343 },
+ { url = "https://files.pythonhosted.org/packages/03/ce/d4a646508bed2f8dec6435b40166fe9308dd191262033d3f307b2bbcaecd/yarl-1.24.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a6377060e7927187a42b7eb202090cbe2b34933a4eeaf90e3bd9e33432e5cae", size = 105704 },
+ { url = "https://files.pythonhosted.org/packages/4b/07/b3278e82d8bc41485bcf6d856cd0433262593de615b1d3dc43bd3f5bead4/yarl-1.24.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17076578bce0049a5ce57d14ad1bded391b68a3b213e9b81b0097b090244999a", size = 97281 },
+ { url = "https://files.pythonhosted.org/packages/17/5b/4cee6e7c92e487bebe7afc797da0aa54a248ab4e776a68fe369ec29665a5/yarl-1.24.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:50713f1d4d6be6375bb178bb43d140ee1acb8abe589cd723320b7925a275be1e", size = 114020 },
+ { url = "https://files.pythonhosted.org/packages/5c/82/111076571545a7d4f9cca3fbd5c6f40615af58642be09f12328f48022468/yarl-1.24.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:34263e2fa8fb5bb63a0d97706cda38edbad62fddb58c7f12d6acbc092812aa50", size = 111450 },
+ { url = "https://files.pythonhosted.org/packages/b6/ec/08f671f69a444d704aeecebf92af659b67b97a869942411d0a578b08c334/yarl-1.24.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49016d82f032b1bd1e10b01078a7d29ae71bf468eeae0ea22df8bab691e60003", size = 106384 },
+ { url = "https://files.pythonhosted.org/packages/e5/86/ce41e7a7a199340b2330d52b60f25c4074b6636dd0e60b1a80d31a9db042/yarl-1.24.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f6d2c216318f8f32038ca3f72501ba08536f0fd18a36e858836b121b2deed9f", size = 106153 },
+ { url = "https://files.pythonhosted.org/packages/c4/5d/31be8a729531ab3e55ac3e7e5c800be8c89ea98947f418b2f6ea259fb6ee/yarl-1.24.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08d3a33218e0c64393e7610284e770409a9c31c429b078bcb24096ed0a783b8f", size = 105322 },
+ { url = "https://files.pythonhosted.org/packages/47/9b/b57afb22b386ae87ac9940f09878b98d8c333f89113e6fc96fcf4ca9eb64/yarl-1.24.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5d699376c4ca3cba49bbfae3a05b5b70ded572937171ce1e0b8d87118e2ba294", size = 99057 },
+ { url = "https://files.pythonhosted.org/packages/a3/4f/06348c27c8389256c313e8a57d796808fc0264c915dd5e7cfd3c0e314dc7/yarl-1.24.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a1cab588b4fa14bea2e55ebea27478adfb05372f47573738e1acc4a36c0b05d2", size = 113502 },
+ { url = "https://files.pythonhosted.org/packages/5f/1c/284f307b298e4a17b7943b07d9d7ecc4151537f8d137ba51f3bb6c31ca20/yarl-1.24.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ec87ccc31bd21db7ad009d8572c127c1000f268517618a4cc09adba3c2a7f21c", size = 105253 },
+ { url = "https://files.pythonhosted.org/packages/c8/bf/0de123bec8619e45c80cbded9085f61b5b4a9eddb8abe6d25d28ee1ec866/yarl-1.24.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d1dd47a22843b212baa8d74f37796815d43bd046b42a0f41e9da433386c3136b", size = 111345 },
+ { url = "https://files.pythonhosted.org/packages/90/af/0248eb065e51129d2a9b2436cd1b5c772c19a6b04e5b6a186955671e3319/yarl-1.24.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b54b9c67c2b06bd7b9a77253d242124b9c95d2c02def5a1144001ee547dd9d5", size = 106558 },
+ { url = "https://files.pythonhosted.org/packages/21/3c/f960d7a65ef97d8ba9b424fb5128796a4bc710fc6df2ddbbd7dfdc3bbd20/yarl-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:f8fdbcff8b2c7c9284e60c196f693588598ddcee31e11c18e14949ce44519d45", size = 92808 },
+ { url = "https://files.pythonhosted.org/packages/03/1a/49fb03750e4de4d2284cd5b885a383133c34eef45bd59631b2bb8b7e81e8/yarl-1.24.2-cp311-cp311-win_arm64.whl", hash = "sha256:b32c37a7a337e90822c45797bf3d79d60875cfcccd3ecc80e9f453d87026c122", size = 87610 },
+ { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957 },
+ { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164 },
+ { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688 },
+ { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902 },
+ { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931 },
+ { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030 },
+ { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392 },
+ { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612 },
+ { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487 },
+ { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333 },
+ { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025 },
+ { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507 },
+ { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719 },
+ { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438 },
+ { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719 },
+ { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901 },
+ { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229 },
+ { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978 },
+ { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733 },
+ { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113 },
+ { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899 },
+ { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862 },
+ { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060 },
+ { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613 },
+ { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012 },
+ { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887 },
+ { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620 },
+ { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599 },
+ { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604 },
+ { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161 },
+ { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619 },
+ { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362 },
+ { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667 },
+ { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069 },
+ { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670 },
+ { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916 },
+ { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625 },
+ { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574 },
+ { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534 },
+ { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481 },
+ { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529 },
+ { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338 },
+ { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147 },
+ { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272 },
+ { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962 },
+ { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063 },
+ { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438 },
+ { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458 },
+ { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589 },
+ { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424 },
+ { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690 },
+ { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248 },
+ { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084 },
+ { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272 },
+ { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497 },
+ { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002 },
+ { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524 },
+ { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165 },
+ { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010 },
+ { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128 },
+ { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382 },
+ { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964 },
+ { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204 },
+ { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510 },
+ { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584 },
+ { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410 },
+ { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980 },
+ { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219 },
+ { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576 },
+]