, tabs, new [url], close. "
+ f"Use this for web interaction, authenticated sites, downloads, form filling. "
+ f"Run `ls /opt/agent-core/common-tools/` to see all. "
+ f"Prefer existing tools over writing new code."
+ f"{user_context}"
+ f"{workspace_context}"
+ f"{conv_context}"
+ )
+
+ claude_args = [
+ cmd,
+ *session_flag,
+ "-p",
+ "--verbose",
+ "--output-format", "stream-json",
+ "--append-system-prompt", system_extra,
+ "--allowedTools", ",".join(config.allowed_tools),
+ "--max-turns", "50",
+ ]
+ if model_override:
+ claude_args.extend(["--model", model_override])
+ claude_args.append(message)
+
+ # Wrap with bwrap if available
+ bwrap_path = Path(__file__).resolve().parent.parent / "bwrap-claude"
+ if bwrap_path.exists() and shutil.which("bwrap"):
+ args = [str(bwrap_path)] + claude_args
+ else:
+ args = claude_args
+
+ # Build clean environment for Claude subprocess
+ _strip_prefixes = ("CLAUDECODE", "CLAUDE_CODE")
+ _strip_keys = {
+ "BOT_TOKEN", "MATRIX_ACCESS_TOKEN", "MATRIX_HOMESERVER",
+ "MATRIX_USER_ID", "MATRIX_OWNER_MXID", "MATRIX_DEVICE_ID",
+ }
+ # Auth env vars that must pass through to Claude CLI
+ _passthrough_keys = {"CLAUDE_CODE_OAUTH_TOKEN"}
+ env = {
+ k: v for k, v in os.environ.items()
+ if k in _passthrough_keys
+ or (not any(k.startswith(p) for p in _strip_prefixes) and k not in _strip_keys)
+ }
+ # Add common-tools to PATH so Claude can use send-to-user, generate-image, etc.
+ common_tools = str(Path(__file__).resolve().parent.parent / "common-tools")
+ env["PATH"] = common_tools + ":" + env.get("PATH", "")
+
+ # Load per-user workspace .env (Readest keys, Linkwarden keys, etc.)
+ if workspace_dir:
+ ws_env = workspace_dir / ".env"
+ if ws_env.exists():
+ for line in ws_env.read_text().splitlines():
+ line = line.strip()
+ if line and not line.startswith("#") and "=" in line:
+ key, _, val = line.partition("=")
+ env[key.strip()] = val.strip().strip("'\"") # handle KEY="value" and KEY='value'
+
+ session_label = existing_session[:8] if existing_session else f"new:{new_id[:8]}"
+ logger.info("Claude CLI: topic=%s session=%s cmd=%s", topic_id, session_label, cmd)
+
+ proc = await asyncio.create_subprocess_exec(
+ *args,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ cwd=str(topic_dir),
+ env=env,
+ limit=10 * 1024 * 1024, # 10MB — stream-json lines can be huge (base64 images)
+ )
+
+ response_parts: list[str] = []
+ full_text = ""
+ result_text = "" # clean final response from result event
+ result_session_id = None
+ timeout_reason = None
+
+ # Tool tracking for status events
+ block_tools: dict[str, str] = {} # tool_use_id -> tool name
+
+ # Idle timeout state — mutable so watchdog can read, user can extend
+ idle_timeout = idle_timeout_ref if idle_timeout_ref is not None else [config.claude_idle_timeout]
+ last_activity = [time.monotonic()]
+ start_time = time.monotonic()
+
+ # Start question watcher if callback provided
+ question_task = None
+ if on_question:
+ question_task = asyncio.create_task(_watch_questions(topic_dir, on_question))
+
+ # Watchdog: checks idle timeout, hard timeout, and cancel
+ async def _watchdog():
+ nonlocal timeout_reason
+ while True:
+ await asyncio.sleep(2)
+ now = time.monotonic()
+ if cancel_event and cancel_event.is_set():
+ timeout_reason = "cancelled"
+ proc.kill()
+ return
+ idle = now - last_activity[0]
+ if idle > idle_timeout[0]:
+ timeout_reason = "idle"
+ proc.kill()
+ return
+ elapsed = now - start_time
+ if elapsed > config.claude_max_timeout:
+ timeout_reason = "max"
+ proc.kill()
+ return
+
+ watchdog_task = asyncio.create_task(_watchdog())
+
+ # Stream log — save all events from Claude CLI for debugging/replay
+ stream_log_path = topic_dir / "stream.jsonl"
+ stream_log = open(stream_log_path, "a")
+
+ try:
+ async for line in proc.stdout:
+ last_activity[0] = time.monotonic() # reset idle timer on ANY output
+
+ line = line.decode("utf-8", errors="replace").strip()
+ if not line:
+ continue
+
+ # Log raw event to stream.jsonl
+ stream_log.write(line + "\n")
+ stream_log.flush()
+
+ try:
+ event = json.loads(line)
+ except json.JSONDecodeError:
+ logger.debug("Non-JSON stdout: %s", line[:200])
+ continue
+
+ etype = event.get("type")
+
+ # Capture session_id from init or result events
+ if etype == "system" and event.get("session_id"):
+ result_session_id = event["session_id"]
+ elif etype == "result" and event.get("session_id"):
+ result_session_id = event["session_id"]
+
+ # Handle result events — this has the clean final response
+ if etype == "result":
+ if event.get("is_error"):
+ errors = event.get("errors", [])
+ logger.error("Claude CLI error: %s", "; ".join(errors))
+ if event.get("result"):
+ result_text = event["result"]
+
+ # --- Status events from stream-json ---
+ # Claude CLI emits full "assistant" snapshots (with tool_use blocks)
+ # followed by "user" events (with tool_result).
+ if etype == "assistant":
+ content = event.get("message", {}).get("content", [])
+ has_tools = any(b.get("type") == "tool_use" for b in content)
+
+ for block in content:
+ if block.get("type") == "tool_use" and on_status:
+ tool_name = block.get("name", "")
+ tool_id = block.get("id", "")
+ inp = block.get("input", {})
+ preview = _tool_preview(tool_name, json.dumps(inp, ensure_ascii=False))
+ if tool_id:
+ block_tools[tool_id] = tool_name
+ if tool_name == "Agent":
+ desc = inp.get("description", "")
+ bg = inp.get("run_in_background", False)
+ await on_status({
+ "event": "agent_start",
+ "description": desc,
+ "background": bg,
+ })
+ else:
+ await on_status({
+ "event": "tool_start",
+ "tool": tool_name,
+ "input_preview": preview,
+ })
+
+ # All assistant text goes to thread as narration.
+ # Only result.result is the final clean response.
+ if block.get("type") == "text" and block.get("text"):
+ text = block["text"]
+ if on_status:
+ await on_status({
+ "event": "thinking",
+ "text": text,
+ })
+ # Also accumulate for on_chunk (Telegram streaming)
+ response_parts.append(text)
+ full_text = "".join(response_parts)
+ if on_chunk:
+ await on_chunk(full_text)
+
+ # Tool results mark tool completion
+ if etype == "user" and on_status:
+ content = event.get("message", {}).get("content", [])
+ if isinstance(content, list):
+ for block in content:
+ if isinstance(block, dict) and block.get("type") == "tool_result":
+ tool_id = block.get("tool_use_id", "")
+ tool_name = block_tools.pop(tool_id, "tool")
+ await on_status({"event": "tool_end", "tool": tool_name})
+
+ # Check if watchdog killed the process
+ if watchdog_task.done():
+ break
+
+ await proc.wait()
+
+ except Exception:
+ if not watchdog_task.done():
+ watchdog_task.cancel()
+ raise
+ finally:
+ stream_log.close()
+ if not watchdog_task.done():
+ watchdog_task.cancel()
+ try:
+ await watchdog_task
+ except asyncio.CancelledError:
+ pass
+ if question_task:
+ question_task.cancel()
+ try:
+ await question_task
+ except asyncio.CancelledError:
+ pass
+
+ elapsed = int(time.monotonic() - start_time)
+
+ # Handle timeout/cancel
+ if timeout_reason:
+ await proc.wait()
+ if timeout_reason == "cancelled":
+ logger.info("Claude CLI cancelled by user after %ds", elapsed)
+ suffix = "\n\n[cancelled by user]"
+ elif timeout_reason == "idle":
+ logger.warning("Claude CLI idle timeout after %ds (idle limit: %ds)", elapsed, idle_timeout[0])
+ suffix = f"\n\n[idle timeout — no output for {idle_timeout[0]}s]"
+ else:
+ logger.error("Claude CLI hard timeout after %ds (max: %ds)", elapsed, config.claude_max_timeout)
+ suffix = f"\n\n[timeout — {elapsed}s elapsed]"
+
+ # Save session even on timeout — don't lose conversation history
+ if result_session_id:
+ save_session(config.data_dir, topic_id, result_session_id, provider)
+
+ # On timeout: prefer result_text (clean), fall back to full_text (has thinking)
+ response = result_text or full_text
+ error_patterns = ["Failed to authenticate", "API Error:", "authentication_error", "401"]
+ if response and not any(p in response for p in error_patterns):
+ return response + suffix
+ raise RuntimeError(f"Claude CLI {timeout_reason} after {elapsed}s (error response: {full_text[:100]})")
+
+ # Save session ID for future resume
+ if result_session_id:
+ save_session(config.data_dir, topic_id, result_session_id, provider)
+
+ # Check for error responses (auth failures, API errors) - these should trigger fallback
+ error_patterns = ["Failed to authenticate", "API Error:", "authentication_error", "401"]
+ is_error_response = any(p in full_text for p in error_patterns)
+
+ if proc.returncode != 0 or is_error_response:
+ stderr = await proc.stderr.read()
+ stderr_text = stderr.decode("utf-8", errors="replace").strip()
+ logger.error("Claude CLI failed (rc=%d): %s", proc.returncode, stderr_text[:500])
+ if is_error_response:
+ raise RuntimeError(f"Claude CLI returned error: {full_text[:200]}")
+ response = result_text or full_text
+ if response:
+ return response
+ # Non-auth failure with no output — raise to trigger fallback
+ # but preserve session file (conversation history is valuable)
+ raise RuntimeError(f"Claude CLI exited with code {proc.returncode}")
+
+ response = result_text or full_text
+ if not response and _retry_count < 1:
+ logger.warning("Claude CLI returned empty response, retrying (attempt %d)", _retry_count + 1)
+ return await _send_with_provider(
+ config, topic_id, message, on_chunk, on_question,
+ on_status=on_status, cancel_event=cancel_event,
+ idle_timeout_ref=idle_timeout_ref,
+ provider=provider, cmd_override=cmd_override, model_override=model_override,
+ user_profile=user_profile, workspace_dir=workspace_dir,
+ _retry_count=_retry_count + 1,
+ )
+
+ return response or "(no response)"
+
+
+def _extract_text(event: dict) -> str | None:
+ """Extract text content from a stream-json event."""
+ etype = event.get("type")
+
+ if etype == "assistant":
+ content = event.get("message", {}).get("content", [])
+ texts = []
+ for block in content:
+ if block.get("type") == "text":
+ texts.append(block.get("text", ""))
+ return "".join(texts) if texts else None
+
+ if etype == "content_block_delta":
+ delta = event.get("delta", {})
+ if delta.get("type") == "text_delta":
+ return delta.get("text", "")
+
+ # Don't extract from "result" — it duplicates what was already
+ # streamed via "assistant" events. The caller uses it as fallback
+ # only if full_text is empty after processing all events.
+
+ return None
diff --git a/bot-examples/matrix_bot_rooms.py b/bot-examples/matrix_bot_rooms.py
new file mode 100755
index 0000000..8e6eadf
--- /dev/null
+++ b/bot-examples/matrix_bot_rooms.py
@@ -0,0 +1,2667 @@
+"""Matrix bot frontend.
+
+Connects to a Matrix homeserver, listens for messages in rooms,
+routes them through Claude CLI sessions. Same session layer as Telegram bot.
+
+Commands:
+ !new [topic] — Create a new conversation room with optional topic name.
+ !claude-auth — Refresh Claude Code OAuth token (manual browser flow).
+"""
+
+import asyncio
+import json
+import logging
+import os
+import re
+import time
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from pathlib import Path
+
+import httpx
+from nio import (
+ AsyncClient,
+ AsyncClientConfig,
+ MatrixRoom,
+ MegolmEvent,
+ RoomEncryptedAudio,
+ RoomEncryptedFile,
+ RoomEncryptedImage,
+ RoomMemberEvent,
+ RoomMessageAudio,
+ RoomMessageImage,
+ RoomMessageText,
+ RoomMessageFile,
+ RoomMessageUnknown,
+ SyncResponse,
+ UnknownEvent,
+)
+from nio.events.to_device import (
+ KeyVerificationCancel,
+ KeyVerificationKey,
+ KeyVerificationMac,
+ KeyVerificationStart,
+)
+
+from nio.crypto import decrypt_attachment
+
+from core.asr import transcribe
+from core.claude_session import send_message as claude_send
+from core.config import Config
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class SessionState:
+ """Tracks an active Claude session for a room."""
+ cancel_event: asyncio.Event
+ user_event_id: str # original user message (thread root)
+ status_event_id: str | None = None # status message in thread
+ status_lines: list[str] = field(default_factory=list)
+ last_status_edit: float = 0.0
+ idle_timeout_ref: list = field(default_factory=lambda: [120])
+ start_time: float = field(default_factory=time.monotonic)
+
+
+class MatrixBot:
+ def __init__(self, config: Config, homeserver: str, user_id: str, access_token: str,
+ owner_mxid: str = "", users: dict[str, dict] | None = None,
+ device_id: str = "AGENT_CORE", admin_mxid: str = ""):
+ self.config = config
+ self.owner_mxid = owner_mxid
+ self.admin_mxid = admin_mxid # For admin notifications (fallback, errors)
+ self._users = users or {}
+ # If single-owner mode (no users map), treat owner as the only allowed user
+ if not self._users and owner_mxid:
+ self._users = {owner_mxid: {}}
+ # E2E: crypto store for keys, auto-decrypt/encrypt
+ store_path = str(config.data_dir / "crypto_store")
+ Path(store_path).mkdir(parents=True, exist_ok=True)
+ client_config = AsyncClientConfig(
+ encryption_enabled=True,
+ store_sync_tokens=True,
+ )
+ self.client = AsyncClient(
+ homeserver, user_id,
+ device_id=device_id,
+ store_path=store_path,
+ config=client_config,
+ )
+ self.client.restore_login(user_id, device_id, access_token)
+ self._synced = False
+ self._default_room_prefix = "Bot: "
+ self._pending_questions: dict[str, asyncio.Future] = {}
+ self._active_sessions: dict[str, SessionState] = {} # room_id -> session state
+ # Persistent message queue removed — using queue.jsonl files instead
+ self._auth_flows: dict[str, dict] = {} # safe_id -> {tmux_session, started}
+ self._collect_preambles: dict[str, str] = {} # safe_id -> preamble for next Claude call
+ self._processed_events: set[str] = set()
+ self._room_verifications: dict[str, dict] = {} # tx_id → state
+ self._sync_token_path = config.data_dir / "matrix_sync_token.txt"
+ self._avatar_mxc: str | None = None # cached after upload
+
+ def _is_allowed_user(self, sender: str) -> bool:
+ return sender in self._users
+
+ def _get_user_workspace(self, sender: str) -> Path | None:
+ """Get workspace directory for a user, or None."""
+ user_info = self._users.get(sender, {})
+ ws = user_info.get("workspace")
+ if ws:
+ path = Path(ws)
+ if path.is_dir():
+ return path
+ return None
+
+ def _get_user_profile(self, sender: str) -> str:
+ """Load user.md content for a sender, or empty string."""
+ user_info = self._users.get(sender, {})
+ profile_file = user_info.get("profile")
+ if profile_file and self.config.workspace_dir:
+ path = self.config.workspace_dir / profile_file
+ if path.exists():
+ return path.read_text().strip()
+ # Fallback: single-user mode with user.md
+ if self.config.workspace_dir:
+ path = self.config.workspace_dir / "user.md"
+ if path.exists():
+ return path.read_text().strip()
+ return ""
+
+ def _is_group_room(self, room: MatrixRoom) -> bool:
+ """Room has more than 2 members (joined + invited, not a 1:1 chat)."""
+ return (room.member_count + room.invited_count) > 2
+
+ def _text_mentions_bot(self, text: str) -> bool:
+ """Check if text contains a bot mention (@user_id, localpart, or display name)."""
+ text = text.lower()
+ # Check user_id (@bot:your.homeserver.example)
+ if self.client.user_id.lower() in text:
+ return True
+ # Check localpart (bot)
+ local_name = self.client.user_id.split(":")[0].lstrip("@").lower()
+ if local_name in text:
+ return True
+ # Check display name from any room
+ for room in self.client.rooms.values():
+ me = room.users.get(self.client.user_id)
+ if me and me.display_name and me.display_name.lower() in text:
+ return True
+ return False
+
+ def _strip_mention_prefix(self, text: str) -> str:
+ """Strip bot mention prefix from text (e.g. '@[bot-dev] !status' → '!status')."""
+ import re
+ local_name = self.client.user_id.split(":")[0].lstrip("@")
+ names = [re.escape(self.client.user_id), re.escape(local_name)]
+ for room in self.client.rooms.values():
+ me = room.users.get(self.client.user_id)
+ if me and me.display_name:
+ names.append(re.escape(me.display_name))
+ break
+ alts = "|".join(names)
+ # Match: @[name], @name, name: , name, — with optional @[] wrapping and trailing punctuation
+ pattern = r"^@?\[?(?:" + alts + r")\]?[\s:,]*"
+ return re.sub(pattern, "", text, flags=re.IGNORECASE)
+
+ def _is_bot_mentioned(self, event: RoomMessageText) -> bool:
+ """Check if bot is mentioned in a message event."""
+ # Check structured mentions first (m.mentions in content)
+ mentions = event.source.get("content", {}).get("m.mentions", {})
+ user_ids = mentions.get("user_ids", [])
+ if self.client.user_id in user_ids:
+ return True
+ return self._text_mentions_bot(event.body)
+
+ def _room_dir(self, room_id: str) -> Path:
+ safe_id = room_id.replace(":", "_").replace("!", "")
+ d = self.config.data_dir / "rooms" / safe_id
+ d.mkdir(parents=True, exist_ok=True)
+ return d
+
+ def _topic_dir(self, safe_id: str) -> Path:
+ return self.config.data_dir / "topics" / safe_id
+
+ # --- Room history ---
+
+ def _save_room_message(self, room_id: str, sender: str, msg_type: str, text: str,
+ file_path: str | None = None) -> None:
+ """Append a message to room history. Called for ALL messages in ALL rooms."""
+ history_file = self._room_dir(room_id) / "history.jsonl"
+ display = sender.split(":")[0].lstrip("@")
+ entry: dict = {
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "sender": sender,
+ "name": display,
+ "type": msg_type,
+ "text": text,
+ }
+ if file_path:
+ entry["file"] = file_path
+ with open(history_file, "a") as f:
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
+
+ def _get_room_context(self, room_id: str, limit: int = 50) -> str:
+ """Read last N messages from history.jsonl and format as chat context."""
+ history_file = self._room_dir(room_id) / "history.jsonl"
+ if not history_file.exists():
+ return ""
+ lines = []
+ try:
+ with open(history_file) as f:
+ all_lines = f.readlines()
+ for line in all_lines[-limit:]:
+ line = line.strip()
+ if line:
+ lines.append(json.loads(line))
+ except Exception as e:
+ logger.warning("Failed to read room history: %s", e)
+ return ""
+ if not lines:
+ return ""
+ parts = []
+ for msg in lines:
+ name = msg.get("name", "?")
+ text = msg.get("text", "")
+ msg_type = msg.get("type", "text")
+ ts = msg.get("ts", "")[:16].replace("T", " ")
+ if msg_type == "image":
+ parts.append(f"[{ts}] {name}: [sent an image] {text}")
+ elif msg_type == "audio":
+ parts.append(f"[{ts}] {name}: [voice] {text}")
+ elif msg_type == "file":
+ parts.append(f"[{ts}] {name}: [sent a file] {text}")
+ else:
+ parts.append(f"[{ts}] {name}: {text}")
+ context = "\n".join(parts)
+ return (
+ "[Recent room history — you can see what participants discussed before mentioning you. "
+ "Use this context to understand the conversation. Do NOT repeat this history back.]\n\n"
+ + context
+ )
+
+ # --- Room mode (quiet / context / full / collect) ---
+
+ ROOM_MODES = ("quiet", "context", "full", "collect")
+
+ def _get_room_mode(self, room_id: str) -> str:
+ """Get room mode from config.json. Default: quiet for groups, full for 1:1."""
+ config_file = self._room_dir(room_id) / "config.json"
+ if config_file.exists():
+ try:
+ data = json.loads(config_file.read_text())
+ mode = data.get("mode", "")
+ if mode in self.ROOM_MODES:
+ return mode
+ except Exception:
+ pass
+ room = self.client.rooms.get(room_id)
+ if room and self._is_group_room(room):
+ return "quiet"
+ return "full"
+
+ def _set_room_mode(self, room_id: str, mode: str) -> None:
+ """Save room mode to config.json."""
+ config_file = self._room_dir(room_id) / "config.json"
+ data = {}
+ if config_file.exists():
+ try:
+ data = json.loads(config_file.read_text())
+ except Exception:
+ pass
+ data["mode"] = mode
+ config_file.write_text(json.dumps(data, ensure_ascii=False, indent=2))
+
+ # --- Room security mode (strict / guarded / open) ---
+
+ SECURITY_MODES = ("strict", "guarded", "open")
+
+ def _get_security_mode(self, room_id: str) -> str:
+ """Get room security mode from config.json. Default: guarded."""
+ config_file = self._room_dir(room_id) / "config.json"
+ if config_file.exists():
+ try:
+ data = json.loads(config_file.read_text())
+ mode = data.get("security", "")
+ if mode in self.SECURITY_MODES:
+ return mode
+ except Exception:
+ pass
+ return "guarded"
+
+ def _set_security_mode(self, room_id: str, mode: str) -> None:
+ """Save room security mode to config.json."""
+ config_file = self._room_dir(room_id) / "config.json"
+ data = {}
+ if config_file.exists():
+ try:
+ data = json.loads(config_file.read_text())
+ except Exception:
+ pass
+ data["security"] = mode
+ config_file.write_text(json.dumps(data, ensure_ascii=False, indent=2))
+
+ def _get_unverified_devices(self, room_id: str) -> dict[str, list[str]]:
+ """Return {user_id: [device_id, ...]} for unverified devices in a room.
+
+ Only checks allowed users (room members known to the bot).
+ """
+ if not self.client.olm:
+ return {}
+ room = self.client.rooms.get(room_id)
+ if not room:
+ return {}
+ unverified: dict[str, list[str]] = {}
+ for user_id in room.users:
+ if user_id == self.client.user_id:
+ continue
+ for device in self.client.device_store.active_user_devices(user_id):
+ if not device.verified:
+ unverified.setdefault(user_id, []).append(device.id)
+ return unverified
+
+ def _user_fully_verified(self, sender: str) -> bool:
+ """Check if all of sender's devices are verified."""
+ if not self.client.olm:
+ return True # no E2E, no verification needed
+ for device in self.client.device_store.active_user_devices(sender):
+ if not device.verified:
+ return False
+ return True
+
+ def _format_unverified_warning(self, unverified: dict[str, list[str]]) -> str:
+ """Format a warning string listing unverified devices."""
+ parts = []
+ for user_id, devices in unverified.items():
+ dev_str = ", ".join(f"`{d}`" for d in devices)
+ parts.append(f"{user_id}: {dev_str}")
+ return "\u26a0 Unverified devices in room: " + "; ".join(parts)
+
+ async def _check_security(self, room_id: str, sender: str) -> tuple[bool, str | None]:
+ """Check room security policy for a sender.
+
+ Returns:
+ (allowed, warning_or_error):
+ - (True, None) — proceed, no warning
+ - (True, warning) — proceed, append warning to response
+ - (False, error) — refuse, send error message
+ """
+ security = self._get_security_mode(room_id)
+ if security == "open":
+ unverified = self._get_unverified_devices(room_id)
+ if unverified:
+ return True, self._format_unverified_warning(unverified)
+ return True, None
+
+ unverified = self._get_unverified_devices(room_id)
+ if not unverified:
+ return True, None
+
+ if security == "strict":
+ return False, (
+ "Room has unverified devices — refusing to respond.\n"
+ + self._format_unverified_warning(unverified)
+ + "\n\nVerify devices or use `!security open` from a fully verified session."
+ )
+
+ # guarded: block only users with unverified devices
+ sender_unverified = unverified.get(sender)
+ if sender_unverified:
+ dev_str = ", ".join(f"`{d}`" for d in sender_unverified)
+ return False, (
+ f"You have unverified devices ({dev_str}) — not accepting commands.\n"
+ "Verify your devices or ask a verified user to `!security open`."
+ )
+ return True, None
+
+ def _log_interaction(self, room_id: str, user_msg: str, bot_msg: str) -> None:
+ log_file = self._room_dir(room_id) / "log.jsonl"
+ entry = {
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "user": user_msg[:1000],
+ "bot": bot_msg[:2000],
+ }
+ with open(log_file, "a") as f:
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
+
+ def _md_to_html(self, text: str) -> str:
+ """Convert markdown to Matrix HTML, with tables as monospace blocks."""
+ import re
+ import markdown
+
+ lines = text.split("\n")
+ result_lines = []
+ table_lines = []
+ in_table = False
+
+ for line in lines:
+ is_table_line = bool(re.match(r"^\s*\|.*\|\s*$", line))
+ is_separator = bool(re.match(r"^\s*\|[-:| ]+\|\s*$", line))
+
+ if is_table_line:
+ if not in_table:
+ in_table = True
+ table_lines = []
+ if not is_separator:
+ table_lines.append(line)
+ else:
+ table_lines.append(line)
+ else:
+ if in_table:
+ result_lines.append("```")
+ result_lines.extend(table_lines)
+ result_lines.append("```")
+ table_lines = []
+ in_table = False
+ result_lines.append(line)
+
+ if in_table:
+ result_lines.append("```")
+ result_lines.extend(table_lines)
+ result_lines.append("```")
+
+ text = "\n".join(result_lines)
+ html = markdown.markdown(text, extensions=["fenced_code"])
+ return html
+
+ # --- Avatar management ---
+
+ def _avatar_path(self) -> Path | None:
+ """Return path to avatar.jpg in workspace, or None."""
+ if self.config.workspace_dir:
+ p = self.config.workspace_dir / "avatar.jpg"
+ if p.exists():
+ return p
+ return None
+
+ async def _set_bot_avatar(self) -> None:
+ """Upload avatar.jpg and set as bot profile picture (only if not already set)."""
+ path = self._avatar_path()
+ if not path:
+ return
+ try:
+ async with httpx.AsyncClient() as http:
+ user_id = self.client.user_id
+ hs = self.client.homeserver
+ # Check if avatar already set
+ resp = await http.get(
+ f"{hs}/_matrix/client/v3/profile/{user_id}/avatar_url",
+ headers={"Authorization": f"Bearer {self.client.access_token}"},
+ timeout=10,
+ )
+ if resp.status_code == 200:
+ existing = resp.json().get("avatar_url", "")
+ if existing:
+ self._avatar_mxc = existing
+ logger.info("Bot avatar already set: %s", existing)
+ return
+ # Upload and set
+ data = path.read_bytes()
+ mxc = await self._upload_file(data, "image/jpeg", "avatar.jpg")
+ if not mxc:
+ return
+ self._avatar_mxc = mxc
+ resp = await http.put(
+ f"{hs}/_matrix/client/v3/profile/{user_id}/avatar_url",
+ json={"avatar_url": mxc},
+ headers={"Authorization": f"Bearer {self.client.access_token}"},
+ timeout=15,
+ )
+ if resp.status_code == 200:
+ logger.info("Set bot profile avatar: %s", mxc)
+ else:
+ logger.warning("Failed to set profile avatar (%d): %s",
+ resp.status_code, resp.text[:200])
+ except Exception as e:
+ logger.warning("Failed to set bot avatar: %s", e)
+
+ async def _set_room_avatar(self, room_id: str) -> None:
+ """Set room avatar to bot's avatar if not already set. Uses HTTP API directly."""
+ if not self._avatar_mxc:
+ return
+ try:
+ from urllib.parse import quote
+ hs = self.client.homeserver
+ rid = quote(room_id, safe="")
+ async with httpx.AsyncClient() as http:
+ # Check if avatar already set
+ resp = await http.get(
+ f"{hs}/_matrix/client/v3/rooms/{rid}/state/m.room.avatar",
+ headers={"Authorization": f"Bearer {self.client.access_token}"},
+ timeout=10,
+ )
+ if resp.status_code == 200:
+ return # already has avatar
+ # Set avatar
+ resp = await http.put(
+ f"{hs}/_matrix/client/v3/rooms/{rid}/state/m.room.avatar",
+ json={"url": self._avatar_mxc},
+ headers={"Authorization": f"Bearer {self.client.access_token}"},
+ timeout=10,
+ )
+ if resp.status_code == 200:
+ logger.info("Set room avatar for %s", room_id)
+ else:
+ logger.warning("Failed to set room avatar for %s (%d): %s",
+ room_id, resp.status_code, resp.text[:200])
+ except Exception as e:
+ logger.warning("Failed to set room avatar for %s: %s", room_id, e)
+
+ # --- Room management ---
+
+ async def _generate_room_label(self, room_id: str, current_label: str = "") -> str | None:
+ """Generate a short room label via local LLM based on conversation history.
+
+ Returns None if generation fails, or the new label string.
+ """
+ # Build context from history
+ history_file = self._room_dir(room_id) / "history.jsonl"
+ chat_lines = []
+ if history_file.exists():
+ try:
+ with open(history_file) as f:
+ all_lines = f.readlines()
+ for line in all_lines[-15:]:
+ line = line.strip()
+ if line:
+ msg = json.loads(line)
+ name = msg.get("name", "?")
+ text = msg.get("text", "")[:150]
+ chat_lines.append(f"{name}: {text}")
+ except Exception:
+ pass
+ if not chat_lines:
+ return None
+
+ conversation = "\n".join(chat_lines)
+ user_content = conversation
+ if current_label:
+ user_content = f"Current name: {current_label}\n\n{conversation}"
+
+ api_base = os.environ.get("LOCAL_LLM_URL") or os.environ.get("OPENAI_API_BASE", "http://localhost:4000/v1")
+ api_key = os.environ.get("OPENAI_API_KEY", "")
+ model = os.environ.get("LOCAL_LLM_MODEL", "qwen3.5-122b")
+ llm_url = api_base.rstrip("/") + "/chat/completions"
+ headers = {}
+ if api_key:
+ headers["Authorization"] = f"Bearer {api_key}"
+ try:
+ async with httpx.AsyncClient() as http:
+ resp = await http.post(llm_url, json={
+ "model": model,
+ "messages": [
+ {"role": "system", "content": (
+ "You generate short chat room titles (3-5 words) based on what the user is asking about. "
+ "Rules: output ONLY the title. No quotes, no prefixes. Same language as the user. "
+ "Focus on the user's main question or task, ignore bot replies and minor tangents."
+ )},
+ {"role": "user", "content": user_content},
+ ],
+ "max_tokens": 20,
+ "temperature": 0.3,
+ "chat_template_kwargs": {"enable_thinking": False},
+ }, headers=headers, timeout=15)
+ if resp.status_code == 200:
+ data = resp.json()
+ label = data["choices"][0]["message"]["content"].strip().strip('"\'')
+ return label[:80] if label else None
+ except Exception as e:
+ logger.warning("Failed to generate room label: %s", e)
+ return None
+
+ async def _rename_room(self, room_id: str, safe_id: str,
+ user_text: str = "", response: str = "") -> None:
+ """Rename room if it still has the default 'Bot: ' prefix."""
+ room = self.client.rooms.get(room_id)
+ if not room:
+ return
+ current_name = room.name or ""
+ if not current_name.startswith(self._default_room_prefix):
+ return # user renamed it manually — don't touch
+ current_label = current_name[len(self._default_room_prefix):].strip()
+ label = await self._generate_room_label(room_id, current_label)
+ if not label:
+ return
+ new_name = f"{self._default_room_prefix}{label}"
+ if new_name == current_name:
+ return
+ try:
+ from nio.responses import RoomPutStateError
+ resp = await self.client.room_put_state(
+ room_id, "m.room.name", {"name": new_name[:255]},
+ )
+ if isinstance(resp, RoomPutStateError):
+ logger.warning("Cannot rename room %s: %s", room_id, resp.status_code)
+ return
+ logger.info("Renamed room %s to: %s", room_id, new_name)
+ await self._set_room_avatar(room_id)
+ except Exception as e:
+ logger.warning("Failed to rename room: %s", e)
+
+ async def _create_conversation_room(self, name: str, for_user: str | None = None) -> str | None:
+ """Create a private encrypted room and invite the user."""
+ initial_state = [
+ {
+ "type": "m.room.encryption",
+ "state_key": "",
+ "content": {"algorithm": "m.megolm.v1.aes-sha2"},
+ },
+ ]
+ if self._avatar_mxc:
+ initial_state.append({
+ "type": "m.room.avatar",
+ "state_key": "",
+ "content": {"url": self._avatar_mxc},
+ })
+ body: dict = {
+ "name": name,
+ "visibility": "private",
+ "preset": "trusted_private_chat",
+ "invite": [for_user] if for_user else [],
+ }
+ # Give the target user admin power (matches Element-created rooms)
+ if for_user:
+ body["power_level_content_override"] = {
+ "users": {
+ self.client.user_id: 100,
+ for_user: 100,
+ },
+ }
+ if initial_state:
+ body["initial_state"] = initial_state
+ try:
+ async with httpx.AsyncClient() as http:
+ resp = await http.post(
+ f"{self.client.homeserver}/_matrix/client/v3/createRoom",
+ headers={
+ "Authorization": f"Bearer {self.client.access_token}",
+ "Content-Type": "application/json",
+ },
+ json=body,
+ timeout=15,
+ )
+ if resp.status_code == 200:
+ room_id = resp.json()["room_id"]
+ logger.info("Created room %s: %s", room_id, name)
+ return room_id
+ logger.error("Failed to create room (%d): %s", resp.status_code, resp.text[:200])
+ except Exception as e:
+ logger.error("Failed to create room: %s", e)
+ return None
+
+ # --- Sending ---
+
+ async def _send_response(self, room_id: str, response: str,
+ ignore_unverified_devices: bool = True) -> None:
+ """Send response with HTML formatting."""
+ html = self._md_to_html(response)
+ await self.client.room_send(
+ room_id, "m.room.message",
+ {
+ "msgtype": "m.text",
+ "body": response,
+ "format": "org.matrix.custom.html",
+ "formatted_body": html,
+ },
+ ignore_unverified_devices=ignore_unverified_devices,
+ )
+
+ async def _upload_file(self, data: bytes, content_type: str, filename: str) -> str | None:
+ """Upload file to Matrix via HTTP API directly."""
+ homeserver = self.client.homeserver
+ url = f"{homeserver}/_matrix/media/v3/upload?filename={filename}"
+ async with httpx.AsyncClient() as http:
+ resp = await http.post(
+ url, content=data,
+ headers={
+ "Authorization": f"Bearer {self.client.access_token}",
+ "Content-Type": content_type,
+ },
+ timeout=60,
+ )
+ if resp.status_code == 200:
+ return resp.json().get("content_uri")
+ logger.error("Matrix upload failed (%d): %s", resp.status_code, resp.text[:200])
+ return None
+
+ async def _download_media(self, event) -> bytes | None:
+ """Download media from Matrix, decrypting if E2E encrypted."""
+ resp = await self.client.download(event.url)
+ if not hasattr(resp, "body"):
+ logger.error("Failed to download media: %s", resp)
+ return None
+ data = resp.body
+ # Encrypted media (RoomEncryptedImage/Audio/File) has key/hashes/iv
+ if hasattr(event, "key") and hasattr(event, "hashes") and hasattr(event, "iv"):
+ try:
+ data = decrypt_attachment(
+ data, event.key["k"], event.hashes["sha256"], event.iv,
+ )
+ except Exception as e:
+ logger.error("Failed to decrypt attachment: %s", e)
+ return None
+ return data
+
+ async def _send_outbox(self, room_id: str, room_dir: Path) -> None:
+ """Send files queued in outbox.jsonl by Claude via send-to-user tool."""
+ outbox = room_dir / "outbox.jsonl"
+ if not outbox.exists():
+ return
+
+ entries = []
+ try:
+ with open(outbox) as f:
+ for line in f:
+ line = line.strip()
+ if line:
+ entries.append(json.loads(line))
+ outbox.unlink()
+ except Exception as e:
+ logger.error("Failed to read outbox: %s", e)
+ return
+
+ mime_map = {
+ "jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png",
+ "webp": "image/webp", "gif": "image/gif", "bmp": "image/bmp",
+ "mp4": "video/mp4", "mov": "video/quicktime", "webm": "video/webm",
+ "ogg": "audio/ogg", "mp3": "audio/mpeg", "wav": "audio/wav", "m4a": "audio/mp4",
+ "pdf": "application/pdf", "doc": "application/msword",
+ "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "html": "text/html", "txt": "text/plain", "csv": "text/csv",
+ "zip": "application/zip", "json": "application/json",
+ }
+
+ for entry in entries:
+ fpath = Path(entry.get("path", ""))
+ ftype = entry.get("type", "document")
+
+ if not fpath.is_file():
+ logger.warning("Outbox file not found: %s", fpath)
+ continue
+
+ try:
+ data = fpath.read_bytes()
+ ext = fpath.suffix.lstrip(".").lower()
+ content_type = mime_map.get(ext, "application/octet-stream")
+
+ content_uri = await self._upload_file(data, content_type, fpath.name)
+ if not content_uri:
+ continue
+
+ if ftype == "image":
+ msgtype = "m.image"
+ elif ftype == "video":
+ msgtype = "m.video"
+ elif ftype == "audio":
+ msgtype = "m.audio"
+ else:
+ msgtype = "m.file"
+
+ await self.client.room_send(
+ room_id, "m.room.message",
+ {
+ "msgtype": msgtype,
+ "body": fpath.name,
+ "filename": fpath.name,
+ "url": content_uri,
+ "info": {"mimetype": content_type, "size": len(data)},
+ },
+ ignore_unverified_devices=True,
+ )
+ logger.info("Sent %s to Matrix: %s", ftype, fpath.name)
+ except Exception as e:
+ logger.error("Failed to send %s %s: %s", ftype, fpath.name, e)
+
+ def _sender_display_name(self, room: MatrixRoom, sender: str) -> str:
+ """Get display name for a sender in a room, fallback to localpart."""
+ member = room.users.get(sender)
+ if member and member.display_name:
+ return member.display_name
+ return sender.split(":")[0].lstrip("@")
+
+ async def _fetch_recent_messages(self, room_id: str, limit: int = 5) -> list[dict]:
+ """Fetch recent messages from a room for context mode."""
+ room = self.client.rooms.get(room_id)
+ if not room or not room.prev_batch:
+ return []
+ resp = await self.client.room_messages(room_id, start=room.prev_batch, limit=limit)
+ if not hasattr(resp, "chunk"):
+ return []
+ messages = []
+ for event in reversed(resp.chunk): # chronological order
+ if event.sender == self.client.user_id:
+ continue
+ body = getattr(event, "body", None)
+ if not body:
+ continue
+ name = self._sender_display_name(room, event.sender)
+ messages.append({"sender": name, "text": body})
+ return messages
+
+ # --- Thread status messaging ---
+
+ async def _send_thread_message(self, room_id: str, thread_root_event_id: str,
+ body: str) -> str | None:
+ """Send a notice in a thread under the given event."""
+ content = {
+ "msgtype": "m.notice",
+ "body": body,
+ "m.relates_to": {
+ "rel_type": "m.thread",
+ "event_id": thread_root_event_id,
+ "is_falling_back": True,
+ "m.in_reply_to": {"event_id": thread_root_event_id},
+ },
+ }
+ resp = await self.client.room_send(
+ room_id, "m.room.message", content,
+ ignore_unverified_devices=True,
+ )
+ if hasattr(resp, "event_id"):
+ return resp.event_id
+ return None
+
+ async def _edit_message(self, room_id: str, event_id: str, new_body: str) -> None:
+ """Edit an existing message using m.replace relation."""
+ content = {
+ "msgtype": "m.notice",
+ "body": f"* {new_body}",
+ "m.new_content": {
+ "msgtype": "m.notice",
+ "body": new_body,
+ },
+ "m.relates_to": {
+ "rel_type": "m.replace",
+ "event_id": event_id,
+ },
+ }
+ await self.client.room_send(
+ room_id, "m.room.message", content,
+ ignore_unverified_devices=True,
+ )
+
+ async def _run_claude_session(self, room: MatrixRoom, event, message: str,
+ security_msg: str | None = None,
+ on_question=None,
+ on_done=None,
+ **extra_kwargs) -> None:
+ """Run a Claude session as a background task.
+
+ Runs concurrently so the sync loop stays free to process !stop etc.
+ on_done(response) is called after session completes (for logging, renaming).
+ """
+ room_id = room.room_id
+ safe_id = room_id.replace(":", "_").replace("!", "")
+
+ cancel_event = asyncio.Event()
+ idle_timeout_ref = [self.config.claude_idle_timeout]
+ session = SessionState(
+ cancel_event=cancel_event,
+ user_event_id=event.event_id,
+ idle_timeout_ref=idle_timeout_ref,
+ start_time=time.monotonic(),
+ )
+ self._active_sessions[room_id] = session
+
+ status_event_id = await self._send_thread_message(
+ room_id, event.event_id, "Working..."
+ )
+ session.status_event_id = status_event_id
+ on_status = self._make_on_status(room_id, session)
+
+ user_profile = self._get_user_profile(event.sender)
+ workspace_dir = self._get_user_workspace(event.sender)
+
+ # Default on_question: post to room, wait for user reply
+ if on_question is None:
+ async def on_question(question: str) -> str:
+ await self.client.room_send(
+ room_id, "m.room.message",
+ {"msgtype": "m.text", "body": f"? {question}"},
+ ignore_unverified_devices=True,
+ )
+ future = asyncio.get_event_loop().create_future()
+ self._pending_questions[safe_id] = future
+ return await future
+
+ # Run as background task so sync loop stays free to process !stop etc.
+ async def _session_task():
+ response = ""
+ try:
+ response = await self._call_claude(
+ room_id, safe_id, message,
+ on_status=on_status, cancel_event=cancel_event,
+ idle_timeout_ref=idle_timeout_ref,
+ on_question=on_question,
+ user_profile=user_profile, sender=event.sender,
+ workspace_dir=workspace_dir,
+ **extra_kwargs,
+ )
+ display = response + f"\n\n{security_msg}" if security_msg else response
+ await self._send_response(room_id, display)
+ except RuntimeError as e:
+ if cancel_event.is_set():
+ await self._send_response(room_id, "Stopped.")
+ response = "[cancelled]"
+ else:
+ logger.error("Claude error in room %s: %s", room.display_name, e)
+ await self._send_response(room_id, f"Error: {e}")
+ response = f"[error] {e}"
+ finally:
+ elapsed = int(time.monotonic() - session.start_time)
+ mins, secs = divmod(elapsed, 60)
+ time_str = f"{mins}m {secs:02d}s" if mins else f"{secs}s"
+ tools_used = len(session.status_lines)
+ final_status = f"Done ({time_str}, {tools_used} tools)"
+ if session.cancel_event.is_set():
+ final_status = f"Cancelled ({time_str})"
+ try:
+ if session.status_event_id:
+ await self._edit_message(room_id, session.status_event_id, final_status)
+ except Exception:
+ pass
+
+ await self._send_outbox(room_id, self._topic_dir(safe_id))
+
+ # Auto-commit workspace changes
+ if workspace_dir:
+ asyncio.create_task(self._auto_commit_workspace(workspace_dir, room))
+
+ # Post-session callback (logging, renaming, etc.)
+ if on_done:
+ try:
+ await on_done(response)
+ except Exception as e:
+ logger.warning("on_done callback failed: %s", e)
+
+ # Process queued messages — combine all into one prompt.
+ # Drain BEFORE popping session so room stays "busy" and new
+ # messages don't sneak in between drain and new session start.
+ queued, last_eid = self._drain_queue(room_id)
+ if queued and last_eid:
+ # _process_queued_messages calls _run_claude_session which
+ # overwrites _active_sessions[room_id] with a new session.
+ await self._process_queued_messages(room, queued, last_eid)
+ else:
+ self._active_sessions.pop(room_id, None)
+
+ asyncio.create_task(_session_task())
+
+ async def _auto_commit_workspace(self, workspace_dir: Path, room: MatrixRoom) -> None:
+ """Git commit workspace changes after a session, if any."""
+ try:
+ # Check for uncommitted changes
+ proc = await asyncio.create_subprocess_exec(
+ "git", "status", "--porcelain",
+ cwd=str(workspace_dir),
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ stdout, _ = await proc.communicate()
+ if not stdout.strip():
+ return # nothing changed
+
+ # Stage all and commit
+ await (await asyncio.create_subprocess_exec(
+ "git", "add", "-A",
+ cwd=str(workspace_dir),
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )).communicate()
+
+ room_name = room.display_name or room.room_id
+ msg = f"auto: {room_name}"
+ await (await asyncio.create_subprocess_exec(
+ "git", "commit", "-m", msg, "--no-gpg-sign",
+ cwd=str(workspace_dir),
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )).communicate()
+ logger.info("Auto-committed workspace changes: %s", workspace_dir)
+ except Exception as e:
+ logger.warning("Workspace auto-commit failed: %s", e)
+
+ def _is_room_busy(self, room_id: str) -> bool:
+ return room_id in self._active_sessions
+
+ def _enqueue_message(self, room_id: str, event_id: str, sender: str,
+ text: str, msg_type: str = "text",
+ file_path: str | None = None) -> None:
+ """Queue a processed message to queue.jsonl for later delivery."""
+ queue_file = self._room_dir(room_id) / "queue.jsonl"
+ entry = {
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "event_id": event_id,
+ "sender": sender,
+ "type": msg_type,
+ "text": text,
+ }
+ if file_path:
+ entry["file"] = file_path
+ with open(queue_file, "a") as f:
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
+ count = sum(1 for _ in open(queue_file))
+ logger.info("Queued message for room %s (%d pending)", room_id, count)
+
+ def _drain_queue(self, room_id: str) -> tuple[list[dict], str | None]:
+ """Read and clear queue.jsonl. Returns (messages, last_event_id)."""
+ queue_file = self._room_dir(room_id) / "queue.jsonl"
+ if not queue_file.exists():
+ return [], None
+ messages = []
+ try:
+ with open(queue_file) as f:
+ for line in f:
+ line = line.strip()
+ if line:
+ messages.append(json.loads(line))
+ queue_file.unlink()
+ except Exception as e:
+ logger.warning("Failed to drain queue for %s: %s", room_id, e)
+ last_event_id = messages[-1]["event_id"] if messages else None
+ return messages, last_event_id
+
+ async def _process_queued_messages(self, room: MatrixRoom,
+ messages: list[dict], last_event_id: str) -> None:
+ """Combine queued messages into one prompt and send to Claude."""
+ room_id = room.room_id
+ safe_id = room_id.replace(":", "_").replace("!", "")
+
+ # Build combined prompt
+ parts = []
+ for msg in messages:
+ mtype = msg.get("type", "text")
+ text = msg.get("text", "")
+ fpath = msg.get("file", "")
+ if mtype == "image":
+ parts.append(f"[User sent an image: {fpath}]")
+ if text:
+ parts.append(text)
+ elif mtype == "audio":
+ parts.append(f"[voice message]: {text}")
+ elif mtype == "file":
+ parts.append(f"[User sent a file: {fpath}]")
+ else:
+ parts.append(text)
+
+ combined = "\n".join(parts)
+ if len(messages) > 1:
+ combined = (f"[{len(messages)} messages arrived while you were busy. "
+ f"Process them all:]\n\n{combined}")
+
+ # Minimal event-like object — covers all attributes accessed by
+ # _run_claude_session and downstream code paths
+ sender = messages[-1].get("sender", "")
+ event = type("QueuedEvent", (), {
+ "event_id": last_event_id,
+ "sender": sender,
+ "body": combined[:100],
+ "source": {"content": {}}, # empty — won't match thread checks
+ })()
+
+ mode = self._get_room_mode(room_id)
+
+ async def _on_done(response: str):
+ if mode == "full":
+ self._save_room_message(room_id, self.client.user_id, "text", response)
+ await self._rename_room(room_id, safe_id)
+ self._log_interaction(room_id, combined[:200], response)
+
+ # Add full context if in full mode
+ message_for_claude = combined
+ if mode == "full":
+ for msg in messages:
+ self._save_room_message(room_id, msg.get("sender", ""),
+ msg.get("type", "text"), msg.get("text", ""))
+ context = self._get_room_context(room_id)
+ if context:
+ message_for_claude = context + "\n\n---\n\n" + combined
+
+ await self._run_claude_session(
+ room, event, message_for_claude, on_done=_on_done,
+ )
+
+ async def _handle_thread_command(self, room_id: str, user_text: str,
+ session: SessionState) -> bool:
+ """Handle user commands in a session thread. Returns True if handled."""
+ cmd = user_text.strip().lower().lstrip("!")
+ if cmd in ("stop", "cancel", "abort"):
+ session.cancel_event.set()
+ await self._send_thread_message(room_id, session.user_event_id, "Stopping...")
+ return True
+ if cmd in ("more time", "+5m", "+5"):
+ session.idle_timeout_ref[0] += 300
+ mins = session.idle_timeout_ref[0] // 60
+ await self._send_thread_message(
+ room_id, session.user_event_id, f"Timeout extended to {mins}m")
+ return True
+ if cmd in ("+10m", "+10"):
+ session.idle_timeout_ref[0] += 600
+ mins = session.idle_timeout_ref[0] // 60
+ await self._send_thread_message(
+ room_id, session.user_event_id, f"Timeout extended to {mins}m")
+ return True
+ return False
+
+ def _make_on_status(self, room_id: str, session: SessionState):
+ """Create an on_status callback that posts individual thread messages."""
+ async def on_status(status: dict):
+ event_type = status.get("event")
+ msg = None
+
+ if event_type == "tool_start":
+ tool = status.get("tool", "?")
+ preview = status.get("input_preview", "")
+ session.status_lines.append(tool) # count for final summary
+ if preview:
+ msg = f"`{tool}`: {preview}"
+ else:
+ msg = f"`{tool}`"
+ elif event_type == "tool_end":
+ pass # tool_start already posted, no need for end message
+ elif event_type == "agent_start":
+ desc = status.get("description", "subagent")
+ bg = " (bg)" if status.get("background") else ""
+ session.status_lines.append("Agent")
+ msg = f"`Agent{bg}`: {desc}"
+ elif event_type == "thinking":
+ text = status.get("text", "").strip()
+ if text:
+ msg = text
+
+ if msg and session.user_event_id:
+ try:
+ await self._send_thread_message(room_id, session.user_event_id, msg)
+ except Exception as e:
+ logger.debug("Failed to send thread status: %s", e)
+
+ return on_status
+
+ # --- Claude call wrapper ---
+
+ async def _notify_fallback_used(self, room_id: str, sender: str) -> None:
+ """Send notification to admin when fallback provider was used."""
+ if not self.admin_mxid or sender == self.admin_mxid:
+ return # Don't notify if no admin or admin triggered it
+
+ # Find DM room with admin — prefer room named exactly after the bot
+ # Priority: exact bot name > "Bot: something" > any 1:1 room
+ dm_room_id = None
+ named_dm_id = None
+ any_dm_id = None
+ bot_name = self.client.user_id.split(":")[0].lstrip("@")
+ for room in self.client.rooms.values():
+ if len(room.users) == 2 and self.admin_mxid in room.users:
+ name = (room.name or "").strip()
+ if name.lower() == bot_name.lower():
+ dm_room_id = room.room_id
+ break
+ if bot_name.lower() in name.lower() and not named_dm_id:
+ named_dm_id = room.room_id
+ if not any_dm_id:
+ any_dm_id = room.room_id
+ if not dm_room_id:
+ dm_room_id = named_dm_id or any_dm_id
+
+ if not dm_room_id:
+ # Create DM room with admin
+ resp = await self.client.room_create(
+ visibility="private",
+ preset="trusted_private_chat",
+ invite=[self.admin_mxid],
+ )
+ if hasattr(resp, "room_id"):
+ dm_room_id = resp.room_id
+ logger.info("Created DM room with admin: %s", dm_room_id)
+
+ if dm_room_id:
+ room_link = f"https://matrix.to/#/{room_id}"
+ await self.client.room_send(
+ dm_room_id, "m.room.message",
+ {
+ "msgtype": "m.notice",
+ "body": f"⚠️ Fallback (z.ai) used for room {room_link} (sender: {sender})",
+ },
+ ignore_unverified_devices=True,
+ )
+
+ async def _call_claude(self, room_id: str, safe_id: str, message: str,
+ sender: str = "", on_status=None, cancel_event=None,
+ idle_timeout_ref=None, **kwargs) -> str:
+ """Call Claude CLI with typing indicator and status updates."""
+ await self.client.room_typing(room_id, typing_state=True, timeout=30000)
+ try:
+ response = await claude_send(
+ self.config, safe_id, message,
+ on_status=on_status, cancel_event=cancel_event,
+ idle_timeout_ref=idle_timeout_ref,
+ **kwargs,
+ )
+ # Check if fallback was used and notify owner
+ if "(via z.ai fallback)" in response and sender:
+ asyncio.create_task(self._notify_fallback_used(room_id, sender))
+ return response
+ finally:
+ await self.client.room_typing(room_id, typing_state=False)
+
+ # --- Bot commands ---
+
+ async def _handle_status(self, room: MatrixRoom) -> None:
+ """Handle !status: show room/session info."""
+ safe_id = room.room_id.replace(":", "_").replace("!", "")
+ topic_dir = self._topic_dir(safe_id)
+ is_busy = room.room_id in self._active_sessions
+ lines = [f"**Status: {'working' if is_busy else 'idle'}**", f"Room: `{safe_id}`"]
+
+ # Session info
+ session_file = topic_dir / "session.txt"
+ if session_file.exists():
+ sid = session_file.read_text().strip()
+ lines.append(f"Session: `{sid[:12]}...`")
+ else:
+ lines.append("Session: new")
+
+ # Topic dir size
+ if topic_dir.exists():
+ total = sum(f.stat().st_size for f in topic_dir.rglob("*") if f.is_file())
+ files = sum(1 for f in topic_dir.rglob("*") if f.is_file())
+ if total < 1024:
+ size_str = f"{total} B"
+ elif total < 1024 * 1024:
+ size_str = f"{total // 1024} KB"
+ else:
+ size_str = f"{total // (1024 * 1024)} MB"
+ lines.append(f"Dir: {files} files, {size_str}")
+
+ # Interaction count from log
+ log_file = self._room_dir(room.room_id) / "log.jsonl"
+ if log_file.exists():
+ count = sum(1 for _ in open(log_file))
+ lines.append(f"Interactions: {count}")
+
+ # Auth info
+ if os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"):
+ lines.append("Auth: `CLAUDE_CODE_OAUTH_TOKEN` (long-lived)")
+ else:
+ lines.append("Auth: OAuth credentials (short-lived)")
+
+ await self._send_response(room.room_id, "\n".join(lines))
+
+ async def _handle_help(self, room: MatrixRoom) -> None:
+ """Show available commands."""
+ room_id = room.room_id
+ mode = self._get_room_mode(room_id)
+ await self._send_response(room_id,
+ f"**Commands:**\n"
+ f"`!new [topic]` — new conversation room\n"
+ f"`!mode [mode]` — set room mode (current: `{mode}`)\n"
+ f" `quiet` — transcribe voice only\n"
+ f" `context` — include recent history\n"
+ f" `full` — persistent session with full history\n"
+ f" `collect` — accumulate notes/images/voice, no replies\n"
+ f"`!stop` — stop active Claude session\n"
+ f"`!status` — bot status and active sessions\n"
+ f"`!security [mode]` — room security level\n"
+ f"`!claude-auth` — refresh OAuth token (admin, 1:1 only)\n"
+ f"`!help` — this message")
+
+ async def _handle_mode_command(self, room: MatrixRoom, args: str) -> None:
+ """Handle !mode [quiet|context|full]: set or show room mode."""
+ room_id = room.room_id
+ mode = args.strip().lower()
+ if not mode:
+ current = self._get_room_mode(room_id)
+ await self._send_response(room_id,
+ f"**Mode:** `{current}`\n"
+ f"Available: `quiet` (transcribe only), `context` (recent history), "
+ f"`full` (persistent session), `collect` (accumulate context, no replies)")
+ return
+ if mode not in self.ROOM_MODES:
+ await self._send_response(room_id,
+ f"Unknown mode `{mode}`. Use: quiet, context, full, collect")
+ return
+ prev_mode = self._get_room_mode(room_id)
+ self._set_room_mode(room_id, mode)
+
+ # When leaving collect mode, summarize what was accumulated
+ if prev_mode == "collect" and mode != "collect":
+ summary = self._collect_summary(room_id)
+ if summary:
+ await self._send_response(room_id,
+ f"Mode set to `{mode}`\n\n{summary}")
+ # Store preamble for next Claude call
+ safe_id = room_id.replace(":", "_").replace("!", "")
+ self._collect_preambles[safe_id] = summary
+ else:
+ await self._send_response(room_id, f"Mode set to `{mode}`")
+ else:
+ await self._send_response(room_id, f"Mode set to `{mode}`")
+
+ def _collect_summary(self, room_id: str) -> str:
+ """Summarize what was accumulated in collect mode."""
+ history_file = self._room_dir(room_id) / "history.jsonl"
+ if not history_file.exists():
+ return ""
+ images, voice, texts, files = 0, 0, 0, 0
+ try:
+ with open(history_file) as f:
+ for line in f:
+ line = line.strip()
+ if not line:
+ continue
+ msg = json.loads(line)
+ mtype = msg.get("type", "text")
+ sender = msg.get("sender", "")
+ if sender == self.client.user_id:
+ continue # skip bot messages
+ if mtype == "image":
+ images += 1
+ elif mtype == "audio":
+ voice += 1
+ elif mtype == "file":
+ files += 1
+ else:
+ texts += 1
+ except Exception:
+ return ""
+ parts = []
+ if images:
+ parts.append(f"{images} image(s)")
+ if voice:
+ parts.append(f"{voice} voice note(s)")
+ if texts:
+ parts.append(f"{texts} text message(s)")
+ if files:
+ parts.append(f"{files} file(s)")
+ if not parts:
+ return ""
+ return f"Accumulated: {', '.join(parts)}"
+
+ async def _handle_security_command(self, room: MatrixRoom, sender: str, args: str) -> None:
+ """Handle !security [strict|guarded|open]: set or show room security mode."""
+ room_id = room.room_id
+ mode = args.strip().lower()
+ if not mode:
+ current = self._get_security_mode(room_id)
+ unverified = self._get_unverified_devices(room_id)
+ lines = [
+ f"**Security:** `{current}`",
+ "Available: `strict` (block all if unverified), "
+ "`guarded` (block unverified users), `open` (allow all + warning)",
+ ]
+ if unverified:
+ lines.append(self._format_unverified_warning(unverified))
+ else:
+ lines.append("All devices in room are verified.")
+ await self._send_response(room_id, "\n".join(lines))
+ return
+ if mode not in self.SECURITY_MODES:
+ await self._send_response(room_id,
+ f"Unknown security mode `{mode}`. Use: strict, guarded, open")
+ return
+ # Loosening security requires fully verified sender
+ current = self._get_security_mode(room_id)
+ mode_rank = {"strict": 2, "guarded": 1, "open": 0}
+ if mode_rank[mode] < mode_rank[current]:
+ if not self._user_fully_verified(sender):
+ await self._send_response(room_id,
+ "Only users with all devices verified can loosen security.")
+ return
+ self._set_security_mode(room_id, mode)
+ await self._send_response(room_id, f"Security set to `{mode}`")
+
+ async def _handle_claude_auth_command(self, room: MatrixRoom, sender: str, args: str) -> None:
+ """Handle !claude-auth command: refresh Claude Code OAuth token.
+
+ Restricted to admin (MATRIX_ADMIN_MXID) in 1:1 rooms only.
+
+ Flow:
+ 1. !claude-auth -> runs `claude setup-token` in tmux, extracts URL
+ 2. User opens URL, authenticates, copies token
+ 3. User pastes token here -> bot feeds it to tmux via send-keys
+ 4. `claude setup-token` finishes and writes credentials itself
+ """
+ room_id = room.room_id
+
+ # Admin-only, 1:1 rooms only (token must not leak to group chat history)
+ if not self.admin_mxid or sender != self.admin_mxid:
+ await self._send_response(room_id, "This command is admin-only.")
+ return
+ if self._is_group_room(room):
+ await self._send_response(room_id, "This command only works in 1:1 rooms (token security).")
+ return
+
+ safe_id = room_id.replace(":", "_").replace("!", "")
+
+ # Phase 2: user pasted the token — feed it to tmux
+ if safe_id in self._auth_flows:
+ token = args.strip()
+ flow = self._auth_flows.get(safe_id, {})
+ tmux_session = flow.get("tmux_session")
+
+ if not tmux_session:
+ self._auth_flows.pop(safe_id, None)
+ await self._send_response(room_id, "Auth flow lost its tmux session. Run `!claude-auth` again.")
+ return
+
+ try:
+ # Feed token to claude setup-token via tmux
+ proc = await asyncio.create_subprocess_exec(
+ "tmux", "send-keys", "-t", tmux_session, token, "Enter",
+ stdout=asyncio.subprocess.DEVNULL,
+ stderr=asyncio.subprocess.PIPE
+ )
+ _, stderr = await proc.communicate()
+ if proc.returncode != 0:
+ self._auth_flows.pop(safe_id, None)
+ await self._send_response(room_id,
+ f"Failed to send token to tmux: {stderr.decode().strip()}\nRun `!claude-auth` again.")
+ return
+
+ # Wait for setup-token to process and exit
+ await self._send_response(room_id, "Token sent to `claude setup-token`, waiting for it to finish...")
+
+ success = False
+ for _ in range(15):
+ await asyncio.sleep(1)
+ # Check if tmux session still exists
+ check = await asyncio.create_subprocess_exec(
+ "tmux", "has-session", "-t", tmux_session,
+ stdout=asyncio.subprocess.DEVNULL,
+ stderr=asyncio.subprocess.DEVNULL
+ )
+ await check.wait()
+ if check.returncode != 0:
+ # Session exited — setup-token finished
+ success = True
+ break
+
+ # Also check pane output for success/error messages
+ cap = await asyncio.create_subprocess_exec(
+ "tmux", "capture-pane", "-t", tmux_session, "-p",
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.DEVNULL
+ )
+ stdout, _ = await cap.communicate()
+ output = stdout.decode('utf-8', errors='replace').lower()
+ if 'success' in output or 'saved' in output or 'authenticated' in output:
+ success = True
+ break
+ if 'error' in output or 'invalid' in output or 'failed' in output:
+ clean = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', stdout.decode('utf-8', errors='replace'))
+ self._auth_flows.pop(safe_id, None)
+ await self._kill_tmux(tmux_session)
+ await self._send_response(room_id,
+ f"`claude setup-token` reported an error:\n```\n{clean.strip()[-500:]}\n```")
+ return
+
+ self._auth_flows.pop(safe_id, None)
+
+ # Capture pane output BEFORE killing tmux — it contains the long-lived token
+ final_output = ""
+ if success:
+ cap = await asyncio.create_subprocess_exec(
+ "tmux", "capture-pane", "-t", tmux_session, "-p", "-S", "-100",
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.DEVNULL
+ )
+ stdout, _ = await cap.communicate()
+ final_output = stdout.decode('utf-8', errors='replace')
+
+ await self._kill_tmux(tmux_session)
+
+ if success:
+ # Extract long-lived token from setup-token output
+ clean_output = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', final_output)
+ clean_output = re.sub(r'\x1b[^a-zA-Z]*[a-zA-Z]', '', clean_output)
+ oauth_token = self._extract_oauth_token(clean_output)
+
+ if oauth_token:
+ # Try to save to deploy .env
+ saved = self._save_oauth_token_to_env(oauth_token)
+ if saved:
+ msg = "Long-lived token saved to deploy `.env`. Restart bot to apply."
+ else:
+ msg = (f"Token extracted. Set in deploy `.env` and restart:\n"
+ f"`CLAUDE_CODE_OAUTH_TOKEN={oauth_token}`")
+ else:
+ msg = "Auth completed but could not extract long-lived token from output."
+
+ # Also verify with claude auth status
+ status_proc = await asyncio.create_subprocess_exec(
+ "claude", "auth", "status",
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE
+ )
+ status_out, _ = await status_proc.communicate()
+ status_text = status_out.decode('utf-8', errors='replace').strip()
+
+ await self._send_response(room_id,
+ f"{msg}\n\n```\n{status_text[:500]}\n```")
+ logger.info("Claude auth flow completed for room %s (token saved: %s)",
+ room_id, bool(oauth_token))
+ else:
+ await self._send_response(room_id,
+ "`claude setup-token` didn't finish within 15s. "
+ "Check manually with `claude auth status`.")
+
+ except Exception as e:
+ self._auth_flows.pop(safe_id, None)
+ await self._kill_tmux(tmux_session)
+ logger.error("Error feeding token to tmux: %s", e)
+ await self._send_response(room_id, f"Error: {e}")
+ return
+
+ # Phase 1: start claude setup-token in tmux, extract URL
+ await self._send_response(room_id, "Starting Claude Code OAuth flow...")
+
+ tmux_session = f"claude-auth-{safe_id[:20]}"
+
+ try:
+ # Kill any leftover session
+ await self._kill_tmux(tmux_session)
+ await asyncio.sleep(0.3)
+
+ # Start claude setup-token in tmux
+ proc = await asyncio.create_subprocess_exec(
+ "tmux", "new-session", "-d", "-s", tmux_session,
+ "-x", "200", "-y", "50",
+ "claude", "setup-token"
+ )
+ await proc.wait()
+
+ # Poll for the OAuth URL to appear
+ output = ""
+ for _ in range(15):
+ await asyncio.sleep(1)
+
+ cap = await asyncio.create_subprocess_exec(
+ "tmux", "capture-pane", "-t", tmux_session, "-p",
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.DEVNULL
+ )
+ stdout, _ = await cap.communicate()
+ output = stdout.decode('utf-8', errors='replace')
+
+ if 'oauth/authorize' in output.lower() or 'console.anthropic.com' in output.lower():
+ break
+
+ # Strip ANSI escapes
+ clean_output = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', output)
+ clean_output = re.sub(r'\x1b[^a-zA-Z]*[a-zA-Z]', '', clean_output)
+
+ # tmux wraps long URLs across lines — join continuation lines
+ # Remove newlines that break mid-URL (lines not starting with whitespace
+ # after a line ending with a URL-safe char)
+ lines = clean_output.split('\n')
+ joined = lines[0] if lines else ''
+ for line in lines[1:]:
+ stripped = line.strip()
+ # If prev line ends with URL-safe char and this line looks like URL continuation
+ if stripped and not stripped.startswith(('$', '#', '>', ' ')) and re.match(r'^[a-zA-Z0-9%&=_.~:/?#\[\]@!$\'()*+,;-]', stripped):
+ # Check if we're likely in a URL context
+ if joined.rstrip().endswith(tuple('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789%&=_.-~:/?#[]@!$\'()*+,;')):
+ joined += stripped
+ continue
+ joined += '\n' + line
+ clean_output = joined
+
+ # Extract URL
+ url_match = re.search(r'(https://[^\s]*(?:oauth/authorize|console\.anthropic\.com)[^\s]*)', clean_output)
+
+ if not url_match:
+ await self._kill_tmux(tmux_session)
+ await self._send_response(room_id,
+ "Could not extract auth URL from `claude setup-token`.\n"
+ f"```\n{clean_output.strip()[:500]}\n```")
+ logger.warning("claude setup-token output: %s", clean_output)
+ return
+
+ auth_url = url_match.group(1)
+
+ # Register auth flow
+ self._auth_flows[safe_id] = {
+ "tmux_session": tmux_session,
+ "started": time.time()
+ }
+
+ await self._send_response(room_id,
+ "**Claude Code Authentication**\n\n"
+ f"1. Open: {auth_url}\n\n"
+ "2. Authenticate and copy the token from the page\n\n"
+ "3. Paste it here\n\n"
+ "Flow expires in 5 minutes."
+ )
+
+ # Timeout cleanup
+ async def _auth_cleanup():
+ await asyncio.sleep(300)
+ if safe_id in self._auth_flows:
+ flow = self._auth_flows.pop(safe_id, {})
+ await self._kill_tmux(flow.get("tmux_session"))
+ await self._send_response(room_id, "Auth flow expired. Run `!claude-auth` to restart.")
+
+ asyncio.create_task(_auth_cleanup())
+
+ except Exception as e:
+ await self._kill_tmux(tmux_session)
+ logger.error("Error starting claude setup-token: %s", e)
+ await self._send_response(room_id, f"Error: {e}")
+
+ async def _kill_tmux(self, session: str | None) -> None:
+ """Kill a tmux session if it exists."""
+ if not session:
+ return
+ proc = await asyncio.create_subprocess_exec(
+ "tmux", "kill-session", "-t", session,
+ stdout=asyncio.subprocess.DEVNULL,
+ stderr=asyncio.subprocess.DEVNULL
+ )
+ await proc.wait()
+
+ @staticmethod
+ def _extract_oauth_token(text: str) -> str | None:
+ """Extract CLAUDE_CODE_OAUTH_TOKEN from setup-token output."""
+ # Look for the token after "export CLAUDE_CODE_OAUTH_TOKEN=" or similar
+ m = re.search(r'CLAUDE_CODE_OAUTH_TOKEN[=\s]+([a-zA-Z0-9_\-]+)', text)
+ if m:
+ return m.group(1)
+ # Fallback: look for sk-ant-oat pattern (setup-token format)
+ m = re.search(r'(sk-ant-oat[a-zA-Z0-9_\-]+)', text)
+ if m:
+ return m.group(1)
+ return None
+
+ def _save_oauth_token_to_env(self, token: str) -> bool:
+ """Save CLAUDE_CODE_OAUTH_TOKEN to workspace .env file."""
+ if not self.config.workspace_dir:
+ return False
+ env_path = Path(self.config.workspace_dir) / ".env"
+ try:
+ content = env_path.read_text() if env_path.exists() else ""
+ if "CLAUDE_CODE_OAUTH_TOKEN=" in content:
+ content = re.sub(
+ r'CLAUDE_CODE_OAUTH_TOKEN=.*',
+ f'CLAUDE_CODE_OAUTH_TOKEN={token}',
+ content
+ )
+ else:
+ content = content.rstrip('\n') + f'\nCLAUDE_CODE_OAUTH_TOKEN={token}\n'
+ env_path.write_text(content)
+ os.chmod(env_path, 0o600)
+ logger.info("Saved CLAUDE_CODE_OAUTH_TOKEN to %s", env_path)
+ return True
+ except Exception as e:
+ logger.error("Failed to save token to %s: %s", env_path, e)
+ return False
+
+ async def _handle_new_command(self, room: MatrixRoom, event_sender: str, topic: str) -> None:
+ """Handle !new command: create a new conversation room and invite user."""
+ room_id = room.room_id
+ name = topic.strip() if topic.strip() else f"{self._default_room_prefix}Новый чат"
+
+ new_room_id = await self._create_conversation_room(name, for_user=event_sender)
+ if not new_room_id:
+ await self._send_response(room_id, "Failed to create room.")
+ return
+
+ room_link = f"https://matrix.to/#/{new_room_id}"
+ display_name = name.removeprefix(self._default_room_prefix)
+ await self.client.room_send(
+ room_id, "m.room.message",
+ {
+ "msgtype": "m.text",
+ "body": f"{display_name}: {room_link}",
+ "format": "org.matrix.custom.html",
+ "formatted_body": f"{display_name}",
+ },
+ ignore_unverified_devices=True,
+ )
+ logger.info("Created /new room %s: %s", new_room_id, name)
+
+ # --- Message handlers ---
+
+ async def _handle_text(self, room: MatrixRoom, event: RoomMessageText) -> None:
+ is_group = self._is_group_room(room)
+
+ # 1:1 rooms: only owner can use the bot
+ # Group rooms: anyone can mention the bot
+ if not is_group and not self._is_allowed_user(event.sender):
+ return
+
+ user_text = event.body
+ room_id = room.room_id
+ safe_id = room_id.replace(":", "_").replace("!", "")
+
+ # Check if this is a session command — thread reply or !command while busy
+ session = self._active_sessions.get(room_id)
+ if session:
+ relates_to = event.source.get("content", {}).get("m.relates_to", {})
+ is_thread = relates_to.get("rel_type") == "m.thread"
+ is_bang_cmd = user_text.strip().lower().lstrip("!") in (
+ "stop", "cancel", "abort", "+5m", "+5", "+10m", "+10",
+ )
+ if is_thread or is_bang_cmd:
+ if await self._handle_thread_command(room_id, user_text, session):
+ return
+
+ # Strip mention prefix (e.g. "Bot: !status" → "!status")
+ command_text = self._strip_mention_prefix(user_text)
+
+ # If Claude is waiting for an answer in this room, deliver it
+ if safe_id in self._pending_questions:
+ future = self._pending_questions.pop(safe_id)
+ if not future.done():
+ future.set_result(user_text)
+ return
+
+ # Check if we're in an auth flow for this room
+ if safe_id in self._auth_flows:
+ # Only intercept if it looks like a token (long, no spaces, no command prefix)
+ candidate = user_text.strip()
+ if len(candidate) > 20 and ' ' not in candidate and not candidate.startswith('!'):
+ # Redact the token message from chat history
+ try:
+ await self.client.room_redact(room_id, event.event_id, reason="auth token")
+ except Exception:
+ pass # best-effort, E2E rooms may not support redaction
+ await self._handle_claude_auth_command(room, event.sender, user_text)
+ return
+ # If it looks like a command or normal message, check for !claude-auth cancel
+ if candidate.lower() in ('!cancel', '!claude-auth cancel', 'cancel'):
+ flow = self._auth_flows.pop(safe_id, {})
+ await self._kill_tmux(flow.get("tmux_session"))
+ await self._send_response(room_id, "Auth flow cancelled.")
+ return
+ # Fall through to normal message handling
+
+ # Bot commands — only allowed users
+ if self._is_allowed_user(event.sender):
+ if command_text.strip() in ("!help", "!commands", "!?"):
+ await self._handle_help(room)
+ return
+ if command_text.startswith("!new"):
+ topic = command_text[4:].strip()
+ await self._handle_new_command(room, event.sender, topic)
+ return
+ if command_text.strip() == "!status":
+ await self._handle_status(room)
+ return
+ if command_text.startswith("!mode"):
+ await self._handle_mode_command(room, command_text[5:])
+ return
+ if command_text.startswith("!security"):
+ await self._handle_security_command(room, event.sender, command_text[9:])
+ return
+ if command_text.strip() in ("!claude-auth", "!claudeauth"):
+ await self._handle_claude_auth_command(room, event.sender, "")
+ return
+
+ mode = self._get_room_mode(room_id)
+
+ # Group rooms: only respond when mentioned (quiet/context modes)
+ if is_group and mode not in ("full", "collect"):
+ logger.info("Group room %s (members=%d), checking mention", room_id, room.member_count)
+ if not self._is_bot_mentioned(event):
+ logger.info("Not mentioned in group room, skipping")
+ return
+
+ # Collect mode: save to history, acknowledge, no Claude
+ if mode == "collect":
+ self._save_room_message(room_id, event.sender, "text", user_text)
+ return
+
+ # Check if already processing in this room — queue if busy
+ if self._is_room_busy(room_id):
+ self._enqueue_message(room_id, event.event_id, event.sender, user_text)
+ return
+
+ # Security check — after mention check, before Claude interaction
+ allowed, security_msg = await self._check_security(room_id, event.sender)
+ if not allowed:
+ await self._send_response(room_id, security_msg)
+ return
+
+ # In full mode, save every message to room history
+ if mode == "full":
+ self._save_room_message(room_id, event.sender, "text", user_text)
+
+ # Build message for Claude
+ message_for_claude = user_text
+ if mode == "context":
+ recent = await self._fetch_recent_messages(room_id, limit=10)
+ if recent:
+ context_lines = [f"{m['sender']}: {m['text']}" for m in recent]
+ context_block = "\n".join(context_lines)
+ message_for_claude = (
+ "[Recent room messages for context]\n"
+ f"{context_block}\n\n---\n\n{user_text}"
+ )
+ elif mode == "full":
+ context = self._get_room_context(room_id)
+ if context:
+ message_for_claude = context + "\n\n---\n\n" + user_text
+
+ # Inject collect mode preamble if switching from collect
+ preamble = self._collect_preambles.pop(safe_id, "")
+ if preamble:
+ message_for_claude = (
+ "[CONTEXT UPDATE: User just switched from COLLECT mode. "
+ "New material was accumulated in this room's history — images, voice notes, "
+ "and/or text that you haven't seen yet. Review the conversation history above carefully, "
+ "especially entries with [image:] paths (use Read tool to view them) "
+ "and voice transcriptions. Process all accumulated material before responding.]\n\n"
+ + message_for_claude
+ )
+
+ async def _on_done(response: str):
+ self._pending_questions.pop(safe_id, None)
+ if mode == "full":
+ self._save_room_message(room_id, self.client.user_id, "text", response)
+ await self._rename_room(room_id, safe_id, user_text=user_text, response=response)
+ self._log_interaction(room_id, user_text, response)
+
+ await self._run_claude_session(
+ room, event, message_for_claude,
+ security_msg=security_msg, on_done=_on_done,
+ )
+
+ async def _handle_image(self, room: MatrixRoom, event) -> None:
+ if not self._is_allowed_user(event.sender):
+ return
+ mode = self._get_room_mode(room.room_id)
+ if self._is_group_room(room) and mode not in ("full", "collect"):
+ return
+
+ room_id = room.room_id
+ safe_id = room_id.replace(":", "_").replace("!", "")
+
+ # Download and save image regardless of mode
+ images_dir = self._room_dir(room_id) / "images"
+ images_dir.mkdir(exist_ok=True)
+
+ data = await self._download_media(event)
+ if data is None:
+ return
+
+ ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
+ filename = f"{ts}_{event.body or 'image'}"
+ if not any(filename.endswith(ext) for ext in (".jpg", ".jpeg", ".png", ".webp", ".gif")):
+ filename += ".jpg"
+ filepath = images_dir / filename
+ with open(filepath, "wb") as f:
+ f.write(data)
+
+ caption = event.body if event.body and event.body != "image" else ""
+
+ # Collect mode: save to history, no Claude
+ if mode == "collect":
+ history_text = f"[image: {filepath}]"
+ if caption:
+ history_text += f" {caption}"
+ self._save_room_message(room_id, event.sender, "image", history_text, file_path=str(filepath))
+ return
+
+ # Security check
+ allowed, security_msg = await self._check_security(room_id, event.sender)
+ if not allowed:
+ await self._send_response(room_id, security_msg)
+ return
+
+ message = f"User sent an image: {filepath}"
+ if caption:
+ message += f"\nCaption: {caption}"
+
+ if self._is_room_busy(room_id):
+ history_text = f"[image: {filepath}]"
+ if caption:
+ history_text += f" {caption}"
+ self._enqueue_message(room_id, event.event_id, event.sender,
+ history_text, msg_type="image", file_path=str(filepath))
+ return
+
+ async def _on_done(response: str):
+ await self._rename_room(room_id, safe_id, user_text=message, response=response)
+ self._log_interaction(room_id, f"[image] {event.body}", response)
+
+ await self._run_claude_session(
+ room, event, message, security_msg=security_msg, on_done=_on_done,
+ )
+
+ async def _handle_audio(self, room: MatrixRoom, event) -> None:
+ is_group = self._is_group_room(room)
+ if not is_group and not self._is_allowed_user(event.sender):
+ return
+
+ room_id = room.room_id
+ safe_id = room_id.replace(":", "_").replace("!", "")
+ mode = self._get_room_mode(room_id)
+ voice_dir = self._room_dir(room_id) / "voice"
+ voice_dir.mkdir(exist_ok=True)
+
+ data = await self._download_media(event)
+ if data is None:
+ return
+
+ ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
+ filename = f"{ts}_{event.body or 'voice.ogg'}"
+ filepath = voice_dir / filename
+ with open(filepath, "wb") as f:
+ f.write(data)
+
+ # Transcribe
+ transcribed_text = None
+ engine_tag = ""
+ if self.config.stt_url:
+ try:
+ transcribed_text, engine_tag = await transcribe(
+ str(filepath), self.config.stt_url,
+ whisper_url=os.environ.get("STT_SHORT_URL"),
+ )
+ logger.info("Transcribed voice in room %s: %d chars [%s]",
+ room.display_name, len(transcribed_text), engine_tag)
+ except RuntimeError as e:
+ logger.error("ASR failed for room %s: %s", room.display_name, e)
+
+ # Post transcription with sender attribution + engine tag
+ if transcribed_text:
+ sender_name = self._sender_display_name(room, event.sender)
+ notice = f"🎙 {sender_name}: {transcribed_text}"
+ if engine_tag and os.environ.get("STT_SHORT_URL"):
+ notice += f" // {engine_tag}"
+ await self.client.room_send(
+ room_id, "m.room.message",
+ {"msgtype": "m.notice", "body": notice},
+ ignore_unverified_devices=True,
+ )
+
+ # Save to history in full/collect modes
+ if mode in ("full", "collect"):
+ history_text = transcribed_text or f"[audio: {filepath}]"
+ self._save_room_message(room_id, event.sender, "audio", history_text, file_path=str(filepath))
+
+ # Collect mode: transcribe and save, no Claude
+ if mode == "collect":
+ return
+
+ # Decide whether to respond via Claude
+ should_respond = not is_group # always respond in 1:1
+ if is_group and transcribed_text and self._text_mentions_bot(transcribed_text):
+ should_respond = True
+ if not should_respond:
+ return
+
+ if self._is_room_busy(room_id):
+ queue_text = transcribed_text or f"[audio: {filepath}]"
+ self._enqueue_message(room_id, event.event_id, event.sender,
+ queue_text, msg_type="audio", file_path=str(filepath))
+ return
+
+ # Security check — before Claude interaction
+ allowed, security_msg = await self._check_security(room_id, event.sender)
+ if not allowed:
+ await self._send_response(room_id, security_msg)
+ return
+
+ # Build message for Claude
+ if transcribed_text:
+ message = f"[voice message transcription]: {transcribed_text}"
+ else:
+ message = f"User sent a voice message: {filepath}"
+
+ if mode == "context":
+ recent = await self._fetch_recent_messages(room_id, limit=10)
+ if recent:
+ context_lines = [f"{m['sender']}: {m['text']}" for m in recent]
+ context_block = "\n".join(context_lines)
+ message = f"[Recent room messages for context]\n{context_block}\n\n---\n\n{message}"
+
+ async def _on_done(response: str):
+ if mode == "full":
+ self._save_room_message(room_id, self.client.user_id, "text", response)
+ await self._rename_room(room_id, safe_id, user_text=message, response=response)
+ self._log_interaction(room_id, message, response)
+
+ await self._run_claude_session(
+ room, event, message, security_msg=security_msg, on_done=_on_done,
+ )
+
+ async def _handle_file(self, room: MatrixRoom, event) -> None:
+ if not self._is_allowed_user(event.sender):
+ return
+ mode = self._get_room_mode(room.room_id)
+ if self._is_group_room(room) and mode not in ("full", "collect"):
+ return
+
+ room_id = room.room_id
+ safe_id = room_id.replace(":", "_").replace("!", "")
+
+ # Download and save file regardless of mode
+ docs_dir = self._room_dir(room_id) / "documents"
+ docs_dir.mkdir(exist_ok=True)
+
+ data = await self._download_media(event)
+ if data is None:
+ return
+
+ ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
+ orig_name = event.body or "document"
+ filename = f"{ts}_{orig_name}"
+ filepath = docs_dir / filename
+ with open(filepath, "wb") as f:
+ f.write(data)
+
+ # Collect mode: save to history, no Claude
+ if mode == "collect":
+ self._save_room_message(room_id, event.sender, "file",
+ f"[file: {orig_name}]", file_path=str(filepath))
+ return
+
+ # Security check
+ allowed, security_msg = await self._check_security(room_id, event.sender)
+ if not allowed:
+ await self._send_response(room_id, security_msg)
+ return
+
+ message = f"User sent a document: {filepath} (name: {orig_name}, size: {len(data)} bytes)"
+
+ if self._is_room_busy(room_id):
+ self._enqueue_message(room_id, event.event_id, event.sender,
+ f"[file: {orig_name}]", msg_type="file", file_path=str(filepath))
+ return
+
+ async def _on_done(response: str):
+ await self._rename_room(room_id, safe_id, user_text=message, response=response)
+ self._log_interaction(room_id, f"[document: {orig_name}]", response)
+
+ await self._run_claude_session(
+ room, event, message, security_msg=security_msg, on_done=_on_done,
+ )
+
+ # --- E2E cross-signing & trust ---
+
+ async def _setup_cross_signing(self) -> None:
+ """Generate cross-signing keys (or load existing) and self-sign device."""
+ if not self.client.olm:
+ return
+ import base64
+ import olm as _olm
+
+ seeds_path = self.config.data_dir / "crypto_store" / "cross_signing_seeds.json"
+
+ # Load or generate seeds
+ if seeds_path.exists():
+ seeds = json.loads(seeds_path.read_text())
+ master_seed = base64.b64decode(seeds["master_seed"])
+ self_signing_seed = base64.b64decode(seeds["self_signing_seed"])
+ user_signing_seed = base64.b64decode(seeds["user_signing_seed"])
+ else:
+ master_seed = _olm.PkSigning.generate_seed()
+ self_signing_seed = _olm.PkSigning.generate_seed()
+ user_signing_seed = _olm.PkSigning.generate_seed()
+ seeds_path.parent.mkdir(parents=True, exist_ok=True)
+ seeds_path.write_text(json.dumps({
+ "master_seed": base64.b64encode(master_seed).decode(),
+ "self_signing_seed": base64.b64encode(self_signing_seed).decode(),
+ "user_signing_seed": base64.b64encode(user_signing_seed).decode(),
+ }))
+
+ master = _olm.PkSigning(master_seed)
+ self_signing = _olm.PkSigning(self_signing_seed)
+ _olm.PkSigning(user_signing_seed) # validate
+
+ def _canonical(obj):
+ return json.dumps(obj, separators=(",", ":"), sort_keys=True, ensure_ascii=False)
+
+ def _sign(obj, key_id, signing_key):
+ to_sign = {k: v for k, v in obj.items() if k not in ("signatures", "unsigned")}
+ sig = signing_key.sign(_canonical(to_sign))
+ obj.setdefault("signatures", {}).setdefault(self.client.user_id, {})[key_id] = sig
+
+ user_id = self.client.user_id
+ hs = self.client.homeserver
+
+ async with httpx.AsyncClient() as http:
+ headers = {"Authorization": f"Bearer {self.client.access_token}",
+ "Content-Type": "application/json"}
+
+ # Check if already uploaded
+ resp = await http.post(f"{hs}/_matrix/client/v3/keys/query",
+ headers=headers, json={"device_keys": {user_id: []}}, timeout=10)
+ existing = resp.json().get("master_keys", {}).get(user_id)
+ if existing:
+ logger.info("Cross-signing keys already uploaded")
+ else:
+ # Build and upload cross-signing keys
+ master_key = {"user_id": user_id, "usage": ["master"],
+ "keys": {f"ed25519:{master.public_key}": master.public_key}}
+ self_signing_key = {"user_id": user_id, "usage": ["self_signing"],
+ "keys": {f"ed25519:{self_signing.public_key}": self_signing.public_key}}
+ user_signing_key_obj = {"user_id": user_id, "usage": ["user_signing"],
+ "keys": {f"ed25519:{_olm.PkSigning(user_signing_seed).public_key}":
+ _olm.PkSigning(user_signing_seed).public_key}}
+ _sign(self_signing_key, f"ed25519:{master.public_key}", master)
+ _sign(user_signing_key_obj, f"ed25519:{master.public_key}", master)
+ resp = await http.post(f"{hs}/_matrix/client/v3/keys/device_signing/upload",
+ headers=headers, timeout=10,
+ json={"master_key": master_key,
+ "self_signing_key": self_signing_key,
+ "user_signing_key": user_signing_key_obj})
+ if resp.status_code == 401:
+ session = resp.json().get("session", "")
+ resp = await http.post(f"{hs}/_matrix/client/v3/keys/device_signing/upload",
+ headers=headers, timeout=10,
+ json={"master_key": master_key,
+ "self_signing_key": self_signing_key,
+ "user_signing_key": user_signing_key_obj,
+ "auth": {"type": "m.login.dummy", "session": session}})
+ if resp.status_code == 200:
+ logger.info("Uploaded cross-signing keys")
+ else:
+ logger.error("Failed to upload cross-signing keys (%d): %s",
+ resp.status_code, resp.text[:200])
+ return
+
+ # Self-sign our device with self-signing key
+ resp = await http.post(f"{hs}/_matrix/client/v3/keys/query",
+ headers=headers, json={"device_keys": {user_id: []}}, timeout=10)
+ device_keys = resp.json()["device_keys"][user_id].get(self.client.device_id)
+ if not device_keys:
+ logger.error("Own device keys not found on server")
+ return
+
+ # Check if already signed by self-signing key
+ existing_sigs = device_keys.get("signatures", {}).get(user_id, {})
+ ss_key_id = f"ed25519:{self_signing.public_key}"
+ if ss_key_id in existing_sigs:
+ logger.info("Device already self-signed")
+ return
+
+ to_sign = {k: v for k, v in device_keys.items() if k not in ("signatures", "unsigned")}
+ sig = self_signing.sign(_canonical(to_sign))
+ sig_body = {user_id: {self.client.device_id: {
+ **to_sign,
+ "signatures": {user_id: {ss_key_id: sig}},
+ }}}
+ resp = await http.post(f"{hs}/_matrix/client/v3/keys/signatures/upload",
+ headers=headers, json=sig_body, timeout=10)
+ if resp.status_code == 200:
+ logger.info("Self-signed device %s", self.client.device_id)
+ else:
+ logger.error("Failed to self-sign device (%d): %s",
+ resp.status_code, resp.text[:200])
+
+ async def _sync_cross_signing_trust(self) -> None:
+ """Query server for cross-signing keys and trust devices signed by self-signing keys.
+
+ This bridges the gap between server-side cross-signing verification
+ (what Element shows as green/red) and nio's local device trust store.
+ A device is considered verified if it's signed by its owner's self-signing key.
+ """
+ if not self.client.olm:
+ return
+ hs = self.client.homeserver
+ headers = {"Authorization": f"Bearer {self.client.access_token}",
+ "Content-Type": "application/json"}
+
+ # Collect all user IDs we care about
+ user_ids = set(self._users.keys())
+ if not user_ids:
+ return
+
+ try:
+ async with httpx.AsyncClient() as http:
+ resp = await http.post(
+ f"{hs}/_matrix/client/v3/keys/query",
+ headers=headers,
+ json={"device_keys": {uid: [] for uid in user_ids}},
+ timeout=10,
+ )
+ if resp.status_code != 200:
+ logger.warning("Cross-signing trust sync failed (%d)", resp.status_code)
+ return
+ data = resp.json()
+ except Exception as e:
+ logger.warning("Cross-signing trust sync error: %s", e)
+ return
+
+ # For each user, find their self-signing key
+ for user_id in user_ids:
+ ss_key_obj = data.get("self_signing_keys", {}).get(user_id)
+ if not ss_key_obj:
+ continue
+ # Extract the self-signing public key
+ ss_keys = ss_key_obj.get("keys", {})
+ ss_pubkey = None
+ for key_id, key_val in ss_keys.items():
+ if key_id.startswith("ed25519:"):
+ ss_pubkey = key_id # e.g. "ed25519:ABCDEF..."
+ break
+ if not ss_pubkey:
+ continue
+
+ # Check each device: is it signed by the self-signing key?
+ user_devices = data.get("device_keys", {}).get(user_id, {})
+ for device_id, dev_keys in user_devices.items():
+ sigs = dev_keys.get("signatures", {}).get(user_id, {})
+ is_cross_signed = ss_pubkey in sigs
+
+ # Find this device in nio's local store
+ nio_device = None
+ for d in self.client.device_store.active_user_devices(user_id):
+ if d.id == device_id:
+ nio_device = d
+ break
+
+ if nio_device is None:
+ continue
+
+ if is_cross_signed and not nio_device.verified:
+ self.client.verify_device(nio_device)
+ logger.info("Trusted cross-signed device %s of %s", device_id, user_id)
+ elif not is_cross_signed and nio_device.verified:
+ # Device lost cross-signing — untrust it
+ # (nio has no unverify, but we can note it)
+ logger.warning("Device %s of %s no longer cross-signed", device_id, user_id)
+
+ logger.info("Cross-signing trust sync complete")
+
+ # --- Auto-join and room locking ---
+
+ async def _auto_join_invites(self) -> None:
+ for room_id in list(self.client.invited_rooms):
+ await self.client.join(room_id)
+ logger.info("Accepted invite to room %s", room_id)
+
+ def _load_sync_token(self) -> str | None:
+ if self._sync_token_path.exists():
+ token = self._sync_token_path.read_text().strip()
+ return token if token else None
+ return None
+
+ def _save_sync_token(self, token: str) -> None:
+ self._sync_token_path.parent.mkdir(parents=True, exist_ok=True)
+ self._sync_token_path.write_text(token)
+
+ async def run(self) -> None:
+ """Start the Matrix bot."""
+ # Plain events
+ self.client.add_event_callback(self._on_message, RoomMessageText)
+ self.client.add_event_callback(self._on_image, RoomMessageImage)
+ self.client.add_event_callback(self._on_audio, RoomMessageAudio)
+ self.client.add_event_callback(self._on_file, RoomMessageFile)
+ self.client.add_event_callback(self._on_member, RoomMemberEvent)
+ # Encrypted events (nio auto-decrypts to RoomMessage* types above,
+ # but encrypted media comes as RoomEncrypted* types)
+ self.client.add_event_callback(self._on_image, RoomEncryptedImage)
+ self.client.add_event_callback(self._on_audio, RoomEncryptedAudio)
+ self.client.add_event_callback(self._on_file, RoomEncryptedFile)
+ # Undecryptable events (missing keys)
+ self.client.add_event_callback(self._on_megolm, MegolmEvent)
+ # In-room verification events (Element X, FluffyChat)
+ self.client.add_event_callback(self._on_room_verify_event, RoomMessageUnknown)
+ self.client.add_event_callback(self._on_room_verify_event, UnknownEvent)
+ self.client.add_response_callback(self._on_sync, SyncResponse)
+ # SAS key verification (to-device events)
+ self.client.add_to_device_callback(self._on_verify_start, KeyVerificationStart)
+ self.client.add_to_device_callback(self._on_verify_key, KeyVerificationKey)
+ self.client.add_to_device_callback(self._on_verify_mac, KeyVerificationMac)
+ self.client.add_to_device_callback(self._on_verify_cancel, KeyVerificationCancel)
+
+ logger.info("Matrix bot starting as %s", self.client.user_id)
+
+ saved_token = self._load_sync_token()
+ if saved_token:
+ logger.info("Resuming from saved sync token")
+
+ resp = await self.client.sync(timeout=10000, since=saved_token, full_state=True)
+ if hasattr(resp, "next_batch") and resp.next_batch:
+ self._save_sync_token(resp.next_batch)
+ await self._auto_join_invites()
+ # E2E setup: upload our keys, then fetch and trust other users' devices
+ if self.client.olm:
+ if self.client.should_upload_keys:
+ await self.client.keys_upload()
+ logger.info("Uploaded device keys to server")
+ try:
+ await self.client.keys_query()
+ except Exception:
+ pass # no keys to query yet (fresh user, no rooms)
+ # Note: we intentionally do NOT auto-trust all user devices here.
+ # The security model (strict/guarded/open) handles unverified devices
+ # per room. Devices are verified via in-room verification or cross-signing.
+ await self._sync_cross_signing_trust()
+ await self._setup_cross_signing()
+ await self._set_bot_avatar()
+ self._synced = True
+ logger.info("Initial sync complete, E2E=%s, listening for new messages",
+ "enabled" if self.client.olm else "disabled")
+
+ await self.client.sync_forever(timeout=30000)
+
+ def _should_process(self, event, room: MatrixRoom | None = None) -> bool:
+ """Check if event should be processed (not own, not old, not duplicate, after sync)."""
+ eid = event.event_id
+ room_id = room.room_id if room else "?"
+ logger.info("_should_process: eid=%s sender=%s room=%s ts=%s body=%s",
+ eid, event.sender, room_id, event.server_timestamp,
+ getattr(event, 'body', '')[:50])
+ if not self._synced:
+ return False
+ if event.sender == self.client.user_id:
+ return False
+ if eid in self._processed_events:
+ logger.warning("Duplicate event %s, skipping", eid)
+ return False
+ self._processed_events.add(eid)
+ # Keep set bounded
+ if len(self._processed_events) > 1000:
+ self._processed_events = set(list(self._processed_events)[-500:])
+ return True
+
+ async def _on_message(self, room: MatrixRoom, event: RoomMessageText) -> None:
+ if not self._should_process(event, room):
+ return
+ await self._handle_text(room, event)
+
+ async def _on_image(self, room: MatrixRoom, event) -> None:
+ if not self._should_process(event, room):
+ return
+ await self._handle_image(room, event)
+
+ async def _on_audio(self, room: MatrixRoom, event) -> None:
+ if not self._should_process(event, room):
+ return
+ await self._handle_audio(room, event)
+
+ async def _on_file(self, room: MatrixRoom, event) -> None:
+ if not self._should_process(event, room):
+ return
+ await self._handle_file(room, event)
+
+ async def _on_megolm(self, room: MatrixRoom, event: MegolmEvent) -> None:
+ """Handle messages we couldn't decrypt."""
+ if not self._synced:
+ return
+ logger.warning("Could not decrypt event %s in %s from %s (session %s)",
+ event.event_id, room.room_id, event.sender,
+ event.session_id)
+
+ # --- SAS key verification (auto-accept for allowed users) ---
+
+ async def _on_verify_start(self, event: KeyVerificationStart) -> None:
+ """Incoming verification request — auto-accept from allowed users."""
+ if not self._is_allowed_user(event.sender):
+ logger.warning("Verification from non-allowed user %s, ignoring", event.sender)
+ return
+ logger.info("Verification request from %s (tx=%s), auto-accepting",
+ event.sender, event.transaction_id)
+ resp = await self.client.accept_key_verification(event.transaction_id)
+ if hasattr(resp, "message"):
+ logger.error("Failed to accept verification: %s", resp.message)
+
+ async def _on_verify_key(self, event: KeyVerificationKey) -> None:
+ """Key exchange done — emojis available. Auto-confirm (bot trusts allowed users)."""
+ sas = self.client.key_verifications.get(event.transaction_id)
+ if not sas:
+ return
+ emojis = sas.get_emoji()
+ emoji_str = " ".join(f"{e[0]} ({e[1]})" for e in emojis)
+ logger.info("Verification emojis for %s: %s", sas.other_olm_device.user_id, emoji_str)
+ resp = await self.client.confirm_short_auth_string(event.transaction_id)
+ if hasattr(resp, "message"):
+ logger.error("Failed to confirm SAS: %s", resp.message)
+
+ async def _on_verify_mac(self, event: KeyVerificationMac) -> None:
+ """MAC received — verification complete."""
+ sas = self.client.key_verifications.get(event.transaction_id)
+ if not sas:
+ return
+ if sas.verified:
+ logger.info("Device %s of %s verified via SAS",
+ sas.other_olm_device.id, sas.other_olm_device.user_id)
+ else:
+ logger.warning("SAS verification failed for %s", event.transaction_id)
+
+ async def _on_verify_cancel(self, event: KeyVerificationCancel) -> None:
+ """Verification canceled."""
+ logger.info("Verification %s canceled by %s: %s",
+ event.transaction_id, event.sender, event.reason)
+
+ # --- In-room verification (used by Element X, FluffyChat) ---
+
+ async def _on_room_verify_event(self, room: MatrixRoom, event) -> None:
+ """Handle in-room verification events (m.key.verification.*)."""
+ if not self._synced:
+ return
+ source = getattr(event, "source", {})
+ content = source.get("content", {})
+ event_type = source.get("type", "")
+ sender = source.get("sender", "")
+ event_id = source.get("event_id", "")
+ logger.debug("Room event: type=%s sender=%s eid=%s keys=%s",
+ event_type, sender, event_id, list(content.keys()))
+
+ # m.room.message with msgtype m.key.verification.request
+ if event_type == "m.room.message":
+ msgtype = content.get("msgtype", "")
+ if msgtype != "m.key.verification.request":
+ return
+ event_type = "m.key.verification.request"
+
+ if not event_type.startswith("m.key.verification."):
+ return
+
+ if sender == self.client.user_id:
+ return
+
+ if not self._is_allowed_user(sender):
+ return
+
+ # Get transaction_id from m.relates_to or from the request event_id
+ relates_to = content.get("m.relates_to", {})
+ tx_id = relates_to.get("event_id", "")
+
+ room_id = room.room_id
+ logger.info("In-room verification: %s from %s (tx=%s)", event_type, sender, tx_id or event_id)
+
+ if event_type == "m.key.verification.request":
+ tx_id = event_id # the request event_id IS the transaction_id
+ # Store SAS state
+ import olm as _olm
+ sas_obj = _olm.Sas()
+ self._room_verifications[tx_id] = {
+ "sas": sas_obj,
+ "room_id": room_id,
+ "sender": sender,
+ "from_device": content.get("from_device", ""),
+ }
+ # Send m.key.verification.ready
+ await self.client.room_send(room_id, "m.key.verification.ready", {
+ "from_device": self.client.device_id,
+ "methods": ["m.sas.v1"],
+ "m.relates_to": {"rel_type": "m.reference", "event_id": tx_id},
+ }, ignore_unverified_devices=True)
+ logger.info("Sent verification ready for tx=%s", tx_id)
+ # Send start immediately (bot always initiates SAS after ready)
+ try:
+ resp = await self.client.room_send(room_id, "m.key.verification.start", {
+ "from_device": self.client.device_id,
+ "method": "m.sas.v1",
+ "key_agreement_protocols": ["curve25519-hkdf-sha256"],
+ "hashes": ["sha256"],
+ "message_authentication_codes": ["hkdf-hmac-sha256.v2"],
+ "short_authentication_string": ["decimal", "emoji"],
+ "m.relates_to": {"rel_type": "m.reference", "event_id": tx_id},
+ }, ignore_unverified_devices=True)
+ logger.info("Sent verification start for tx=%s", tx_id)
+ except Exception as e:
+ logger.error("Failed to send verification start: %s", e)
+
+ elif event_type == "m.key.verification.accept":
+ state = self._room_verifications.get(tx_id)
+ if not state:
+ return
+ state["their_commitment"] = content.get("commitment", "")
+ state["mac_method"] = content.get("message_authentication_code", "hkdf-hmac-sha256.v2")
+ # Send our public key
+ await self.client.room_send(room_id, "m.key.verification.key", {
+ "key": state["sas"].pubkey,
+ "m.relates_to": {"rel_type": "m.reference", "event_id": tx_id},
+ }, ignore_unverified_devices=True)
+ logger.info("Sent verification key for tx=%s", tx_id)
+
+ elif event_type == "m.key.verification.start":
+ state = self._room_verifications.get(tx_id)
+ if not state:
+ return
+ # Send our key
+ await self.client.room_send(room_id, "m.key.verification.key", {
+ "key": state["sas"].pubkey,
+ "m.relates_to": {"rel_type": "m.reference", "event_id": tx_id},
+ }, ignore_unverified_devices=True)
+ logger.info("Sent verification key for tx=%s", tx_id)
+
+ elif event_type == "m.key.verification.key":
+ state = self._room_verifications.get(tx_id)
+ if not state:
+ return
+ their_key = content.get("key", "")
+ state["sas"].set_their_pubkey(their_key)
+ # Generate SAS bytes for emoji
+ sas_info = (
+ "MATRIX_KEY_VERIFICATION_SAS"
+ f"{self.client.user_id}{self.client.device_id}"
+ f"{state['sas'].pubkey}"
+ f"{state['sender']}{state['from_device']}"
+ f"{their_key}{tx_id}"
+ )
+ sas_bytes = state["sas"].generate_bytes(sas_info, 6)
+ state["sas_bytes"] = sas_bytes
+ emojis = self._sas_to_emojis(sas_bytes)
+ logger.info("Verification emojis for %s: %s", state["sender"],
+ " ".join(f"{e[0]}({e[1]})" for e in emojis))
+ # Auto-confirm: calculate and send MAC for device key + master key
+ mac_info_base = (
+ "MATRIX_KEY_VERIFICATION_MAC"
+ f"{self.client.user_id}{self.client.device_id}"
+ f"{state['sender']}{state['from_device']}{tx_id}"
+ )
+ own_device_key_id = f"ed25519:{self.client.device_id}"
+ own_ed25519 = self.client.olm.account.identity_keys["ed25519"]
+ mac_dict = {}
+ key_ids = []
+ # MAC device key
+ mac_dict[own_device_key_id] = state["sas"].calculate_mac_fixed_base64(
+ own_ed25519, mac_info_base + own_device_key_id)
+ key_ids.append(own_device_key_id)
+ # MAC master key (so other side can cross-sign our identity)
+ seeds_path = self.config.data_dir / "crypto_store" / "cross_signing_seeds.json"
+ if seeds_path.exists():
+ import base64
+ import olm as _olm
+ seeds = json.loads(seeds_path.read_text())
+ master_pubkey = _olm.PkSigning(base64.b64decode(seeds["master_seed"])).public_key
+ master_key_id = f"ed25519:{master_pubkey}"
+ mac_dict[master_key_id] = state["sas"].calculate_mac_fixed_base64(
+ master_pubkey, mac_info_base + master_key_id)
+ key_ids.append(master_key_id)
+ # KEY_IDS mac covers sorted comma-separated key ids
+ key_ids.sort()
+ keys_str = ",".join(key_ids)
+ keys_mac = state["sas"].calculate_mac_fixed_base64(
+ keys_str, mac_info_base + "KEY_IDS")
+ await self.client.room_send(room_id, "m.key.verification.mac", {
+ "keys": keys_mac,
+ "mac": mac_dict,
+ "m.relates_to": {"rel_type": "m.reference", "event_id": tx_id},
+ }, ignore_unverified_devices=True)
+ logger.info("Sent verification MAC for tx=%s", tx_id)
+
+ elif event_type == "m.key.verification.mac":
+ state = self._room_verifications.get(tx_id)
+ if not state:
+ return
+ # Send done
+ await self.client.room_send(room_id, "m.key.verification.done", {
+ "m.relates_to": {"rel_type": "m.reference", "event_id": tx_id},
+ }, ignore_unverified_devices=True)
+ # Cross-sign the user's master key with our user-signing key
+ await self._cross_sign_user(state["sender"])
+ logger.info("Verification complete for tx=%s with %s", tx_id, state["sender"])
+ self._room_verifications.pop(tx_id, None)
+
+ elif event_type == "m.key.verification.cancel":
+ logger.info("In-room verification %s canceled: %s", tx_id, content.get("reason", ""))
+ self._room_verifications.pop(tx_id, None)
+
+ elif event_type == "m.key.verification.done":
+ logger.info("In-room verification %s done by %s", tx_id, sender)
+ self._room_verifications.pop(tx_id, None)
+
+ async def _cross_sign_user(self, user_id: str) -> None:
+ """Sign user's master key with our user-signing key after successful verification."""
+ import base64
+ import olm as _olm
+
+ seeds_path = self.config.data_dir / "crypto_store" / "cross_signing_seeds.json"
+ if not seeds_path.exists():
+ logger.warning("No cross-signing seeds, cannot cross-sign user")
+ return
+
+ seeds = json.loads(seeds_path.read_text())
+ user_signing = _olm.PkSigning(base64.b64decode(seeds["user_signing_seed"]))
+
+ hs = self.client.homeserver
+ headers = {"Authorization": f"Bearer {self.client.access_token}",
+ "Content-Type": "application/json"}
+
+ async with httpx.AsyncClient() as http:
+ # Get user's master key
+ resp = await http.post(f"{hs}/_matrix/client/v3/keys/query",
+ headers=headers,
+ json={"device_keys": {user_id: []}}, timeout=10)
+ data = resp.json()
+ master_key_obj = data.get("master_keys", {}).get(user_id)
+ if not master_key_obj:
+ logger.warning("No master key found for %s", user_id)
+ return
+
+ # Sign the master key with our user-signing key
+ to_sign = {k: v for k, v in master_key_obj.items()
+ if k not in ("signatures", "unsigned")}
+ canonical = json.dumps(to_sign, separators=(",", ":"),
+ sort_keys=True, ensure_ascii=False)
+ sig = user_signing.sign(canonical)
+ us_key_id = f"ed25519:{user_signing.public_key}"
+
+ sig_body = {user_id: {
+ list(master_key_obj["keys"].keys())[0].split(":")[1]: {
+ **to_sign,
+ "signatures": {self.client.user_id: {us_key_id: sig}},
+ }
+ }}
+ resp = await http.post(f"{hs}/_matrix/client/v3/keys/signatures/upload",
+ headers=headers, json=sig_body, timeout=10)
+ if resp.status_code == 200:
+ logger.info("Cross-signed master key of %s", user_id)
+ else:
+ logger.error("Failed to cross-sign %s (%d): %s",
+ user_id, resp.status_code, resp.text[:200])
+
+ @staticmethod
+ def _sas_to_emojis(sas_bytes: bytes) -> list[tuple[str, str]]:
+ """Convert 6 SAS bytes to 7 emojis (per Matrix spec)."""
+ emoji_list = [
+ ("🐶","Dog"),("🐱","Cat"),("🦁","Lion"),("🐴","Horse"),("🦄","Unicorn"),
+ ("🐷","Pig"),("🐘","Elephant"),("🐰","Rabbit"),("🐼","Panda"),("🐔","Rooster"),
+ ("🐧","Penguin"),("🐢","Turtle"),("🐟","Fish"),("🐙","Octopus"),("🦋","Butterfly"),
+ ("🌷","Flower"),("🌳","Tree"),("🌵","Cactus"),("🍄","Mushroom"),("🌏","Globe"),
+ ("🌙","Moon"),("☁️","Cloud"),("🔥","Fire"),("🍌","Banana"),("🍎","Apple"),
+ ("🍓","Strawberry"),("🌽","Corn"),("🍕","Pizza"),("🎂","Cake"),("❤️","Heart"),
+ ("😀","Smiley"),("🤖","Robot"),("🎩","Hat"),("👓","Glasses"),("🔧","Wrench"),
+ ("🎅","Santa"),("👍","Thumbs Up"),("☂️","Umbrella"),("⌛","Hourglass"),("⏰","Clock"),
+ ("🎁","Gift"),("💡","Light Bulb"),("📕","Book"),("✏️","Pencil"),("📎","Paperclip"),
+ ("✂️","Scissors"),("🔒","Lock"),("🔑","Key"),("🔨","Hammer"),("☎️","Telephone"),
+ ("🏁","Flag"),("🚂","Train"),("🚲","Bicycle"),("✈️","Airplane"),("🚀","Rocket"),
+ ("🏆","Trophy"),("⚽","Ball"),("🎸","Guitar"),("🎺","Trumpet"),("🔔","Bell"),
+ ("⚓","Anchor"),("🎧","Headphones"),("📁","Folder"),("📌","Pin"),
+ ]
+ # 6 bytes → 42 bits → 7 × 6-bit indices
+ val = int.from_bytes(sas_bytes, "big")
+ result = []
+ for i in range(6, -1, -1):
+ idx = (val >> (i * 6)) & 0x3F
+ result.append(emoji_list[idx])
+ return result
+
+ async def _on_member(self, room: MatrixRoom, event: RoomMemberEvent) -> None:
+ """Handle member events (joins, leaves)."""
+ if not self._synced:
+ return
+ if event.sender == self.client.user_id:
+ return
+ # Query keys for new members so we know their devices
+ if event.membership == "join" and self.client.olm:
+ try:
+ await self.client.keys_query()
+ except Exception:
+ pass
+
+ async def _on_sync(self, response: SyncResponse) -> None:
+ if response.next_batch:
+ self._save_sync_token(response.next_batch)
+ if self._synced:
+ await self._auto_join_invites()
+ # Query keys and re-sync cross-signing trust when device lists change
+ if self.client.olm and response.device_list.changed:
+ try:
+ await self.client.keys_query()
+ await self._sync_cross_signing_trust()
+ except Exception:
+ pass
+
+ async def close(self) -> None:
+ await self.client.close()
diff --git a/bot-examples/matrix_main.py b/bot-examples/matrix_main.py
new file mode 100644
index 0000000..03e2e7f
--- /dev/null
+++ b/bot-examples/matrix_main.py
@@ -0,0 +1,123 @@
+"""Entry point for Matrix bot frontend."""
+
+import asyncio
+import logging
+import os
+import sys
+from pathlib import Path
+
+import httpx
+import yaml
+
+from core.config import Config
+from core.matrix_bot import MatrixBot
+
+
+def _load_dotenv(workspace: Path) -> None:
+ env_file = workspace / ".env"
+ if not env_file.exists():
+ return
+ for line in env_file.read_text().splitlines():
+ line = line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, _, value = line.partition("=")
+ key = key.strip()
+ value = value.strip().strip('"').strip("'")
+ if key not in os.environ:
+ os.environ[key] = value
+
+
+def _load_users(workspace: Path) -> dict[str, dict]:
+ """Load users.yml from workspace. Returns {mxid: {profile: ...}}."""
+ users_file = workspace / "users.yml"
+ if not users_file.exists():
+ return {}
+ with open(users_file) as f:
+ data = yaml.safe_load(f) or {}
+ return data
+
+
+async def main() -> None:
+ logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s %(name)s %(levelname)s %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+ )
+
+ workspace_dir = os.environ.get("WORKSPACE_DIR")
+ if workspace_dir:
+ _load_dotenv(Path(workspace_dir))
+
+ # MATRIX_DATA_DIR overrides DATA_DIR for Matrix bot
+ matrix_data_dir = os.environ.get("MATRIX_DATA_DIR")
+ if matrix_data_dir:
+ os.environ["DATA_DIR"] = matrix_data_dir
+
+ # Matrix-specific env vars
+ homeserver = os.environ.get("MATRIX_HOMESERVER")
+ user_id = os.environ.get("MATRIX_USER_ID")
+ access_token = os.environ.get("MATRIX_ACCESS_TOKEN")
+ owner_mxid = os.environ.get("MATRIX_OWNER_MXID", "")
+ admin_mxid = os.environ.get("MATRIX_ADMIN_MXID", "") # For admin notifications
+
+ if not all([homeserver, user_id, access_token]):
+ logging.error(
+ "Missing Matrix config. Need: MATRIX_HOMESERVER, MATRIX_USER_ID, "
+ "MATRIX_ACCESS_TOKEN"
+ )
+ sys.exit(1)
+
+ # Resolve device_id from server (must match access token)
+ async with httpx.AsyncClient() as http:
+ resp = await http.get(
+ f"{homeserver}/_matrix/client/v3/account/whoami",
+ headers={"Authorization": f"Bearer {access_token}"},
+ timeout=10,
+ )
+ if resp.status_code != 200:
+ logging.error("whoami failed (%d): %s", resp.status_code, resp.text)
+ sys.exit(1)
+ device_id = resp.json().get("device_id")
+ logging.info("Resolved device_id: %s", device_id)
+
+ # Load users map (multi-user mode)
+ users = {}
+ if workspace_dir:
+ users = _load_users(Path(workspace_dir))
+ if not users and not owner_mxid:
+ logging.error("Need either users.yml in workspace or MATRIX_OWNER_MXID env var")
+ sys.exit(1)
+
+ try:
+ config = Config.from_env()
+ except ValueError as e:
+ logging.error("Config error: %s", e)
+ sys.exit(1)
+
+ if config.workspace_dir:
+ logging.info("Workspace: %s", config.workspace_dir)
+ # Symlink workspace CLAUDE.md into data dir
+ claude_md_link = config.data_dir / "CLAUDE.md"
+ claude_md_src = config.workspace_dir / "CLAUDE.md"
+ if claude_md_src.exists() and not claude_md_link.exists():
+ claude_md_link.symlink_to(claude_md_src)
+ logging.info("Symlinked CLAUDE.md into data dir")
+
+ if users:
+ logging.info("Multi-user mode: %d users", len(users))
+ logging.info("Data dir: %s", config.data_dir)
+
+ bot = MatrixBot(config, homeserver, user_id, access_token,
+ owner_mxid=owner_mxid, users=users, device_id=device_id,
+ admin_mxid=admin_mxid)
+ try:
+ await bot.run()
+ except KeyboardInterrupt:
+ pass
+ finally:
+ await bot.close()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/bot-examples/telegram_bot_topics.py b/bot-examples/telegram_bot_topics.py
new file mode 100644
index 0000000..491c579
--- /dev/null
+++ b/bot-examples/telegram_bot_topics.py
@@ -0,0 +1,511 @@
+"""Telegram bot engine.
+
+Handles messages (text, photo, voice), topic management, and Claude CLI integration.
+Uses RetryHTTPXRequest for proxy resilience, progressive message editing for streaming.
+"""
+
+import asyncio
+import json
+import logging
+import time
+from datetime import datetime, timezone
+from pathlib import Path
+
+import yaml
+
+from telegram import BotCommand, Update
+from telegram.constants import ChatAction, ParseMode
+from telegram.error import BadRequest, NetworkError
+from telegram.ext import (
+ Application,
+ CommandHandler,
+ ContextTypes,
+ MessageHandler,
+ filters,
+)
+from telegram.request import HTTPXRequest
+
+from core.asr import transcribe
+from core.claude_session import send_message as claude_send
+from core.config import Config
+
+logger = logging.getLogger(__name__)
+
+# Streaming edit parameters
+EDIT_INTERVAL = 1.5 # seconds between message edits
+EDIT_MIN_DELTA = 150 # minimum new chars before editing
+
+
+class RetryHTTPXRequest(HTTPXRequest):
+ """HTTPXRequest with retry on ConnectError (SOCKS5 proxy hiccups)."""
+
+ MAX_RETRIES = 3
+ RETRY_DELAY = 2
+
+ async def do_request(self, *args, **kwargs):
+ last_exc = None
+ for attempt in range(self.MAX_RETRIES):
+ try:
+ return await super().do_request(*args, **kwargs)
+ except NetworkError as e:
+ if "ConnectError" in str(e):
+ last_exc = e
+ if attempt < self.MAX_RETRIES - 1:
+ logger.warning(
+ "Telegram ConnectError (attempt %d/%d), retrying in %ds...",
+ attempt + 1, self.MAX_RETRIES, self.RETRY_DELAY,
+ )
+ await asyncio.sleep(self.RETRY_DELAY)
+ else:
+ raise
+ raise last_exc
+
+
+def build_app(config: Config) -> Application:
+ """Build and configure the Telegram Application."""
+ builder = Application.builder().token(config.bot_token)
+
+ # Configure HTTP client with proxy and timeouts
+ request_kwargs = {
+ "connect_timeout": 30.0,
+ "read_timeout": 60.0,
+ "write_timeout": 60.0,
+ "pool_timeout": 10.0,
+ }
+ if config.proxy:
+ request_kwargs["proxy"] = config.proxy
+
+ request = RetryHTTPXRequest(**request_kwargs)
+ builder = builder.request(request)
+ builder = builder.concurrent_updates(True)
+
+ app = builder.build()
+
+ # Store config in bot_data for handler access
+ app.bot_data["config"] = config
+
+ # Register handlers (order matters — more specific first)
+ app.add_handler(CommandHandler("start", handle_start))
+ app.add_handler(CommandHandler("newtopic", handle_new_topic))
+ app.add_handler(MessageHandler(filters.PHOTO, handle_photo))
+ app.add_handler(MessageHandler(filters.VOICE | filters.AUDIO, handle_voice))
+ app.add_handler(MessageHandler(filters.Document.ALL, handle_document))
+ app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
+
+ # Post-init: set bot commands
+ app.post_init = _post_init
+
+ return app
+
+
+async def _post_init(application: Application) -> None:
+ """Set bot commands menu after initialization."""
+ commands = [
+ BotCommand("newtopic", "Create a new topic"),
+ BotCommand("start", "Start / help"),
+ ]
+ await application.bot.set_my_commands(commands)
+ logger.info("Bot initialized: @%s", application.bot.username)
+
+
+def _get_config(context: ContextTypes.DEFAULT_TYPE) -> Config:
+ return context.bot_data["config"]
+
+
+def _is_owner(update: Update, config: Config) -> bool:
+ return update.effective_user and update.effective_user.id == config.owner_id
+
+
+def _topic_id(update: Update) -> str:
+ """Get topic ID from message, or 'general' for the default topic."""
+ thread_id = update.effective_message.message_thread_id
+ return str(thread_id) if thread_id else "general"
+
+
+def _topic_dir(config: Config, topic_id: str) -> Path:
+ """Get data directory for a topic."""
+ d = config.data_dir / "topics" / topic_id
+ d.mkdir(parents=True, exist_ok=True)
+ return d
+
+
+def _log_interaction(config: Config, topic_id: str, user_msg: str, bot_msg: str) -> None:
+ """Append interaction to topic log."""
+ log_file = _topic_dir(config, topic_id) / "log.jsonl"
+ entry = {
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "user": user_msg[:1000],
+ "bot": bot_msg[:2000],
+ }
+ with open(log_file, "a") as f:
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
+
+
+def _md_to_html(text: str) -> str:
+ """Convert common Markdown to Telegram HTML."""
+ import re
+ # Escape HTML entities first (but preserve our conversions)
+ text = text.replace("&", "&").replace("<", "<").replace(">", ">")
+
+ # Code blocks: ```lang\n...\n```
+ text = re.sub(
+ r"```\w*\n(.*?)```",
+ lambda m: f"{m.group(1)}",
+ text, flags=re.DOTALL,
+ )
+ # Inline code: `...`
+ text = re.sub(r"`([^`]+)`", r"\1", text)
+ # Bold: **...**
+ text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
+ # Italic: *...*
+ text = re.sub(r"\*(.+?)\*", r"\1", text)
+ # Headers: ## ... → bold line
+ text = re.sub(r"^#{1,6}\s+(.+)$", r"\1", text, flags=re.MULTILINE)
+ # Bullet lists: - item → bullet
+ text = re.sub(r"^- ", "• ", text, flags=re.MULTILINE)
+
+ return text
+
+
+async def _edit_text_md(message, text: str) -> None:
+ """Edit message with HTML formatting, falling back to plain text."""
+ try:
+ html = _md_to_html(text)
+ await message.edit_text(html, parse_mode=ParseMode.HTML)
+ except BadRequest:
+ try:
+ await message.edit_text(text)
+ except BadRequest:
+ pass
+
+
+# Cache of topic labels we've already applied: {topic_id: label}
+_applied_labels: dict[str, str] = {}
+
+# Pending questions from Claude: {topic_id: asyncio.Future}
+_pending_questions: dict[str, asyncio.Future] = {}
+
+
+async def _sync_topic_name(update: Update, config: Config, topic_id: str) -> None:
+ """Rename Telegram topic if topic-map.yml has a new/changed label."""
+ if topic_id == "general":
+ return
+ topic_map_path = config.data_dir / "topic-map.yml"
+ if not topic_map_path.exists():
+ return
+ try:
+ with open(topic_map_path) as f:
+ topic_map = yaml.safe_load(f) or {}
+ entry = topic_map.get(topic_id) or topic_map.get(int(topic_id))
+ if not entry or not isinstance(entry, dict):
+ return
+ label = entry.get("label")
+ if not label or _applied_labels.get(topic_id) == label:
+ return
+ await update.get_bot().edit_forum_topic(
+ chat_id=update.effective_chat.id,
+ message_thread_id=int(topic_id),
+ name=label[:128],
+ )
+ _applied_labels[topic_id] = label
+ logger.info("Renamed topic %s to: %s", topic_id, label)
+ except BadRequest as e:
+ if "not modified" not in str(e).lower():
+ logger.warning("Failed to rename topic %s: %s", topic_id, e)
+ _applied_labels[topic_id] = label # don't retry
+ except Exception as e:
+ logger.warning("Error reading topic-map.yml: %s", e)
+
+
+async def handle_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Handle /start command."""
+ config = _get_config(context)
+ if not _is_owner(update, config):
+ return
+ await update.effective_message.reply_text(
+ "Ready. Send me a message or use /newtopic to create a topic."
+ )
+
+
+async def handle_new_topic(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Handle /newtopic — create a forum topic."""
+ config = _get_config(context)
+ if not _is_owner(update, config):
+ return
+
+ name = " ".join(context.args) if context.args else None
+ if not name:
+ await update.effective_message.reply_text("Usage: /newtopic Topic Name")
+ return
+
+ try:
+ topic = await context.bot.create_forum_topic(
+ chat_id=update.effective_chat.id,
+ name=name,
+ )
+ tid = str(topic.message_thread_id)
+ _topic_dir(config, tid)
+ await context.bot.send_message(
+ chat_id=update.effective_chat.id,
+ message_thread_id=topic.message_thread_id,
+ text=f"Topic created. Send me anything here.",
+ )
+ logger.info("Created topic: %s (id=%s)", name, tid)
+ except BadRequest as e:
+ logger.error("Failed to create topic: %s", e)
+ await update.effective_message.reply_text(f"Failed to create topic: {e}")
+
+
+async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Handle text messages — send to Claude CLI."""
+ config = _get_config(context)
+ if not _is_owner(update, config):
+ return
+
+ tid = _topic_id(update)
+ user_text = update.effective_message.text
+
+ # If Claude is waiting for an answer in this topic, deliver it
+ if tid in _pending_questions:
+ future = _pending_questions.pop(tid)
+ if not future.done():
+ future.set_result(user_text)
+ return
+
+ # Send typing indicator and placeholder
+ await context.bot.send_chat_action(
+ chat_id=update.effective_chat.id,
+ action=ChatAction.TYPING,
+ message_thread_id=update.effective_message.message_thread_id,
+ )
+ placeholder = await update.effective_message.reply_text("thinking...")
+
+ # Streaming state
+ last_edit_time = 0.0
+ last_edit_len = 0
+
+ async def on_chunk(text_so_far: str):
+ nonlocal last_edit_time, last_edit_len
+ now = time.monotonic()
+ delta = len(text_so_far) - last_edit_len
+
+ if delta >= EDIT_MIN_DELTA and (now - last_edit_time) >= EDIT_INTERVAL:
+ try:
+ display = _truncate_for_telegram(text_so_far)
+ await placeholder.edit_text(display)
+ last_edit_time = now
+ last_edit_len = len(text_so_far)
+ except BadRequest:
+ pass # message not modified or too long
+
+ async def on_question(question: str) -> str:
+ """Claude asks user a question — send it and wait for reply."""
+ await update.effective_message.reply_text(f"❓ {question}")
+ loop = asyncio.get_event_loop()
+ future = loop.create_future()
+ _pending_questions[tid] = future
+ return await future
+
+ topic_dir = _topic_dir(config, tid)
+
+ try:
+ response = await claude_send(
+ config, tid, user_text, on_chunk=on_chunk, on_question=on_question,
+ )
+ display = _truncate_for_telegram(response)
+ await _edit_text_md(placeholder, display)
+ except RuntimeError as e:
+ logger.error("Claude error for topic %s: %s", tid, e)
+ await placeholder.edit_text(f"Error: {e}")
+ response = f"[error] {e}"
+ finally:
+ _pending_questions.pop(tid, None)
+
+ await _send_outbox(update, topic_dir)
+ _log_interaction(config, tid, user_text, response)
+ await _sync_topic_name(update, config, tid)
+
+
+async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Handle photo messages — save image, send path to Claude."""
+ config = _get_config(context)
+ if not _is_owner(update, config):
+ return
+
+ tid = _topic_id(update)
+ images_dir = _topic_dir(config, tid) / "images"
+ images_dir.mkdir(exist_ok=True)
+
+ # Download the largest photo
+ photo = update.effective_message.photo[-1]
+ file = await context.bot.get_file(photo.file_id)
+ ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
+ filename = f"{ts}_{photo.file_unique_id}.jpg"
+ filepath = images_dir / filename
+ await file.download_to_drive(str(filepath))
+
+ caption = update.effective_message.caption or ""
+ message = f"User sent an image: {filepath}"
+ if caption:
+ message += f"\nCaption: {caption}"
+
+ # Send typing and placeholder
+ placeholder = await update.effective_message.reply_text("looking at image...")
+
+ try:
+ response = await claude_send(config, tid, message)
+ display = _truncate_for_telegram(response)
+ await _edit_text_md(placeholder, display)
+ except RuntimeError as e:
+ logger.error("Claude error for photo in topic %s: %s", tid, e)
+ await placeholder.edit_text(f"Error: {e}")
+ response = f"[error] {e}"
+
+ _log_interaction(config, tid, f"[photo] {caption}", response)
+ await _sync_topic_name(update, config, tid)
+
+
+async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Handle document messages — save file, send path to Claude."""
+ config = _get_config(context)
+ if not _is_owner(update, config):
+ return
+
+ tid = _topic_id(update)
+ docs_dir = _topic_dir(config, tid) / "documents"
+ docs_dir.mkdir(exist_ok=True)
+
+ doc = update.effective_message.document
+ file = await context.bot.get_file(doc.file_id)
+ # Use original filename if available, otherwise generate one
+ orig_name = doc.file_name or f"{doc.file_unique_id}"
+ ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
+ filename = f"{ts}_{orig_name}"
+ filepath = docs_dir / filename
+ await file.download_to_drive(str(filepath))
+
+ caption = update.effective_message.caption or ""
+ message = f"User sent a document: {filepath} (name: {orig_name}, size: {doc.file_size} bytes)"
+ if caption:
+ message += f"\nCaption: {caption}"
+
+ topic_dir = _topic_dir(config, tid)
+ placeholder = await update.effective_message.reply_text("reading document...")
+
+ try:
+ response = await claude_send(config, tid, message)
+ display = _truncate_for_telegram(response)
+ await _edit_text_md(placeholder, display)
+ except RuntimeError as e:
+ logger.error("Claude error for document in topic %s: %s", tid, e)
+ await placeholder.edit_text(f"Error: {e}")
+ response = f"[error] {e}"
+
+ await _send_outbox(update, topic_dir)
+ _log_interaction(config, tid, f"[document: {orig_name}] {caption}", response)
+ await _sync_topic_name(update, config, tid)
+
+
+async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Handle voice/audio messages — save file, send path to Claude."""
+ config = _get_config(context)
+ if not _is_owner(update, config):
+ return
+
+ tid = _topic_id(update)
+ voice_dir = _topic_dir(config, tid) / "voice"
+ voice_dir.mkdir(exist_ok=True)
+
+ # Download voice file
+ voice = update.effective_message.voice or update.effective_message.audio
+ file = await context.bot.get_file(voice.file_id)
+ ext = "ogg" if update.effective_message.voice else "mp3"
+ ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
+ filename = f"{ts}_{voice.file_unique_id}.{ext}"
+ filepath = voice_dir / filename
+ await file.download_to_drive(str(filepath))
+
+ topic_dir = _topic_dir(config, tid)
+
+ # Transcribe via Whisper if available, otherwise send file path
+ if config.whisper_url:
+ placeholder = await update.effective_message.reply_text("transcribing voice...")
+ try:
+ text = await transcribe(str(filepath), config.whisper_url)
+ message = f"[voice message transcription]: {text}"
+ logger.info("Transcribed voice in topic %s: %d chars", tid, len(text))
+ # Show transcription to user, then send to Claude
+ try:
+ await placeholder.edit_text(f"🎤 {text}")
+ except BadRequest:
+ pass
+ placeholder = await update.effective_message.reply_text("thinking...")
+ except RuntimeError as e:
+ logger.error("ASR failed for topic %s: %s", tid, e)
+ message = f"User sent a voice message: {filepath} (duration: {voice.duration}s)\n(transcription failed: {e})"
+ else:
+ message = f"User sent a voice message: {filepath} (duration: {voice.duration}s)"
+ placeholder = await update.effective_message.reply_text("processing voice...")
+
+ try:
+ response = await claude_send(config, tid, message)
+ display = _truncate_for_telegram(response)
+ await _edit_text_md(placeholder, display)
+ except RuntimeError as e:
+ logger.error("Claude error for voice in topic %s: %s", tid, e)
+ await placeholder.edit_text(f"Error: {e}")
+ response = f"[error] {e}"
+
+ await _send_outbox(update, topic_dir)
+ _log_interaction(config, tid, message, response)
+ await _sync_topic_name(update, config, tid)
+
+
+async def _send_outbox(update: Update, topic_dir: Path) -> None:
+ """Send files queued in outbox.jsonl by Claude via send-to-user tool."""
+ outbox = topic_dir / "outbox.jsonl"
+ if not outbox.exists():
+ return
+
+ entries = []
+ try:
+ with open(outbox) as f:
+ for line in f:
+ line = line.strip()
+ if line:
+ entries.append(json.loads(line))
+ # Clear outbox
+ outbox.unlink()
+ except Exception as e:
+ logger.error("Failed to read outbox: %s", e)
+ return
+
+ for entry in entries:
+ fpath = Path(entry.get("path", ""))
+ ftype = entry.get("type", "document")
+ caption = entry.get("caption", "") or fpath.name
+
+ if not fpath.is_file():
+ logger.warning("Outbox file not found: %s", fpath)
+ continue
+
+ try:
+ with open(fpath, "rb") as f:
+ if ftype == "image":
+ await update.effective_message.reply_photo(photo=f, caption=caption)
+ elif ftype == "video":
+ await update.effective_message.reply_video(video=f, caption=caption)
+ elif ftype == "audio":
+ await update.effective_message.reply_voice(voice=f, caption=caption)
+ else:
+ await update.effective_message.reply_document(document=f, caption=caption)
+ logger.info("Sent %s: %s", ftype, fpath.name)
+ except Exception as e:
+ logger.error("Failed to send %s %s: %s", ftype, fpath.name, e)
+
+
+def _truncate_for_telegram(text: str, max_len: int = 4096) -> str:
+ """Truncate text to Telegram message limit."""
+ if len(text) <= max_len:
+ return text
+ return text[: max_len - 20] + "\n\n[truncated]"
diff --git a/bot-examples/telegram_main.py b/bot-examples/telegram_main.py
new file mode 100644
index 0000000..cf5d13e
--- /dev/null
+++ b/bot-examples/telegram_main.py
@@ -0,0 +1,75 @@
+"""Entry point for agent-core bot.
+
+Loads config from environment, optionally reads .env from workspace,
+builds and runs the Telegram bot.
+"""
+
+import logging
+import sys
+from pathlib import Path
+
+from core.bot import build_app
+from core.config import Config
+
+
+def _load_dotenv(workspace_dir: Path | None) -> None:
+ """Load .env file from workspace directory if it exists."""
+ if not workspace_dir:
+ return
+ env_file = workspace_dir / ".env"
+ if not env_file.exists():
+ return
+
+ import os
+ for line in env_file.read_text().splitlines():
+ line = line.strip()
+ if not line or line.startswith("#"):
+ continue
+ if "=" not in line:
+ continue
+ key, _, value = line.partition("=")
+ key = key.strip()
+ value = value.strip().strip('"').strip("'")
+ # Don't override existing env vars
+ if key not in os.environ:
+ os.environ[key] = value
+
+
+def main() -> None:
+ logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s %(name)s %(levelname)s %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+ )
+
+ import os
+ workspace_dir = os.environ.get("WORKSPACE_DIR")
+ if workspace_dir:
+ _load_dotenv(Path(workspace_dir))
+
+ try:
+ config = Config.from_env()
+ except ValueError as e:
+ logging.error("Config error: %s", e)
+ sys.exit(1)
+
+ if config.workspace_dir:
+ logging.info("Workspace: %s", config.workspace_dir)
+ # Symlink workspace CLAUDE.md into data dir so Claude CLI finds it
+ # when running in topic subdirectories
+ claude_md_link = config.data_dir / "CLAUDE.md"
+ claude_md_src = config.workspace_dir / "CLAUDE.md"
+ if claude_md_src.exists() and not claude_md_link.exists():
+ claude_md_link.symlink_to(claude_md_src)
+ logging.info("Symlinked CLAUDE.md into data dir")
+ logging.info("Data dir: %s", config.data_dir)
+
+ app = build_app(config)
+ app.run_polling(
+ allowed_updates=["message", "edited_message"],
+ stop_signals=None,
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/config/matrix-agents.example.yaml b/config/matrix-agents.example.yaml
new file mode 100644
index 0000000..84221eb
--- /dev/null
+++ b/config/matrix-agents.example.yaml
@@ -0,0 +1,44 @@
+# Agent registry for the Matrix bot.
+# Production target: one surface bot routes to 25-30 externally managed agents.
+# Keep adding entries with the same base_url/workspace_path pattern.
+#
+# user_agents: maps a Matrix user ID to an agent ID.
+# If a user is not listed, the bot uses the first agent from the list below.
+# Omit this section entirely for a single-agent setup.
+#
+# agents: list of available agents.
+# id — must match the agent ID known to the platform
+# label — human-readable name (shown in logs)
+# base_url — HTTP/WS URL of this agent's endpoint
+# (overrides the global AGENT_BASE_URL env var for this agent)
+# workspace_path — absolute path to this agent's workspace directory inside the bot container
+# (the bot saves incoming files directly here and reads outgoing files from here)
+# Example: /agents/0 means the bot mounts the shared volume at /agents/
+# and this agent's files live under /agents/0/
+
+user_agents:
+ "@user0:matrix.example.org": agent-0
+ "@user1:matrix.example.org": agent-1
+ "@user2:matrix.example.org": agent-2
+
+agents:
+ - id: agent-0
+ label: "Agent 0"
+ base_url: "http://lambda.coredump.ru:7000/agent_0/"
+ workspace_path: "/agents/0"
+
+ - id: agent-1
+ label: "Agent 1"
+ base_url: "http://lambda.coredump.ru:7000/agent_1/"
+ workspace_path: "/agents/1"
+
+ - id: agent-2
+ label: "Agent 2"
+ base_url: "http://lambda.coredump.ru:7000/agent_2/"
+ workspace_path: "/agents/2"
+
+ # Continue the same pattern through agent-29 for a 25-30 agent deployment:
+ # - id: agent-29
+ # label: "Agent 29"
+ # base_url: "http://lambda.coredump.ru:7000/agent_29/"
+ # workspace_path: "/agents/29"
diff --git a/config/matrix-agents.smoke.yaml b/config/matrix-agents.smoke.yaml
new file mode 100644
index 0000000..9b357fe
--- /dev/null
+++ b/config/matrix-agents.smoke.yaml
@@ -0,0 +1,10 @@
+agents:
+ - id: agent-0
+ label: "Smoke Agent 0"
+ base_url: "http://agent-proxy:7000/agent_0/"
+ workspace_path: "/agents/0"
+
+ - id: agent-1
+ label: "Smoke Agent 1"
+ base_url: "http://agent-proxy:7000/agent_1/"
+ workspace_path: "/agents/1"
diff --git a/config/matrix-agents.yaml b/config/matrix-agents.yaml
new file mode 100644
index 0000000..3ab9366
--- /dev/null
+++ b/config/matrix-agents.yaml
@@ -0,0 +1,8 @@
+# Single-agent configuration for MVP deployment.
+# For multi-agent setup with per-user routing, see config/matrix-agents.example.yaml.
+
+agents:
+ - id: agent-1
+ label: Surface
+ base_url: "http://lambda.coredump.ru:7000/agent_1/"
+ workspace_path: "/agents/1"
diff --git a/core/handler.py b/core/handler.py
index f6dd5bd..5b40078 100644
--- a/core/handler.py
+++ b/core/handler.py
@@ -15,7 +15,7 @@ from core.protocol import (
OutgoingEvent,
)
from core.settings import SettingsManager
-from platform.interface import PlatformClient
+from sdk.interface import PlatformClient
logger = structlog.get_logger(__name__)
diff --git a/core/handlers/chat.py b/core/handlers/chat.py
index 8e32468..a7140b5 100644
--- a/core/handlers/chat.py
+++ b/core/handlers/chat.py
@@ -4,9 +4,19 @@ from __future__ import annotations
from core.protocol import IncomingCommand, OutgoingMessage
+def _command(platform: str, name: str) -> str:
+ prefix = "!" if platform == "matrix" else "/"
+ return f"{prefix}{name}"
+
+
async def handle_new_chat(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
if not await auth_mgr.is_authenticated(event.user_id):
- return [OutgoingMessage(chat_id=event.chat_id, text="Введите /start чтобы начать.")]
+ return [
+ OutgoingMessage(
+ chat_id=event.chat_id,
+ text=f"Введите {_command(event.platform, 'start')} чтобы начать.",
+ )
+ ]
name = " ".join(event.args) if event.args else None
ctx = await chat_mgr.get_or_create(
user_id=event.user_id,
@@ -20,7 +30,12 @@ async def handle_new_chat(event: IncomingCommand, auth_mgr, platform, chat_mgr,
async def handle_rename(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
if not event.args:
- return [OutgoingMessage(chat_id=event.chat_id, text="Укажите название: /rename Название")]
+ return [
+ OutgoingMessage(
+ chat_id=event.chat_id,
+ text=f"Укажите название: {_command(event.platform, 'rename')} Название",
+ )
+ ]
ctx = await chat_mgr.rename(event.chat_id, " ".join(event.args))
return [OutgoingMessage(chat_id=event.chat_id, text=f"Переименован в: {ctx.display_name}")]
diff --git a/core/handlers/message.py b/core/handlers/message.py
index e1475ef..876754c 100644
--- a/core/handlers/message.py
+++ b/core/handlers/message.py
@@ -1,12 +1,49 @@
# core/handlers/message.py
from __future__ import annotations
-from core.protocol import IncomingMessage, OutgoingMessage, OutgoingTyping
+from core.protocol import Attachment, IncomingMessage, OutgoingMessage, OutgoingTyping
+
+
+def _infer_attachment_type(mime_type: str | None) -> str:
+ if not mime_type:
+ return "document"
+ if mime_type.startswith("image/"):
+ return "image"
+ if mime_type.startswith("audio/"):
+ return "audio"
+ if mime_type.startswith("video/"):
+ return "video"
+ return "document"
+
+
+def _to_core_attachments(raw: list) -> list[Attachment]:
+ result = []
+ for a in raw:
+ if isinstance(a, Attachment):
+ result.append(a)
+ else:
+ result.append(Attachment(
+ type=getattr(a, "type", None) or _infer_attachment_type(getattr(a, "mime_type", None)),
+ url=getattr(a, "url", None),
+ filename=getattr(a, "filename", None),
+ mime_type=getattr(a, "mime_type", None),
+ workspace_path=getattr(a, "workspace_path", None),
+ ))
+ return result
+
+
+def _start_command(platform: str) -> str:
+ return "!start" if platform == "matrix" else "/start"
async def handle_message(event: IncomingMessage, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
if not await auth_mgr.is_authenticated(event.user_id):
- return [OutgoingMessage(chat_id=event.chat_id, text="Введите /start чтобы начать.")]
+ return [
+ OutgoingMessage(
+ chat_id=event.chat_id,
+ text=f"Введите {_start_command(event.platform)} чтобы начать.",
+ )
+ ]
# Voice slot fallback: audio attachment without registered voice_handler
if event.attachments and event.attachments[0].type == "audio":
@@ -20,10 +57,15 @@ async def handle_message(event: IncomingMessage, auth_mgr, platform, chat_mgr, s
user_id=event.user_id,
chat_id=event.chat_id,
text=event.text,
- attachments=[],
+ attachments=event.attachments,
)
return [
OutgoingTyping(chat_id=event.chat_id, is_typing=False),
- OutgoingMessage(chat_id=event.chat_id, text=response.response, parse_mode="markdown"),
+ OutgoingMessage(
+ chat_id=event.chat_id,
+ text=response.response,
+ parse_mode="markdown",
+ attachments=_to_core_attachments(getattr(response, "attachments", [])),
+ ),
]
diff --git a/core/protocol.py b/core/protocol.py
index 02a9f4a..7d6e25f 100644
--- a/core/protocol.py
+++ b/core/protocol.py
@@ -12,6 +12,7 @@ class Attachment:
content: bytes | None = None
filename: str | None = None
mime_type: str | None = None
+ workspace_path: str | None = None
@dataclass
diff --git a/core/settings.py b/core/settings.py
index ba72a89..c7d25a8 100644
--- a/core/settings.py
+++ b/core/settings.py
@@ -5,7 +5,7 @@ import structlog
from core.protocol import SettingsAction
from core.store import StateStore
-from platform.interface import PlatformClient, UserSettings
+from sdk.interface import PlatformClient, UserSettings
logger = structlog.get_logger(__name__)
diff --git a/docker-compose.fullstack.yml b/docker-compose.fullstack.yml
new file mode 100644
index 0000000..88ff37b
--- /dev/null
+++ b/docker-compose.fullstack.yml
@@ -0,0 +1,61 @@
+services:
+ matrix-bot:
+ extends:
+ file: docker-compose.prod.yml
+ service: matrix-bot
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: development
+ args:
+ LAMBDA_AGENT_API_REF: ${LAMBDA_AGENT_API_REF:-master}
+ additional_contexts:
+ agent_api: ./external/platform-agent_api
+ tags:
+ - ${SURFACES_BOT_DEV_IMAGE:-surfaces-bot:dev}
+ environment:
+ AGENT_BASE_URL: http://platform-agent:8000
+ depends_on:
+ platform-agent:
+ condition: service_healthy
+
+ platform-agent:
+ build:
+ context: ./external/platform-agent
+ target: development
+ additional_contexts:
+ agent_api: ./external/platform-agent_api
+ environment:
+ PYTHONUNBUFFERED: "1"
+ AGENT_ID: ${AGENT_ID:-matrix-dev}
+ PROVIDER_MODEL: ${PROVIDER_MODEL:-openai/gpt-4o-mini}
+ PROVIDER_URL: ${PROVIDER_URL:-}
+ PROVIDER_API_KEY: ${PROVIDER_API_KEY:-}
+ COMPOSIO_API_KEY: ${COMPOSIO_API_KEY:-}
+ volumes:
+ - ./external/platform-agent/src:/app/src
+ - ./external/platform-agent_api:/agent_api
+ - agents:/workspace
+ command: >
+ sh -lc "
+ mkdir -p /workspace &&
+ chown -R agent:agent /workspace &&
+ exec /app/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000 --no-access-log
+ "
+ ports:
+ - "8000:8000"
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/openapi.json', timeout=2).read()"
+ interval: 60s
+ timeout: 5s
+ retries: 5
+ start_period: 15s
+ restart: unless-stopped
+
+volumes:
+ agents:
+ name: ${SURFACES_SHARED_VOLUME:-surfaces-agents}
+ bot-state:
+ name: ${SURFACES_BOT_STATE_VOLUME:-surfaces-bot-state}
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 0000000..2c7e942
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -0,0 +1,26 @@
+services:
+ matrix-bot:
+ image: "${SURFACES_BOT_IMAGE:?Set SURFACES_BOT_IMAGE to the pushed image, e.g. mput1/surfaces-bot:latest}"
+ environment:
+ MATRIX_HOMESERVER: ${MATRIX_HOMESERVER:-}
+ MATRIX_USER_ID: ${MATRIX_USER_ID:-}
+ MATRIX_PASSWORD: ${MATRIX_PASSWORD:-}
+ MATRIX_ACCESS_TOKEN: ${MATRIX_ACCESS_TOKEN:-}
+ MATRIX_PLATFORM_BACKEND: ${MATRIX_PLATFORM_BACKEND:-real}
+ MATRIX_AGENT_REGISTRY_PATH: ${MATRIX_AGENT_REGISTRY_PATH:-/app/config/matrix-agents.yaml}
+ AGENT_BASE_URL: ${AGENT_BASE_URL:-}
+ SURFACES_WORKSPACE_DIR: ${SURFACES_WORKSPACE_DIR:-/agents}
+ MATRIX_DB_PATH: /app/state/lambda_matrix.db
+ MATRIX_STORE_PATH: /app/state/matrix_store
+ PYTHONUNBUFFERED: "1"
+ volumes:
+ - agents:/agents
+ - bot-state:/app/state
+ - ./config:/app/config:ro
+ restart: unless-stopped
+
+volumes:
+ agents:
+ name: ${SURFACES_SHARED_VOLUME:-surfaces-agents}
+ bot-state:
+ name: ${SURFACES_BOT_STATE_VOLUME:-surfaces-bot-state}
diff --git a/docker-compose.smoke.timeout.yml b/docker-compose.smoke.timeout.yml
new file mode 100644
index 0000000..c8f4ba3
--- /dev/null
+++ b/docker-compose.smoke.timeout.yml
@@ -0,0 +1,18 @@
+services:
+ agent-proxy:
+ volumes:
+ - ./docker/nginx/smoke-agents-timeout.conf:/etc/nginx/nginx.conf:ro
+ depends_on:
+ agent-no-status:
+ condition: service_started
+
+ agent-no-status:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: production
+ args:
+ LAMBDA_AGENT_API_REF: ${LAMBDA_AGENT_API_REF:-master}
+ environment:
+ PYTHONUNBUFFERED: "1"
+ command: ["python", "-m", "tools.no_status_agent", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/docker-compose.smoke.yml b/docker-compose.smoke.yml
new file mode 100644
index 0000000..ed4e8b8
--- /dev/null
+++ b/docker-compose.smoke.yml
@@ -0,0 +1,109 @@
+services:
+ surface-smoke:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: production
+ args:
+ LAMBDA_AGENT_API_REF: ${LAMBDA_AGENT_API_REF:-master}
+ environment:
+ PYTHONUNBUFFERED: "1"
+ SMOKE_TIMEOUT: ${SMOKE_TIMEOUT:-5}
+ volumes:
+ - agents:/agents
+ - ./config:/app/config:ro
+ depends_on:
+ agent-proxy:
+ condition: service_healthy
+ command: >
+ sh -lc "
+ python -m tools.check_matrix_agents --config /app/config/matrix-agents.smoke.yaml --timeout ${SMOKE_TIMEOUT:-5}
+ "
+
+ agent-proxy:
+ image: nginx:1.27-alpine
+ volumes:
+ - ./docker/nginx/smoke-agents.conf:/etc/nginx/nginx.conf:ro
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - nc -z 127.0.0.1 7000
+ interval: 2s
+ timeout: 2s
+ retries: 15
+ start_period: 2s
+ depends_on:
+ agent-0:
+ condition: service_healthy
+ agent-1:
+ condition: service_healthy
+ ports:
+ - "${SMOKE_PROXY_PORT:-7000}:7000"
+
+ agent-0:
+ build:
+ context: ./external/platform-agent
+ target: development
+ additional_contexts:
+ agent_api: ./external/platform-agent_api
+ environment:
+ PYTHONUNBUFFERED: "1"
+ AGENT_ID: ${AGENT_0_ID:-agent-0}
+ PROVIDER_MODEL: ${PROVIDER_MODEL:-debug-model}
+ PROVIDER_URL: ${PROVIDER_URL:-http://provider.invalid/v1}
+ PROVIDER_API_KEY: ${PROVIDER_API_KEY:-debug-key}
+ volumes:
+ - ./external/platform-agent/src:/app/src
+ - ./external/platform-agent_api:/agent_api
+ - agents:/shared-agents
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/openapi.json', timeout=2).read()"
+ interval: 5s
+ timeout: 3s
+ retries: 12
+ start_period: 5s
+ command: >
+ sh -lc "
+ mkdir -p /shared-agents/0 &&
+ rm -rf /workspace &&
+ ln -s /shared-agents/0 /workspace &&
+ exec /app/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000 --no-access-log
+ "
+
+ agent-1:
+ build:
+ context: ./external/platform-agent
+ target: development
+ additional_contexts:
+ agent_api: ./external/platform-agent_api
+ environment:
+ PYTHONUNBUFFERED: "1"
+ AGENT_ID: ${AGENT_1_ID:-agent-1}
+ PROVIDER_MODEL: ${PROVIDER_MODEL:-debug-model}
+ PROVIDER_URL: ${PROVIDER_URL:-http://provider.invalid/v1}
+ PROVIDER_API_KEY: ${PROVIDER_API_KEY:-debug-key}
+ volumes:
+ - ./external/platform-agent/src:/app/src
+ - ./external/platform-agent_api:/agent_api
+ - agents:/shared-agents
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/openapi.json', timeout=2).read()"
+ interval: 5s
+ timeout: 3s
+ retries: 12
+ start_period: 5s
+ command: >
+ sh -lc "
+ mkdir -p /shared-agents/1 &&
+ rm -rf /workspace &&
+ ln -s /shared-agents/1 /workspace &&
+ exec /app/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000 --no-access-log
+ "
+
+volumes:
+ agents:
+ name: ${SURFACES_SMOKE_VOLUME:-surfaces-smoke-agents}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..c7323d0
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,39 @@
+services:
+ platform-agent:
+ build:
+ context: ./external/platform-agent
+ target: development
+ additional_contexts:
+ agent_api: ./external/platform-agent_api
+ env_file: .env
+ environment:
+ PYTHONUNBUFFERED: "1"
+ volumes:
+ - ./external/platform-agent/src:/app/src
+ - ./external/platform-agent_api:/agent_api
+ - workspace:/workspace
+ command: >
+ sh -lc "
+ mkdir -p /workspace &&
+ chown -R agent:agent /workspace &&
+ exec /app/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000
+ "
+ ports:
+ - "8000:8000"
+ restart: unless-stopped
+
+ matrix-bot:
+ build: .
+ env_file: .env
+ environment:
+ AGENT_BASE_URL: http://platform-agent:8000
+ SURFACES_WORKSPACE_DIR: /workspace
+ depends_on:
+ - platform-agent
+ volumes:
+ - workspace:/workspace
+ - ./config:/app/config:ro
+ restart: unless-stopped
+
+volumes:
+ workspace:
diff --git a/docker/nginx/smoke-agents-timeout.conf b/docker/nginx/smoke-agents-timeout.conf
new file mode 100644
index 0000000..03c7e79
--- /dev/null
+++ b/docker/nginx/smoke-agents-timeout.conf
@@ -0,0 +1,28 @@
+events {}
+
+http {
+ map $http_upgrade $connection_upgrade {
+ default upgrade;
+ '' close;
+ }
+
+ server {
+ listen 7000;
+
+ location /agent_0/ {
+ proxy_pass http://agent-0:8000/;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ }
+
+ location /agent_1/ {
+ proxy_pass http://agent-no-status:8000/;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ }
+ }
+}
diff --git a/docker/nginx/smoke-agents.conf b/docker/nginx/smoke-agents.conf
new file mode 100644
index 0000000..e3bcaab
--- /dev/null
+++ b/docker/nginx/smoke-agents.conf
@@ -0,0 +1,28 @@
+events {}
+
+http {
+ map $http_upgrade $connection_upgrade {
+ default upgrade;
+ '' close;
+ }
+
+ server {
+ listen 7000;
+
+ location /agent_0/ {
+ proxy_pass http://agent-0:8000/;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ }
+
+ location /agent_1/ {
+ proxy_pass http://agent-1:8000/;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ }
+ }
+}
diff --git a/docs/api-contract.md b/docs/api-contract.md
deleted file mode 100644
index 10fd899..0000000
--- a/docs/api-contract.md
+++ /dev/null
@@ -1,143 +0,0 @@
-# API Contract — Lambda Platform
-
-> **Статус:** ЧЕРНОВИК — проектируем сами, уточняем с Азаматом когда SDK будет готов
-> **Последнее обновление:** 2026-03-29
-
----
-
-## Архитектурный контекст
-
-Каждому пользователю выделяется **один LXC-контейнер** с workspace 10 ГБ.
-Workspace содержит директории чатов: `C1/`, `C2/`, `C3/` — файлы + `history.db` в каждом.
-
-**Master** управляет lifecycle контейнера (запуск, заморозка, пробуждение).
-Бот **не управляет lifecycle** — он передаёт `user_id` + `chat_id` + сообщение.
-Master сам решает: нужно ли поднять контейнер, смонтировать нужный чат, запустить агента.
-
----
-
-## Base URL
-
-```
-https://api.lambda-platform.io/v1
-```
-
-## Аутентификация
-
-```
-Authorization: Bearer {SERVICE_TOKEN}
-```
-
-Сервисный токен выдаётся команде поверхностей. Не путать с токеном пользователя.
-
----
-
-## Users
-
-### GET /users/{external_id}?platform={platform}
-
-Получает или создаёт пользователя.
-
-**Query params:**
-- `platform` — `telegram` | `matrix`
-
-**Response 200:**
-```json
-{
- "user_id": "usr_abc123",
- "external_id": "12345678",
- "platform": "telegram",
- "display_name": "Иван Иванов",
- "created_at": "2025-01-15T10:30:00Z",
- "is_new": false
-}
-```
-
----
-
-## Messages
-
-Бот не управляет сессиями явно. Отправка сообщения — единственная операция.
-Master решает: нужен ли новый контейнер, или разбудить существующий.
-
-### POST /users/{user_id}/chats/{chat_id}/messages
-
-Отправляет сообщение пользователя агенту. Master поднимает/размораживает контейнер,
-монтирует нужный чат (`C1/`, `C2/`...), запускает агента.
-
-**Request:**
-```json
-{
- "text": "Привет, что ты умеешь?",
- "attachments": []
-}
-```
-
-**Response 200:**
-```json
-{
- "message_id": "msg_qwe012",
- "response": "Я AI-агент Lambda...",
- "tokens_used": 142,
- "finished": true
-}
-```
-
----
-
-## Settings
-
-### GET /users/{user_id}/settings
-
-Настройки пользователя: скиллы, коннекторы, SOUL, безопасность, план.
-
-**Response 200:**
-```json
-{
- "skills": {"web-search": true, "browser": false},
- "connectors": {"gmail": {"connected": true, "email": "user@gmail.com"}},
- "soul": {"name": "Лямбда", "style": "friendly"},
- "safety": {"email-send": true, "file-delete": true},
- "plan": {"name": "Beta", "tokens_used": 800, "tokens_limit": 1000}
-}
-```
-
-### POST /users/{user_id}/settings
-
-Применяет действие над настройками.
-
-**Request:**
-```json
-{
- "action": "toggle_skill",
- "payload": {"skill": "browser", "enabled": true}
-}
-```
-
-**Response 200:**
-```json
-{"ok": true}
-```
-
----
-
-## Error format
-
-```json
-{
- "error": "ERROR_CODE",
- "message": "Human readable description",
- "details": {}
-}
-```
-
-Коды ошибок: `USER_NOT_FOUND`, `RATE_LIMITED`, `PLATFORM_ERROR`, `CONTAINER_UNAVAILABLE`
-
----
-
-## Открытые вопросы к команде платфрмы (SDK)
-
-- [ ] Точный формат эндпоинта отправки сообщения — URL, поля
-- [ ] Как передавать вложения (файлы, изображения)? Через S3 pre-signed URL или напрямую?
-- [ ] Стриминговый ответ (SSE / WebSocket) или только sync?
-- [ ] Формат `SettingsAction` — совпадает с нашим или другой?
diff --git a/docs/deploy-architecture.md b/docs/deploy-architecture.md
new file mode 100644
index 0000000..e838611
--- /dev/null
+++ b/docs/deploy-architecture.md
@@ -0,0 +1,197 @@
+# Deployment Architecture — Matrix Bot + Agents
+
+> Сформировано 2026-04-27 по итогам обсуждения с платформой.
+
+---
+
+## Compose Artifacts
+
+- **Production deploy:** `docker-compose.prod.yml`
+ Bot-only handoff через published image (`SURFACES_BOT_IMAGE`). Поднимает только `matrix-bot`, монтирует shared volume в `/agents`.
+ Платформа предоставляет 25-30 agent containers/services отдельно; бот подключается к ним через `base_url` из `matrix-agents.yaml`.
+- **Internal full-stack E2E:** `docker-compose.fullstack.yml`
+ Внутренний harness для тестирования. Локально собирает `matrix-bot` через Dockerfile target `development`, поднимает один `platform-agent`, health-gated startup.
+
+Production operators: `docker-compose.prod.yml`. Internal E2E: `docker-compose.fullstack.yml`.
+
+---
+
+## Топология
+
+```
+lambda.coredump.ru
+├── :7000 (reverse proxy, path-based routing)
+│ ├── /agent_0/ → agent_0 container
+│ ├── /agent_1/ → agent_1 container
+│ └── /agent_N/ → agent_N container
+│
+└── Matrix bot instance (один инстанс на всех)
+ └── volume /agents/ (shared с агентами)
+ ├── /agents/0/ ← workspace agent_0
+ ├── /agents/1/ ← workspace agent_1
+ └── /agents/N/
+```
+
+- **Один инстанс Matrix-бота** обслуживает всех пользователей.
+- **Один агент-контейнер на пользователя.** Production scale target: 25-30 внешних агентов. Изоляция по `agent_id`, не через один общий agent instance.
+- **Shared volume** `/agents/` смонтирован в Matrix-бот. В internal full-stack harness тот же volume mounted as `/workspace` inside `platform-agent`, чтобы bot-side absolute paths и agent workspace относились к одному и тому же хранилищу.
+
+---
+
+## Конфиг (два словаря)
+
+```yaml
+# config/matrix-agents.yaml
+
+user_agents:
+ "@user0:matrix.lambda.coredump.ru": agent-0
+ "@user1:matrix.lambda.coredump.ru": agent-1
+ "@user2:matrix.lambda.coredump.ru": agent-2
+
+agents:
+ - id: agent-0
+ label: "Agent 0"
+ base_url: "http://lambda.coredump.ru:7000/agent_0/"
+ workspace_path: "/agents/0"
+
+ - id: agent-1
+ label: "Agent 1"
+ base_url: "http://lambda.coredump.ru:7000/agent_1/"
+ workspace_path: "/agents/1"
+
+ - id: agent-2
+ label: "Agent 2"
+ base_url: "http://lambda.coredump.ru:7000/agent_2/"
+ workspace_path: "/agents/2"
+```
+
+- `user_agents` — маппинг Matrix user_id → agent_id. Если пользователь не найден — используется первый агент из списка.
+- `agents[].base_url` — HTTP URL агент-эндпоинта. Бот подключается через AgentApi.
+- `agents[].workspace_path` — абсолютный путь к воркспейсу агента **внутри контейнера бота** (т.е. на shared volume).
+ Бот сохраняет входящие файлы прямо в `{workspace_path}/`, читает исходящие из `{workspace_path}/`.
+- Для 25-30 агентов продолжайте тот же паттерн до нужного номера: `/agent_17/` + `/agents/17`, `/agent_29/` + `/agents/29`.
+
+## Surface Image Build Contract
+
+Production image содержит только Matrix surface. Он не содержит `platform-agent` и не требует локального `external/` build context.
+
+```bash
+docker login
+export SURFACES_BOT_IMAGE=mput1/surfaces-bot:latest
+
+docker build --target production \
+ --build-arg LAMBDA_AGENT_API_REF=master \
+ -t "$SURFACES_BOT_IMAGE" .
+docker push "$SURFACES_BOT_IMAGE"
+```
+
+Published image:
+
+```text
+mput1/surfaces-bot:latest
+sha256:2f135f3535f7765d4377b440cdabe41195ad2efbc3e175def159ae4689ef90bd
+```
+
+`SURFACES_BOT_IMAGE` должен указывать на registry namespace, куда текущий Docker account может пушить. Ошибка `insufficient_scope` означает, что пользователь не залогинен в этот namespace, repository не создан, или у аккаунта нет push-доступа.
+
+Production Dockerfile ставит `platform/agent_api` из Git по тому же принципу, что и `platform-agent` production image:
+
+```bash
+git+https://git.lambda.coredump.ru/platform/agent_api.git
+```
+
+Локальный `docker-compose.fullstack.yml` остаётся dev/E2E harness: он использует target `development` и `additional_contexts.agent_api=./external/platform-agent_api`, чтобы можно было тестировать surface вместе с локальным checkout SDK.
+
+---
+
+## Agent API (используем master ветку `platform/agent_api`)
+
+```python
+from lambda_agent_api.agent_api import AgentApi
+
+connected_agents: dict[tuple[str, int], AgentApi] = {}
+
+def on_agent_disconnect(agent: AgentApi):
+ connected_agents.pop((agent.id, agent.chat_id), None)
+
+async def on_message(matrix_user_id: str, matrix_room_id: str, text: str):
+ agent_id = get_agent_id_by_user(matrix_user_id) # из user_agents конфига
+ platform_chat_id = get_room_platform_chat_id(matrix_room_id)
+
+ agent = connected_agents.get((agent_id, platform_chat_id))
+ if not agent:
+ agent = AgentApi(
+ agent_id,
+ get_agent_base_url(agent_id), # ws://lambda.coredump.ru:7000/agent_0/
+ on_disconnect=on_agent_disconnect,
+ chat_id=platform_chat_id, # отдельный thread на Matrix room
+ )
+ await agent.connect()
+ connected_agents[(agent_id, platform_chat_id)] = agent
+
+ async for event in agent.send_message(text):
+ ...
+```
+
+**Параметры конструктора (master):**
+```python
+AgentApi(
+ agent_id: str,
+ base_url: str, # ws://host:port/agent_N/
+ chat_id: int = 0, # surfaces must supply per-room platform_chat_id
+ on_disconnect: callable,
+)
+```
+
+**Lifecycle:** агент автоматически отключается после нескольких минут бездействия.
+`on_disconnect` удаляет из пула → следующее сообщение создаёт новое соединение.
+
+---
+
+## Передача файлов
+
+### Пользователь → Агент (входящий файл)
+
+1. Matrix-бот получает файл от пользователя
+2. Сохраняет в workspace агента: `/agents/{N}/{filename}`
+3. Если файл уже существует, выбирает следующее имя: `filename (1).ext`, `filename (2).ext`
+4. Вызывает `agent.send_message(text, attachments=["filename"])`
+ — путь относительно `/workspace` агента
+
+### Агент → Пользователь (исходящий файл)
+
+1. Агент эмитит `MsgEventSendFile(path="report.pdf")`
+2. Matrix-бот читает файл: `/agents/{N}/report.pdf`
+3. Отправляет как Matrix file message пользователю
+
+**Ключевое:** production handoff через `docker-compose.prod.yml` и internal E2E через `docker-compose.fullstack.yml` используют один и тот же `/agents` contract на стороне поверхности. Прямой HTTP-доступ к файлам не нужен.
+
+---
+
+## Текущее состояние platform-agent (main)
+
+- Composio интегрирован в main (`#9-интеграция-composIO`)
+- Агент требует в `.env`: `AGENT_ID`, `COMPOSIO_API_KEY`
+- Backend: `IsolatedShellBackend` (main) / `CompositeBackend` (ветка `#19`, не merged)
+- Memory: `MemorySaver` — история слетает при рестарте контейнера (known limitation)
+
+---
+
+## platform-master (будущее, пока не используем)
+
+Ветка `feat/storage` реализует реальный Master-сервис:
+- `POST /api/v1/create {chat_id}` → поднимает/переиспользует sandbox-контейнер
+- TTL-based lifecycle (300с default, конфигурируемо)
+- `ChatStorage` — API для upload/download файлов через Master
+- Auth + p2p lease — вне текущего scope MVP
+
+**Для деплоя MVP используем статический конфиг без Master.**
+При готовности Master: `get_agent_url()` будет вызывать `POST /api/v1/create`, URL возвращается в ответе.
+
+---
+
+## Открытые вопросы
+
+- `platform-agent_api #9-clientside-tool-call` убирает `attachments` и `MsgEventSendFile` — пока используем master. Уточнить у платформы сроки мержа перед деплоем.
+- История при рестарте агента теряется — `platform-agent` использует `MemorySaver` (in-memory). Ограничение платформы.
+- `AGENT_ID` и `COMPOSIO_API_KEY` для каждого агент-контейнера — значения предоставляет платформа.
diff --git a/docs/known-limitations.md b/docs/known-limitations.md
new file mode 100644
index 0000000..e98f0ba
--- /dev/null
+++ b/docs/known-limitations.md
@@ -0,0 +1,51 @@
+# Known Limitations
+
+## Telegram — Threaded Mode (Bot API 9.3+)
+
+Threaded Mode — относительно новая фича Bot API. Ряд ограничений связан с незрелостью клиентов Telegram, а не с нашим кодом.
+
+### Telegram Mac клиент
+
+- Новые топики, созданные ботом через `/new`, не появляются в сайдбаре сразу.
+ Топики существуют на сервере и доступны на мобильном клиенте — это баг Mac клиента.
+
+### Bot API — управление топиками
+
+- `closeForumTopic` и аналогичные методы работают только для supergroup-форумов.
+ В Threaded Mode личного чата эти вызовы возвращают `"the chat is not a supergroup forum"`.
+- `deleteForumTopic` работает на мобильных клиентах, поведение на Mac непоследовательно.
+- Топики, созданные ботом через API (`/new`), пользователь не может удалить через Mac UI
+ (только через мобильный клиент). Бот пытается удалить топик сам при `/archive`.
+
+### После удаления топика
+
+- Когда все топики удалены, Telegram показывает кнопку Start как при первом запуске.
+ Это стандартное поведение Telegram, не баг бота.
+
+### История чатов
+
+- При пересоздании базы данных (`lambda_bot.db`) старые топики в Telegram остаются.
+ История сообщений в Telegram не удаляется при сбросе БД бота.
+
+---
+
+*Все перечисленные ограничения — на стороне платформы Telegram. Решение: принято, движемся дальше.*
+
+## Matrix
+
+### Незашифрованные комнаты только
+
+- Текущая Matrix-реализация в этом репозитории тестируется только в незашифрованных комнатах.
+ Encrypted DM и encrypted rooms пока не поддержаны.
+
+### Зависимость от локального состояния
+
+- Бот хранит локальный маппинг `chat_id ↔ room_id`.
+ Если удалить `lambda_matrix.db` или `matrix_store/`, старые комнаты в Matrix останутся,
+ но `!rename` и `!archive` для них больше не смогут отработать как для зарегистрированных чатов.
+
+### Поведение после рестарта
+
+- При старте бот делает bootstrap sync и продолжает `sync_forever()` с `since`.
+ Это снижает риск повторной обработки старой timeline, но означает, что рестарт не предназначен
+ для ретро-обработки уже существующих исторических сообщений.
diff --git a/docs/matrix-direct-agent-prototype-ru.md b/docs/matrix-direct-agent-prototype-ru.md
new file mode 100644
index 0000000..2367dc5
--- /dev/null
+++ b/docs/matrix-direct-agent-prototype-ru.md
@@ -0,0 +1,301 @@
+# Matrix Direct-Agent Prototype
+
+> **ВНИМАНИЕ: Это исторический документ.**
+> Описанный здесь прототип был интегрирован в `main` и стал основой для Matrix MVP, но архитектура претерпела значительные изменения. Для актуальной информации по деплою смотрите `docs/deploy-architecture.md`, а для создания новой поверхности — `docs/max-surface-guide.md`.
+
+Русскоязычная заметка по прототипу Matrix surface, который ходит не в `MockPlatformClient`, а напрямую в живой agent backend по WebSocket.
+
+## Что сделали
+
+В этой ветке собран рабочий Matrix-only прототип с минимальным вмешательством в существующую архитектуру.
+
+Ключевая идея:
+- Matrix-адаптер и `core/` остаются на старом контракте `PlatformClient`
+- вместо `sdk/mock.py` можно включить `sdk/real.py`
+- `sdk/real.py` внутри разделяет две ответственности:
+ - `sdk/agent_session.py` — прямое общение с agent по WebSocket
+ - `sdk/prototype_state.py` — локальный user/settings state для прототипа
+
+Это позволило не переписывать Matrix-логику под нестабильный `platform/master` и при этом подключить живого агента вместо мока.
+
+## Что поменялось в `surfaces-bot`
+
+Добавлено:
+- `sdk/agent_session.py`
+- `sdk/prototype_state.py`
+- `sdk/real.py`
+- тесты для transport/state/real backend
+
+Изменено:
+- `adapter/matrix/bot.py`
+- `adapter/matrix/handlers/auth.py`
+- `README.md`
+- интеграционные и Matrix dispatcher тесты
+
+Функционально это дало:
+- переключение Matrix backend через env:
+ - `MATRIX_PLATFORM_BACKEND=mock`
+ - `MATRIX_PLATFORM_BACKEND=real`
+- прямую отправку текста в live agent через `AGENT_BASE_URL`
+- локальное хранение settings и user mapping
+- изоляцию backend memory по `thread_id`
+- исправление повторных invite: бот теперь сначала `join()`, а уже потом решает, нужно ли пере-провиженить Space/chat tree
+
+## Что поменяли в `platform-agent`
+
+Для прототипа потребовался минимальный локальный патч в клонированном `external/platform-agent`.
+
+Изменения:
+- `src/api/external.py`
+- `src/agent/service.py`
+
+Смысл патча:
+- agent больше не использует один общий hardcoded `thread_id="default"`
+- `thread_id` читается из query parameter WebSocket-соединения
+- дальше этот `thread_id` передаётся в config memory/checkpointer
+
+Локальный commit в clone:
+- `1dca2c1` — `feat: support websocket thread ids`
+
+Важно:
+- этот commit живёт в `external/platform-agent`
+- он не входит в git-историю `surfaces-bot`
+- если прототип должен запускаться у других людей без ручных патчей, этот commit надо отдельно запушить или повторить в platform repo
+
+## Текущая архитектура прототипа
+
+Поток сообщения сейчас такой:
+
+1. Matrix room event попадает в `adapter/matrix`
+2. адаптер переводит его в `IncomingMessage` / `IncomingCommand`
+3. `EventDispatcher` вызывает handler из `core/`
+4. handler вызывает `PlatformClient`
+5. при real backend это `RealPlatformClient`
+6. `RealPlatformClient` строит `thread_key`
+7. `AgentSessionClient` открывает WebSocket на `agent_ws/?thread_id=...`
+8. ответ агента возвращается обратно в Matrix
+
+Что остаётся локальным в v1:
+- `!settings`
+- `!skills`
+- `!soul`
+- `!safety`
+- user registration mapping
+
+Что реально идёт в живого агента:
+- обычные текстовые сообщения
+- память по чатам через `thread_id`
+
+## Ограничения прототипа
+
+Сейчас это не полный platform integration, а рабочий direct-agent prototype.
+
+Ограничения:
+- только текстовый чат
+- без attachments в agent
+- без async task callbacks/webhooks
+- без реального control-plane из `platform/master`
+- encrypted Matrix rooms пока не поддержаны
+- repeat invite не создаёт новую Space-структуру, если user уже был провиженен локально
+- backend/provider ошибки пока не везде деградируют в user-facing reply; часть ошибок всё ещё может уронить процесс surface
+
+## Как запускать
+
+Нужно поднять два процесса:
+- patched `platform-agent`
+- Matrix bot из `surfaces-bot`
+
+### 1. Подготовить `platform-agent`
+
+Локальный clone:
+- [external/platform-agent](/Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent)
+
+И связанный SDK clone:
+- [external/platform-agent_api](/Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent_api)
+
+Первичная подготовка:
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent
+uv sync
+uv pip install --python .venv/bin/python -e ../platform-agent_api
+```
+
+Если у вас был активирован чужой venv, сначала сделайте:
+
+```bash
+deactivate
+```
+
+Иначе `uv pip install` может поставить пакет не в тот interpreter.
+
+### 2. Запустить agent backend
+
+Пример с OpenRouter:
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent
+
+export PROVIDER_URL=https://openrouter.ai/api/v1
+export PROVIDER_API_KEY='YOUR_OPENROUTER_KEY'
+export PROVIDER_MODEL='qwen/qwen3.5-122b-a10b'
+
+uv run uvicorn src.main:app --host 0.0.0.0 --port 8000
+```
+
+После этого WebSocket endpoint должен быть доступен по:
+
+```text
+ws://127.0.0.1:8000/agent_ws/
+```
+
+### 3. Запустить Matrix bot
+
+В отдельном терминале:
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot
+
+export MATRIX_PLATFORM_BACKEND=real
+export AGENT_BASE_URL=http://127.0.0.1:8000
+export MATRIX_HOMESERVER=https://matrix.lambda.coredump.ru
+export MATRIX_USER_ID=@lambda_surface_test_bot:matrix.lambda.coredump.ru
+export MATRIX_PASSWORD='YOUR_PASSWORD'
+
+PYTHONPATH=. uv run python -m adapter.matrix.bot
+```
+
+Если всё ок, в логах будет что-то вроде:
+
+```text
+Matrix bot starting ...
+```
+
+## Точные команды
+
+Ниже команды в том виде, в котором реально поднимался рабочий прототип.
+
+### Platform / agent backend
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent
+deactivate 2>/dev/null || true
+uv sync
+uv pip install --python .venv/bin/python -e ../platform-agent_api
+
+export PROVIDER_URL=https://openrouter.ai/api/v1
+export PROVIDER_API_KEY='YOUR_OPENROUTER_KEY'
+export PROVIDER_MODEL='qwen/qwen3.5-122b-a10b'
+
+uv run uvicorn src.main:app --host 0.0.0.0 --port 8000
+```
+
+### Matrix bot
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot
+
+export MATRIX_PLATFORM_BACKEND=real
+export AGENT_BASE_URL=http://127.0.0.1:8000
+export MATRIX_HOMESERVER=https://matrix.lambda.coredump.ru
+export MATRIX_USER_ID=@lambda_surface_test_bot:matrix.lambda.coredump.ru
+export MATRIX_PASSWORD='YOUR_PASSWORD'
+
+PYTHONPATH=. uv run python -m adapter.matrix.bot
+```
+
+### Перезапуск Matrix state с нуля
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot
+rm -f lambda_matrix.db
+rm -rf matrix_store
+PYTHONPATH=. uv run python -m adapter.matrix.bot
+```
+
+## Smoke test
+
+Рекомендуемый сценарий ручной проверки:
+
+1. Пригласить бота в fresh unencrypted room
+2. Дождаться join
+3. Если это первый invite для данного локального state:
+ - бот создаст private Space
+ - бот создаст room `Чат 1`
+4. Открыть `Чат 1`
+5. Отправить `!start`
+6. Отправить обычное текстовое сообщение
+7. Проверить, что ответ пришёл от live backend, а не от `[MOCK]`
+8. Проверить `!new`
+9. Проверить, что память разделяется между чатами
+
+Если бот уже был однажды провиженен и локальный state не очищался:
+- повторный invite не создаст новую Space-структуру
+- бот просто зайдёт в room и будет отвечать там
+
+Это нормальное поведение текущей реализации.
+
+## Сброс локального Matrix state
+
+Если нужно повторно проверить именно first-invite provisioning:
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot
+rm -f lambda_matrix.db
+rm -rf matrix_store
+PYTHONPATH=. uv run python -m adapter.matrix.bot
+```
+
+После этого можно снова приглашать бота как "с нуля".
+
+## Частые проблемы
+
+### 1. `ModuleNotFoundError: lambda_agent_api`
+
+Значит `platform-agent_api` не установлен в `.venv` агента.
+
+Исправление:
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent
+uv pip install --python .venv/bin/python -e ../platform-agent_api
+```
+
+### 2. `CERTIFICATE_VERIFY_FAILED` при запуске Matrix bot
+
+Это не ошибка surface logic. Это TLS trust problem до Matrix homeserver.
+
+Нужно:
+- либо установить системные/Python certificates
+- либо передать корпоративный CA через `SSL_CERT_FILE`
+
+### 3. Бот заходит в room, но не создаёт новую Space
+
+Скорее всего user уже есть в локальном state.
+
+Варианты:
+- это ожидаемо для repeat invite
+- либо очистить `lambda_matrix.db` и `matrix_store`
+
+### 4. Бот падает после message send
+
+Значит backend/provider вернул ошибку, которая ещё не была деградирована в user-facing ответ.
+
+Пример уже встречавшегося кейса:
+- неверный model id
+- key не имеет доступа к model
+
+Сначала проверяйте:
+- `PROVIDER_URL`
+- `PROVIDER_MODEL`
+- `PROVIDER_API_KEY`
+
+## Полезные ссылки внутри repo
+
+- [README.md](/Users/a/MAI/sem2/lambda/surfaces-bot/README.md)
+- [adapter/matrix/bot.py](/Users/a/MAI/sem2/lambda/surfaces-bot/adapter/matrix/bot.py)
+- [sdk/agent_session.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/agent_session.py)
+- [sdk/real.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/real.py)
+- [sdk/prototype_state.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/prototype_state.py)
+- [2026-04-08-matrix-direct-agent-prototype-design.md](/Users/a/MAI/sem2/lambda/surfaces-bot/docs/superpowers/specs/2026-04-08-matrix-direct-agent-prototype-design.md)
+- [2026-04-08-matrix-direct-agent-prototype.md](/Users/a/MAI/sem2/lambda/surfaces-bot/docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md)
diff --git a/docs/matrix-prototype.md b/docs/matrix-prototype.md
index 5e57c88..d79ff83 100644
--- a/docs/matrix-prototype.md
+++ b/docs/matrix-prototype.md
@@ -2,247 +2,103 @@
## Концепция
-Один бот, каждый чат — отдельная комната, все комнаты собраны в Space.
+Один бот, каждый чат — отдельная комната, все комнаты собраны в personal Space.
-При первом входе бот создаёт для пользователя личное пространство (Space) —
-это как папка в Element. Внутри Space бот создаёт комнату для каждого нового
-чата с агентом. Пользователь видит аккуратную структуру: одно пространство,
-внутри — список чатов. История хранится нативно в Matrix — это часть протокола,
-ничего дополнительно делать не нужно.
+При первом invite бот создаёт для пользователя личное пространство (Space) и первую рабочую комнату.
+История хранится нативно в Matrix. UX прагматичный: явные `!`-команды, локальный state-store, нативные Matrix rooms.
-Matrix выбран как внутренняя поверхность: команды лаборатории, тестировщики,
-разработчики скиллов. Поэтому UX здесь — про удобство работы, а не онбординг.
+Matrix — внутренняя поверхность: команда лаборатории, тестировщики, разработчики скиллов.
---
-## Аутентификация
+## Онбординг
-### Флоу
-1. Пользователь приглашает бота в личные сообщения или пишет в общей комнате
-2. Бот проверяет `@user:matrix.org` — есть ли аккаунт на платформе
-3. Если нет — бот отправляет одноразовый код или ссылку
-4. Пользователь подтверждает, платформа возвращает токен
-5. Бот сохраняет привязку `matrix_user_id → platform_user_id`
+1. Пользователь приглашает бота в личные сообщения (DM) на Matrix-сервере
+2. Бот принимает invite, создаёт Space `Lambda — {display_name}` и первую комнату `Чат 1`
+3. Приглашает пользователя в `Чат 1` и пишет приветствие
+4. Дальнейшее общение ведётся в рабочих комнатах, не в DM
-### В моке
-- Любой пользователь проходит аутентификацию автоматически
-- Бот отвечает: «Добро пожаловать, {display_name}. Создаю ваше пространство...»
-- Демонстрирует флоу без реальной платформы
-
----
-
-## Чаты через Space + комнаты (вариант Б)
-
-### Структура
```
Space: «Lambda — {display_name}»
- ├── 📌 Настройки ← специальная комната для команд управления
- ├── 💬 Чат 1 ← первый чат, создаётся автоматически
+ ├── 💬 Чат 1 ← создаётся автоматически при invite
├── 💬 Чат 2
- └── 💬 Исследование рынка ← пользователь сам называет
+ └── 💬 Исследование рынка ← пользователь называет сам через !new
```
-### Создание Space
-При первом входе бот:
-1. Создаёт Space `Lambda — {display_name}`
-2. Создаёт комнату `Настройки` (закреплена вверху)
-3. Создаёт первую комнату-чат `Чат 1`
-4. Приглашает пользователя во все комнаты
-5. Пишет в `Чат 1` приветствие
+**Требование:** незашифрованные комнаты. E2EE не поддержан (инфраструктурное ограничение).
+
+---
+
+## Работающие команды
### Управление чатами
-Команды работают в любой комнате Space:
| Команда | Действие |
|---|---|
| `!new` | Создать новый чат (новую комнату в Space) |
| `!new Название` | Создать чат с именем |
-| `!rename Название` | Переименовать текущую комнату |
-| `!archive` | Вывести комнату из Space (не удалять) |
-| `!chats` | Показать список чатов |
+| `!chats` | Список активных чатов |
+| `!rename <название>` | Переименовать текущую комнату |
+| `!archive` | Архивировать чат |
+| `!help` | Справка |
-### Создание нового чата
-1. Пользователь пишет `!new` или `!new Анализ конкурентов`
-2. Бот создаёт новую комнату в Space
-3. Приглашает пользователя
-4. Пишет приветствие; при первом сообщении платформа автоматически поднимает контейнер
-5. Пользователь переходит в новую комнату — начинает диалог
+### Контекст
-### В моке
-- Space и комнаты создаются реально через matrix-nio
-- Сообщения передаются в MockPlatformClient с `chat_id` (C1, C2...)
-- История хранится в Matrix нативно
+| Команда | Действие |
+|---|---|
+| `!clear` | Сбросить контекст текущего чата (создаёт новый thread у агента) |
+| `!reset` | Псевдоним для `!clear` |
+
+### Подтверждения
+
+| Команда | Действие |
+|---|---|
+| `!yes` | Подтвердить действие агента |
+| `!no` | Отменить действие агента |
+
+### Вложения (файловая очередь)
+
+Matrix-клиенты отправляют файлы и текст отдельными событиями. Файл без текстовой инструкции ставится в очередь, а не уходит агенту сразу.
+
+| Команда | Действие |
+|---|---|
+| `!list` | Показать файлы в очереди |
+| `!remove ` | Удалить файл из очереди по номеру |
+| `!remove all` | Очистить всю очередь |
+
+Как отправить файлы агенту:
+1. Отправь один или несколько файлов в рабочую комнату
+2. Напиши текстовое сообщение с инструкцией, например: `что на изображении?`
+3. Бот отправит агенту текст вместе со всеми файлами из очереди
---
-## Основной диалог
+## Диалог
-### Флоу сообщения
-1. Пользователь пишет текст в комнату-чат
-2. Бот показывает typing (m.typing event)
-3. Запрос уходит в платформу (MockPlatformClient)
-4. Бот отвечает в той же комнате
-
-### Вложения
-- Файлы, изображения отправляются как Matrix media events
-- Бот принимает `m.file`, `m.image`, `m.audio`
-- Передаёт в платформу как `attachments` через `IncomingMessage`
-- В моке: подтверждение получения + заглушка-ответ
-
-### Реакции как действия
-Matrix поддерживает реакции на сообщения (`m.reaction`).
-Используем это для подтверждения действий агента:
-
-```
-Агент: Хочу отправить письмо на vasya@mail.ru
- Тема: «Отчёт за неделю»
-
- 👍 — подтвердить ❌ — отменить
-```
-
-Пользователь ставит реакцию — бот обрабатывает. Нативно и удобно.
-
-### Треды для длинных задач
-Если агент выполняет долгую задачу (deep research, генерация документа),
-бот создаёт тред от своего первого ответа и пишет промежуточные статусы туда.
-Основной чат не засоряется.
-
-```
-Бот: Начинаю исследование по теме «AI агенты 2025» [→ в треде]
- └── Ищу источники... (1/4)
- └── Анализирую статьи... (2/4)
- └── Формирую отчёт... (3/4)
- └── Готово. Отчёт: [...]
-```
+- Любое текстовое сообщение уходит агенту, бот показывает typing-индикатор
+- Ответ стримится по WebSocket и выводится в ту же комнату
+- Каждая комната имеет свой `platform_chat_id` — контексты изолированы между комнатами
---
-## Комната «Настройки»
+## Передача файлов
-Специальная комната для управления агентом. Закреплена вверху Space.
-Команды работают только здесь — не мешают диалогу в чатах.
+### Пользователь → Агент
+Бот сохраняет файл в shared volume: `{workspace_path}/{filename}`
+и передаёт агенту относительный путь как `workspace_path`.
-### Коннекторы
-```
-!connectors — показать список
-!connect gmail — подключить Gmail (OAuth ссылка)
-!connect github — подключить GitHub
-!connect calendar — подключить Google Calendar
-!connect notion — подключить Notion
-!disconnect gmail — отключить
-```
-
-Статус:
-```
-Коннекторы:
- ✅ Gmail — подключён (user@gmail.com)
- ❌ GitHub — не подключён → !connect github
- ❌ Google Calendar — не подключён
- ❌ Notion — не подключён
-```
-
-В моке: OAuth ссылка-заглушка → «Подключено ✓»
-
-### Скиллы
-```
-!skills — показать список
-!skill on browser — включить Browser Use
-!skill off browser — выключить
-```
-
-Статус:
-```
-Скиллы:
- ✅ web-search — поиск в интернете
- ✅ fetch-url — чтение веб-страниц
- ✅ email — чтение почты (требует Gmail)
- ❌ browser — управление браузером
- ❌ image-gen — генерация изображений
- ❌ video-gen — генерация видео
- ✅ files — работа с файлами
- ❌ calendar — календарь (требует Google Calendar)
-```
-
-В моке: состояние хранится локально.
-
-### Личность агента
-```
-!soul — показать текущий SOUL.md
-!soul name Лямбда — задать имя агента
-!soul style brief — стиль: brief | friendly | formal
-!soul priority «разбирать почту утром» — приоритетная задача
-!soul reset — сбросить к дефолту
-```
-
-В моке: SOUL.md генерируется и хранится локально, агент обращается по имени.
-
-### Безопасность
-```
-!safety — показать настройки
-!safety on email-send — требовать подтверждение перед отправкой письма
-!safety off calendar-create — не спрашивать для создания событий
-```
-
-Статус:
-```
-Подтверждение требуется для:
- ✅ отправка письма
- ✅ удаление файлов
- ✅ публикация в соцсетях
- ❌ создание события в календаре
- ❌ поиск в интернете
-```
-
-### Подписка
-```
-!plan — показать текущий план
-```
-
-```
-Подписка: Beta (бесплатно)
-Токены этот месяц: 800 / 1000
-━━━━━━━━░░ 80%
-```
-
-Заглушка, реализует другая команда.
-
-### Статус и диагностика
-```
-!status — состояние платформы и чатов
-!whoami — текущий аккаунт платформы
-```
-
-```
-Статус:
- Платформа: ✅ доступна
- Аккаунт: user@lambda.lab
- Активных чатов: 3
-```
+### Агент → Пользователь
+Агент эмитит путь к файлу в своём workspace. Бот читает файл из `/agents/...`
+и отправляет пользователю как Matrix file message.
---
-## FSM состояния
+## Известные ограничения
-```
-[Invite] → AuthPending → AuthConfirmed
- ↓
- SpaceSetup → Idle (в комнате Настройки)
- ↓
- [новая комната] → ChatCreated → Idle (в чате)
- ↓
- ReceivingMessage → WaitingResponse → Idle
- ↓
- WaitingReaction (confirm) → [✅/❌] → Idle
- ↓
- LongTask → [тред со статусами] → Done → Idle
-```
-
----
-
-## Стек
-
-- Python 3.11+
-- matrix-nio (async) — Matrix клиент
-- MockPlatformClient → `platform/interface.py`
-- structlog для логирования
-- SQLite для хранения `matrix_user_id → platform_user_id`, состояния скиллов, маппинга `chat_id → room_id`
+| Проблема | Причина |
+|---|---|
+| `!save` / `!load` / `!context` | Нестабильны: `!save` зависит от агента (пишет файл в workspace), сессии хранятся in-memory и теряются при рестарте |
+| Первый чанк ответа иногда пропадает после tool/file flow | Баг в upstream `platform-agent`; подробности: `docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md` |
+| Персистентность истории между рестартами агента | `platform-agent` использует `MemorySaver` (in-memory) |
+| E2EE комнаты | `python-olm` не собирается на macOS/ARM |
+| `!settings` и настройки скиллов/SOUL/безопасности | Заглушки MVP, требуют готового SDK платформы |
diff --git a/docs/new-surface-guide.md b/docs/new-surface-guide.md
new file mode 100644
index 0000000..7ebdc2a
--- /dev/null
+++ b/docs/new-surface-guide.md
@@ -0,0 +1,313 @@
+# Руководство по созданию новой поверхности
+
+Этот документ описывает, как написать новую новую поверхность (например, Discord, Slack или Custom Web) по образцу текущей Matrix-поверхности в ветке `main`.
+
+Он основан на актуальной реализации Matrix surface в репозитории и отражает текущую продакшн-логику, а не устаревший легаси.
+
+---
+
+## 1. Общая архитектура
+
+### 1.1. Что такое поверхность
+
+Поверхность — это тонкий адаптер между конкретной платформой (Платформа) и общим ядром бота.
+
+В репозитории есть разделение:
+
+- `core/` — общее ядро и бизнес-логика
+- `adapter//` — реализация конкретной поверхности
+- `sdk/real.py` — работа с реальной платформой / агентом
+- `config/` — статическая конфигурация агентов
+- `docs/surface-protocol.md` — общий контракт поверхностей
+
+### 1.2. Как это работает
+
+Поверхность должна:
+
+- принимать нативные события от Платформа
+- преобразовывать их в единый внутренний контракт (`IncomingMessage`, `IncomingCommand`, `IncomingCallback`)
+- передавать их в `core`
+- получать ответы из `core` (`OutgoingMessage`, `OutgoingUI`, `OutgoingTyping`, `OutgoingNotification`)
+- преобразовывать ответы обратно в нативные нативные сообщения
+
+Поверхность не должна:
+
+- управлять жизненным циклом агентских контейнеров
+- хранить долгую историю бесед вне `core`/платформы
+- аутентифицировать пользователей сама (если это не часть Платформа API)
+
+---
+
+## 2. Структура новой поверхности
+
+### 2.1. Основные каталоги
+
+Рекомендуемая структура для новой платформы:
+
+```
+adapter//
+ bot.py
+ converter.py
+ agent_registry.py
+ files.py
+ handlers/
+ store.py
+```
+
+### 2.2. Принцип reuse
+
+По примеру Matrix surface, New surface должен переиспользовать общий `core` и общий `sdk`.
+
+Не дублируйте бизнес-логику, а реализуйте только адаптер:
+
+- `adapter//converter.py` — конвертация событий платформы ⇄ внутренние структуры
+- `adapter//bot.py` — основной runtime, старт Платформа client, loop, отправка/прием
+- `adapter//agent_registry.py` — загрузка `config/-agents.yaml`
+- `adapter//files.py` — хранение входящих/исходящих вложений
+
+---
+
+## 3. Контракт входящих/исходящих событий
+
+### 3.1. Внутренний формат
+
+Смотрите `core/protocol.py`. Основные типы:
+
+- `IncomingMessage` — обычное текстовое сообщение + вложения
+- `IncomingCommand` — управляющая команда
+- `IncomingCallback` — подтверждение / интерактивные действия
+- `OutgoingMessage` — ответ пользователю
+- `OutgoingUI` — интерфейсные элементы (кнопки и т.п.)
+- `OutgoingTyping` — индикатор печати
+- `OutgoingNotification` — системное уведомление
+
+### 3.2. Пример конверсии Matrix
+
+В Matrix-реализации `adapter/matrix/converter.py`:
+
+- текст `!yes` / `!no` превращается в `IncomingCallback` с `action: confirm/cancel`
+- `!list`/`!remove` говорят не агенту, а surface-процессу
+- вложения `m.file`, `m.image`, `m.audio`, `m.video` нормализуются в `Attachment`
+
+Для Платформа реализуйте аналогичную логику для native команд вашего клиента.
+
+---
+
+## 4. Реестр агентов и маршрутизация
+
+### 4.1. Что хранит реестр
+
+В текущей Matrix реализации есть `config/matrix-agents.yaml` и `adapter/matrix/agent_registry.py`.
+
+Структура:
+
+```yaml
+user_agents:
+ "@user0:matrix.example.org": agent-0
+ "@user1:matrix.example.org": agent-1
+
+agents:
+ - id: agent-0
+ label: "Agent 0"
+ base_url: "http://lambda.coredump.ru:7000/agent_0/"
+ workspace_path: "/agents/0"
+```
+
+### 4.2. Логика выбора агента
+
+- `user_agents` маппит конкретного пользователя на `agent_id`
+- если user_id не найден, используется первый агент из списка
+- `agents[].base_url` определяет URL агента
+- `agents[].workspace_path` определяет путь внутри surface-контейнера для этого агента
+
+Это важно: именно на этом контракте строится разделение агентов по рабочим каталогам.
+
+### 4.3. Рекомендуемая Версия для новой платформы
+
+Создайте `config/-agents.yaml` с тем же смыслом.
+
+- `user_agents` — маппинг external user_id → agent_id
+- `agents` — список агентов
+- `workspace_path` для каждого агента должен быть абсолютным путем внутри surface-контейнера, например `/agents/0`
+
+---
+
+## 5. Файловый контракт
+
+### 5.1. Shared volume
+
+Текущее Matrix-решение использует shared volume:
+
+- surface монтирует общий том как `/agents`
+- каждый агент видит свою поддиректорию как `/workspace`
+
+Топология:
+
+```
+Bot (/agents) Agent (/workspace = /agents/N/)
+ /agents/0/report.pdf ←──→ /workspace/report.pdf
+```
+
+### 5.2. Правила записи файлов
+
+В `adapter/matrix/files.py` реализовано:
+
+- входящий файл сохраняется прямо в `{workspace_root}/{filename}`
+- возвращается путь `workspace_path` относительный внутри рабочего каталога агента
+- при коллизии имен создаётся `file (1).ext`, `file (2).ext`
+- `Attachment.workspace_path` передаётся агенту
+
+Для исходящих файлов:
+
+- surface читает файл из `workspace_root / workspace_path`
+- загружает его в платформу
+
+### 5.3. Пример поведения
+
+- Пользователь отправляет файл → surface скачивает файл и кладёт его в agent workspace
+- Агент получает `attachments=["report.pdf"]` и работает с относительным `workspace_path`
+- Агент пишет результат в `/workspace/result.txt`
+- surface читает `/agents/{N}/result.txt` и отправляет файл пользователю
+
+---
+
+## 6. Чат-менеджмент и контекст
+
+### 6.1. `platform_chat_id`
+
+Matrix-реализация использует `platform_chat_id` как стабильный идентификатор чата на стороне агента.
+
+- `room_meta.platform_chat_id` определяется и сохраняется в `adapter/matrix/store.py`
+- `reconcile_startup_state()` восстанавливает отсутствующие `platform_chat_id` при рестарте
+- `RoutedPlatformClient` перенаправляет запросы агенту по `agent_id` + `platform_chat_id`
+
+Для New surface тот же принцип:
+
+- каждая внешняя беседа должна привязываться к одному внутреннему `chat_id`
+- этот `chat_id` используется для вызовов агента
+- если в Платформа есть несколько комнат/топиков, каждая должна иметь свой `surface_ref`
+
+### 6.2. Команды управления чатами
+
+Matrix поддерживает следующие команды, которые нужно сохранить в Платформа:
+
+- `!new [название]` — создать новый чат
+- `!chats` — список активных чатов
+- `!rename <название>` — переименовать текущий чат
+- `!archive` — архивировать чат
+- `!clear` / `!reset` — сбросить контекст текущего чата
+- `!yes` / `!no` — подтвердить или отменить действие агента
+- `!list` — показать очередь вложений
+- `!remove ` / `!remove all` — удалить вложение из очереди
+- `!help` — справка
+
+Эти команды реализованы в Matrix через `adapter/matrix/handlers/`.
+
+### 6.3. Очередь вложений
+
+Matrix surface поддерживает staged attachments:
+
+- файл может быть отправлен без текста
+- surface сохраняет файл в `staged_attachments` для конкретного room_id + user_id
+- следующий текст отправляется агенту вместе со всеми файлами из очереди
+
+В Платформа можно реализовать ту же модель:
+
+- `!list` показывает текущую очередь
+- `!remove` удаляет файл из очереди
+- команда-индикатор или следующее текстовое сообщение отправляет queued attachments агенту
+
+---
+
+## 7. Runtime и окружение
+
+### 7.1. Переменные среды
+
+Для Matrix surface текущий runtime ожидает:
+
+- `MATRIX_HOMESERVER` — URL Matrix-сервера
+- `MATRIX_USER_ID` — `@bot:example.org`
+- `MATRIX_PASSWORD` или `MATRIX_ACCESS_TOKEN`
+- `MATRIX_PLATFORM_BACKEND` — должно быть `real` для продакшна
+- `MATRIX_AGENT_REGISTRY_PATH` — путь к `config/matrix-agents.yaml`
+- `AGENT_BASE_URL` — fallback URL агента
+- `SURFACES_WORKSPACE_DIR` — путь к shared volume внутри контейнера (по умолчанию `/workspace` в коде, но в docs рекомендуют `/agents`)
+
+Для New surface используйте аналогичные переменные:
+
+- `PLATFORM_PLATFORM_BACKEND=real`
+- `PLATFORM_AGENT_REGISTRY_PATH=/app/config/-agents.yaml`
+- `SURFACES_WORKSPACE_DIR=/agents`
+- `AGENT_BASE_URL` — если хотите общий fallback
+
+### 7.2. Environment contract
+
+В коде `adapter/matrix/bot.py`:
+
+- `_agent_base_url_from_env()` читает `AGENT_BASE_URL` или `AGENT_WS_URL`
+- `_load_agent_registry_from_env()` читает `MATRIX_AGENT_REGISTRY_PATH`
+- `_build_platform_from_env()` выбирает `RealPlatformClient` при `MATRIX_PLATFORM_BACKEND=real`
+
+В New surface реализуйте ту же логику, заменив префиксы на `PLATFORM_`.
+
+---
+
+## 8. Локальное тестирование
+
+Для тестирования новой поверхности вместе с одним локальным агентом используйте паттерн `docker-compose.fullstack.yml`.
+В этом режиме:
+- Запускается 1 контейнер вашей поверхности
+- Запускается 1 контейнер `platform-agent`
+- Поднимается локальный shared volume (`surfaces-agents`)
+- Поверхность настроена маршрутизировать запросы на `http://platform-agent:8000` (через `AGENT_BASE_URL`)
+- Пользователь общается с ботом, а бот напрямую общается с локальным агентом, разделяя с ним общую папку для файлов.
+
+Это самый быстрый способ проверить интеграцию новой платформы без внешнего бэкенда.
+
+---
+
+## 9. Реализация шаг за шагом
+
+1. Скопировать `adapter/matrix/` как шаблон для `adapter//`.
+2. Сделать `adapter//converter.py`:
+ - превратить native нативные сообщения в `IncomingMessage`
+ - превратить команды в `IncomingCommand`
+ - превратить yes/no-подтверждения в `IncomingCallback`
+3. Сделать `adapter//agent_registry.py` на основе `adapter/matrix/agent_registry.py`.
+4. Сделать `adapter//files.py` на основе `adapter/matrix/files.py`.
+5. Сделать `adapter//bot.py`:
+ - инстанцировать runtime
+ - читать env vars `PLATFORM_*`
+ - загружать реестр агентов
+ - обрабатывать входящие события
+ - отправлять `Outgoing*` обратно в Платформа
+6. Реализовать команды управления чатами и очередь вложений.
+7. Прописать `config/-agents.yaml`.
+8. Прописать `docker-compose.platform.yml` или аналог, чтобы surface монтировал `/agents`.
+9. Написать тесты по аналогии с `tests/adapter/matrix/`.
+10. Проверить, что все env vars читаются из окружения и не зависят от устаревших Matrix-переменных.
+
+---
+
+## 10. Важные замечания
+
+- Текущий Matrix surface на ветке `main` — активная реализация, а не устаревший легаси.
+- Документация и код согласованы: `agent_registry`, `files`, `routed_platform`, `reconciliation` работают вместе.
+- Обязательно явно задавайте `SURFACES_WORKSPACE_DIR=/agents` в production, если `workspace_path` в реестре указывает на `/agents/*`.
+- Для New surface сохраните ту же архитектуру: surface = thin adapter, агенты = внешние сервисы.
+- Не пытайтесь в surface реализовывать логику запуска/стопа агент-контейнеров.
+
+---
+
+## 11. Полезные ссылки внутри репозитория
+
+- `README.md`
+- `docs/deploy-architecture.md`
+- `docs/surface-protocol.md`
+- `adapter/matrix/bot.py`
+- `adapter/matrix/converter.py`
+- `adapter/matrix/agent_registry.py`
+- `adapter/matrix/files.py`
+- `adapter/matrix/routed_platform.py`
+- `adapter/matrix/reconciliation.py`
+- `tests/adapter/matrix/`
diff --git a/docs/reports/2026-04-01-final-report.md b/docs/reports/2026-04-01-final-report.md
new file mode 100644
index 0000000..8298931
--- /dev/null
+++ b/docs/reports/2026-04-01-final-report.md
@@ -0,0 +1,280 @@
+# Отчёт о проделанной работе — Surfaces Team
+
+**Проект:** Lambda Lab 3.0 — Surfaces
+**Дата:** 2026-04-01
+**Период:** 2026-03-28 — 2026-04-01
+
+---
+
+## 1. Цель этапа
+
+Собрать работоспособный прототип двух поверхностей для взаимодействия пользователя с AI-агентом Lambda:
+
+- **Telegram-бот** — основная пользовательская поверхность
+- **Matrix-бот** — альтернативная децентрализованная поверхность
+
+Ключевое требование: не ждать готовности платформенного SDK, а двигаться вперёд через собственный контракт и мок-реализацию. Это позволило вести параллельную разработку UX, архитектуры и интеграции без блокировки на внешние зависимости.
+
+---
+
+## 2. Архитектура
+
+### 2.1. Общее ядро (`core/`)
+
+Выделен независимый от транспорта слой, используемый обеими поверхностями:
+
+| Компонент | Файл | Назначение |
+|-----------|------|-----------|
+| Протокол событий | `core/protocol.py` | `IncomingMessage`, `OutgoingMessage`, `OutgoingUI` и др. |
+| Диспетчер | `core/handler.py` | `EventDispatcher`: маршрутизация событий → обработчики |
+| Обработчики | `core/handlers/` | `start`, `message`, `chat`, `settings`, `callback` |
+| Хранилище состояний | `core/store.py` | `InMemoryStore`, `SQLiteStore` |
+| Менеджмент чатов | `core/chat.py` | `ChatManager` |
+| Аутентификация | `core/auth.py` | `AuthManager` |
+| Настройки | `core/settings.py` | `SettingsManager` |
+
+Telegram и Matrix — тонкие адаптеры: принимают транспортные события, конвертируют в формат ядра, передают в `core`, рендерят ответ обратно.
+
+### 2.2. Платформенный контракт (`sdk/`)
+
+Вместо ожидания SDK Lambda зафиксирован собственный контракт:
+
+- `sdk/interface.py` — Protocol: `PlatformClient`, `WebhookReceiver`
+- `sdk/mock.py` — `MockPlatformClient` (заглушка с симулируемой латентностью)
+
+При подключении реального SDK заменяется только `sdk/mock.py` — core и адаптеры не трогаются.
+
+> **Примечание:** в процессе работы директория `platform/` была переименована в `sdk/` для устранения конфликта имён со стандартной библиотекой Python (`platform.python_implementation`). Все импорты обновлены.
+
+### 2.3. Структура репозитория
+
+```
+surfaces-bot/
+ core/ — общее ядро
+ sdk/ — платформенный контракт и мок
+ adapter/
+ telegram/ — Telegram-адаптер (worktree: feat/telegram-adapter)
+ matrix/ — Matrix-адаптер (в main)
+ docs/
+ superpowers/
+ specs/ — утверждённые спецификации
+ plans/ — планы реализации
+ research/ — исследования API и архитектурных вариантов
+ reports/ — отчёты
+ tests/ — pytest (70 тестов)
+```
+
+---
+
+## 3. Telegram: итоги
+
+### 3.1. Что реализовано
+
+**Базовый DM-режим (полностью работает):**
+
+| Функция | Команда/механизм |
+|---------|-----------------|
+| Онбординг | `/start` — создание первого чата, восстановление сессии |
+| Создание чатов | `/new [название]` |
+| Список чатов | `/chats` — инлайн-кнопки с переключением |
+| Диалог | Любое сообщение → мок-ответ `[MOCK] Ответ на: «...»` |
+| Typing indicator | `send_chat_action("typing")` + обновление каждые 4 сек |
+| Настройки | `/settings` → меню: скиллы, личность агента, безопасность, подписка |
+| Подтверждения | `confirm:yes/` / `confirm:no/` через `InlineKeyboard` |
+| Список команд | Зарегистрирован через `set_my_commands()` |
+| Вложения | Конвертируются в `Attachment` (фото, документ, голос) |
+
+**Forum Topics режим (реализован поверх DM):**
+
+| Функция | Описание |
+|---------|----------|
+| Подключение группы | `/forum` → FSM онбординг → пересылка сообщения из супергруппы |
+| Проверка прав | Бот должен быть администратором с `can_manage_topics` |
+| Синхронизация | При подключении группы создаются темы для всех DM-чатов |
+| Регистрация темы | `/new` в forum-теме регистрирует её как чат |
+| Создание с синхронизацией | `/new` в DM + подключённая группа → создаёт и DM-чат, и forum-тему |
+| Маршрутизация | Пришло из DM → ответ в DM с тегом `[Чат #N]`; из темы → ответ в тему без тега |
+
+**Ключевые принятые решения:**
+- Основной режим — виртуальные чаты в DM (нулевое friction)
+- Forum Topics — opt-in advanced mode, не обязательный
+- Бот не создаёт группы сам (Telegram Bot API не позволяет)
+- Один контекст (`chat_id` = UUID) для обеих поверхностей
+
+### 3.2. Техническая реализация
+
+```
+adapter/telegram/
+ bot.py — Dispatcher, DispatcherMiddleware, регистрация роутеров
+ states.py — ChatState, SettingsState, ForumSetupState
+ db.py — SQLite: tg_users + chats (включая forum_group_id, forum_thread_id)
+ converter.py — from_message(), is_forum_message(), resolve_forum_chat_id()
+ handlers/
+ auth.py — /start
+ chat.py — сообщения, /new, /chats, forum-маршрутизация
+ settings.py — /settings, скиллы, личность, безопасность, подписка
+ confirm.py — подтверждение действий агента
+ forum.py — /forum, онбординг, регистрация группы
+ keyboards/
+ chat.py — список чатов
+ settings.py — меню настроек, скиллы, безопасность
+ confirm.py — кнопки ✅/❌
+```
+
+**Исправленные баги:**
+- Команды (`/new`, `/settings` и др.) обрабатывались как обычные сообщения — исправлено фильтром `~F.text.startswith("/")`
+- Конфликт `platform/` с stdlib Python — устранён переименованием в `sdk/`
+
+### 3.3. Документация
+
+- Спецификация DM-режима: `docs/superpowers/specs/2026-03-31-telegram-adapter-design.md`
+- Спецификация Forum Topics: `docs/superpowers/specs/2026-03-31-forum-topics-design.md`
+- План реализации Forum Topics: `docs/superpowers/plans/2026-03-31-forum-topics.md`
+- Исследования: `docs/research/telegram-chat-alternatives.md`, `docs/research/telegram-forum-topics.md`
+
+### 3.4. Открытые задачи
+
+- Edge-cases forum synchronization (частично закрыты агентами после лимита)
+- Ручной QA форум-сценариев
+- Слияние `feat/telegram-adapter` → `main`
+
+---
+
+## 4. Matrix: итоги
+
+### 4.1. Что реализовано
+
+- Matrix bot entrypoint (`adapter/matrix/bot.py`)
+- Converter layer (Matrix events → `IncomingEvent`)
+- Room metadata store
+- Маршрутизация входящих событий
+- Обработка реакций
+- Обработка приглашений (invite → DM onboarding)
+- Platform-aware command hints (`/start` для Telegram, `!start` для Matrix)
+- Модель room-per-chat: команда `!new` создаёт **реальную Matrix room**
+
+### 4.2. Архитектурный сдвиг: Space-first → DM-first
+
+Изначально рассматривалась модель Space-first (персональный Space + settings-room + отдельные комнаты внутри Space). По ходу реализации выбран более прагматичный первый этап:
+
+- **DM-first onboarding**: пользователь приглашает бота → бот приветствует → первый контекст привязывается к C1
+- **Room-per-chat**: `!new` создаёт реальную Matrix room, бот приглашает пользователя
+
+Это соответствует принципу: каждый чат — отдельная сущность транспорта, не только внутренняя запись.
+
+### 4.3. Критические баги, исправленные в ходе работы
+
+| Баг | Причина | Исправление |
+|-----|---------|-------------|
+| Бот не принимал invite | Подписка только на `RoomMemberEvent` | Добавлена поддержка `InviteMemberEvent` |
+| Бот отвечал сам себе (цикл) | Нет фильтра собственных сообщений | События от `self.client.user_id` игнорируются |
+| Дублирование приветствия | Неидемпотентный invite flow | Room onboarding сделан идемпотентным |
+| Агрессивные timeout/retry | Настройки sync по умолчанию | Настроен `AsyncClientConfig` |
+| Telegram-ориентированные команды | Тексты в ядре не учитывали платформу | Platform-aware hints в core |
+
+### 4.4. Тесты Matrix
+
+Собран и проходит набор тестов:
+- converter tests
+- dispatcher tests
+- reactions tests
+- store tests
+- интеграционные тесты core-сценариев
+
+Покрытые сценарии: разбор команд `!new`, `!skills`, `!yes`, `!no`; invite onboarding; защита от self-loop; создание реальной Matrix room; mapping `room_id → chat_id`.
+
+### 4.5. Ограничение: Matrix E2EE
+
+Шифрование (E2EE) в текущей реализации не поддержано. Причина — внешняя:
+
+- `matrix-nio` требует `python-olm` для E2EE
+- сборка `python-olm` не воспроизводится на текущей macOS/ARM среде
+
+Текущий рабочий сценарий: **только незашифрованные комнаты**. E2EE — отдельная инфраструктурная задача.
+
+### 4.6. Документация
+
+- Спецификация: `docs/superpowers/specs/2026-03-31-matrix-adapter-design.md`
+- План реализации: `docs/superpowers/plans/2026-03-31-matrix-adapter.md`
+
+---
+
+## 5. Тесты
+
+```
+tests/
+ core/ — 46 тестов (EventDispatcher, ChatManager, AuthManager, SettingsManager, stores)
+ platform/ — 5 тестов (MockPlatformClient)
+ adapter/ — 3 теста (forum DB functions) [в процессе]
+
+Итого: 70 passed, 3 errors (ошибки — проблема пути импорта в CI, не логики)
+```
+
+---
+
+## 6. Отклонения от исходного плана
+
+| Аспект | Исходный план | Фактическое решение | Причина |
+|--------|--------------|-------------------|---------|
+| Telegram Forum | Бот создаёт группу сам | Пользователь создаёт, бот подключается | Telegram Bot API не позволяет создавать группы |
+| Matrix UX | Space-first | DM-first + room-per-chat | Быстрее работает, проще в отладке |
+| Платформенный слой | `platform/` | `sdk/` | Конфликт имён с stdlib Python |
+| Matrix E2EE | В области применения | Вынесено как отдельная задача | Инфраструктурный блокер (python-olm) |
+
+Все изменения — корректная инженерная адаптация, не регресс.
+
+---
+
+## 7. Текущий статус по направлениям
+
+| Направление | Статус | Примечание |
+|-------------|--------|-----------|
+| `core/` | ✅ Готово | Полное покрытие тестами |
+| `sdk/` (mock) | ✅ Готово | Замена на реальный SDK — замена одного файла |
+| Telegram DM-режим | ✅ Готово | Можно тестировать руками |
+| Telegram Forum Topics | ✅ Реализовано | Требует ручного QA |
+| Matrix adapter | ✅ Готово | В `main` |
+| Matrix E2EE | ⏸ Заблокировано | Инфраструктурный блокер |
+| Слияние Telegram ветки | 🔄 В процессе | `feat/telegram-adapter` → `main` |
+
+---
+
+## 8. Риски
+
+| Риск | Уровень | Митигация |
+|------|---------|-----------|
+| Matrix E2EE | Средний | Работаем с незашифрованными комнатами, E2EE — отдельный тикет |
+| Forum sync edge-cases | Низкий | Базовый сценарий работает, edge-cases в backlog |
+| Реальный SDK vs мок | Низкий | Контракт зафиксирован, замена изолирована в `sdk/mock.py` |
+
+---
+
+## 9. Следующие шаги
+
+**Ближайшие:**
+1. Ручной QA Telegram Forum Topics
+2. Слияние `feat/telegram-adapter` → `main`
+3. Ручной QA Matrix-бота (issue `#14`)
+
+**Среднесрочные:**
+1. Расширить покрытие тестами (adapter-level)
+2. Довести Matrix settings workflow
+3. Актуализировать `docs/api-contract.md`
+
+**Стратегические:**
+1. Подготовить замену `MockPlatformClient` → реальный SDK Lambda
+2. Довести обе поверхности до demo-ready состояния
+3. Отдельно решить Matrix E2EE (инфраструктура)
+
+---
+
+## 10. Вывод
+
+За текущий этап команда собрала работающий каркас двух поверхностей вокруг единого ядра и собственного платформенного контракта.
+
+**Практический итог:**
+- Telegram в стадии реального UX-прототипа — можно демонстрировать
+- Matrix имеет рабочий transport-слой и модель комнат
+- Архитектура устойчива и готова к замене мока на реальный SDK
+
+Проект движется по инженерной логике: исследование ограничений → адаптация архитектуры → фиксация решений → реализация. Не по формальному чеклисту.
diff --git a/docs/reports/2026-04-01-surfaces-progress-report.md b/docs/reports/2026-04-01-surfaces-progress-report.md
new file mode 100644
index 0000000..2c2e408
--- /dev/null
+++ b/docs/reports/2026-04-01-surfaces-progress-report.md
@@ -0,0 +1,601 @@
+# Отчёт о проделанной работе
+
+**Проект:** Lambda Lab 3.0 — Surfaces
+**Команда:** Surfaces Team
+**Дата:** 2026-04-01
+**Период отчёта:** текущий этап разработки прототипов Telegram и Matrix
+
+---
+
+## 1. Цель этапа
+
+Целью текущего этапа было собрать работоспособный прототип двух поверхностей для взаимодействия пользователя с AI-агентом Lambda:
+
+- Telegram-бота
+- Matrix-бота
+
+При этом важным требованием было не ждать готовности платформенного SDK, а сразу строить систему вокруг собственного контракта и мок-реализации платформы. Это позволило параллельно двигаться по UX, архитектуре и интеграционным сценариям, не блокируясь внешними зависимостями.
+
+---
+
+## 2. Что было сделано на уровне архитектуры
+
+### 2.1. Сформировано общее ядро
+
+В репозитории выделено общее `core/`, которое не зависит от конкретного транспорта и используется обеими поверхностями.
+
+Реализованы:
+
+- единый протокол событий и ответов
+- диспетчеризация входящих событий через `EventDispatcher`
+- менеджмент чатов
+- менеджмент аутентификации
+- менеджмент настроек
+- общее state-хранилище (`InMemoryStore`, `SQLiteStore`)
+
+Это позволило построить Telegram и Matrix как тонкие адаптеры, которые:
+
+- принимают события транспорта
+- конвертируют их в единый формат ядра
+- передают в `core`
+- рендерят результат обратно в транспорт
+
+### 2.2. Зафиксирован платформенный контракт
+
+Вместо ожидания готового SDK был введён собственный контракт через:
+
+- [`sdk/interface.py`](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/interface.py)
+- [`sdk/mock.py`](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/mock.py)
+
+За счёт этого:
+
+- UX и интеграционный слой можно развивать уже сейчас
+- реальные платформенные вызовы можно позже подключить заменой одной реализации
+- транспортные адаптеры и `core` не придётся переписывать
+
+### 2.3. Уточнена текущая архитектурная стратегия
+
+По ходу работы часть исходных планов была пересмотрена и адаптирована под реальные ограничения платформ и API.
+
+Ключевые изменения:
+
+- `platform/` был переименован в `sdk/` для устранения конфликта имён и более точного смысла слоя
+- Telegram ушёл от идеи автоматического создания групп ботом: Bot API этого не позволяет
+- Matrix ушёл от Space-first реализации к DM-first / room-first модели как к более реалистичному первому рабочему этапу
+
+---
+
+## 3. Telegram: текущее состояние
+
+### 3.1. Организация разработки
+
+Telegram-часть выделена в отдельный worktree:
+
+- ветка: `feat/telegram-adapter`
+
+Это позволило вести Telegram независимо от Matrix и не смешивать контексты разработки.
+
+### 3.2. Что реализовано
+
+В Telegram-адаптере уже собран рабочий базовый UX:
+
+- стартовый onboarding через `/start`
+- основной диалог в DM
+- создание новых чатов
+- список чатов и переключение между ними
+- меню настроек
+- подтверждение действий через inline-кнопки
+- базовая работа с вложениями
+
+Отдельно реализован **Forum Topics mode** как расширение поверх DM-сценария:
+
+- команда `/forum`
+- подключение уже существующей forum-group через пересланное сообщение
+- проверка, что бот является администратором с правом управления темами
+- синхронизация существующих локальных чатов с forum topics
+- routing сообщений из topic обратно в нужный chat context
+- routing confirm callbacks внутри topic
+
+### 3.3. Принятые продуктовые решения
+
+Во время разработки были приняты важные решения по UX Telegram:
+
+- основным пользовательским сценарием остаётся DM-first
+- Forum Topics не являются обязательным режимом, а выступают как advanced mode
+- контекст чатов должен синхронизироваться между DM и topic-представлением
+- пользователь не должен сталкиваться с невозможной автоматизацией создания групп со стороны бота
+
+### 3.4. Что ещё не закрыто
+
+Для Telegram остаются открытые задачи, в первую очередь в области polish и согласованности UX:
+
+- не все сценарии forum synchronization доведены до конца
+- есть оставшиеся вопросы по командам в topic-контексте
+- нужен дополнительный проход по UX-деталям и ручному QA
+
+Актуальный follow-up зафиксирован в issue:
+
+- `#15` Telegram forum topics: remaining UX and synchronization gaps
+
+---
+
+## 4. Matrix: текущее состояние
+
+### 4.1. Что реализовано
+
+В `main` уже добавлен Matrix-адаптер, включающий:
+
+- Matrix bot entrypoint
+- converter layer
+- room metadata store
+- routing входящих событий
+- обработку реакций
+- обработку приглашения в DM
+- базовый onboarding
+- platform-aware command hints
+- набор adapter-level тестов
+
+### 4.2. Главный архитектурный сдвиг
+
+Изначально Matrix рассматривался через модель:
+
+- персональный Space
+- settings-room
+- отдельные room-чаты внутри Space
+
+Однако по ходу реализации был выбран более прагматичный маршрут первого этапа:
+
+- **DM-first onboarding**
+- затем **room-per-chat**
+
+Текущее поведение:
+
+- пользователь приглашает бота в комнату
+- бот приветствует пользователя
+- первый контекст привязывается к `C1`
+- команда `!new` создаёт **реальную новую Matrix room**
+- бот приглашает пользователя в эту новую комнату
+
+Это уже соответствует целевому принципу:
+
+> новый чат пользователя должен быть отдельной сущностью транспорта, а не только внутренней записью в `core`
+
+### 4.3. Критические баги, которые были обнаружены и исправлены
+
+Во время ручной проверки Matrix были найдены и устранены несколько важных проблем:
+
+1. **бот не принимал invite корректно**
+ - причина: подписка только на `RoomMemberEvent`
+ - исправление: добавлена поддержка `InviteMemberEvent`
+
+2. **бот отвечал сам себе и уходил в цикл**
+ - симптом: спам приветствиями и сообщениями типа `Введите !start`
+ - причина: отсутствие фильтра собственных сообщений
+ - исправление: события от `self.client.user_id` теперь игнорируются
+
+3. **дублировалось стартовое приветствие**
+ - причина: invite-flow был неидемпотентным
+ - исправление: room onboarding сделан идемпотентным
+
+4. **слишком агрессивные timeout/retry при sync**
+ - исправление: настроен более мягкий transport config через `AsyncClientConfig`
+
+5. **команды и подсказки были Telegram-ориентированными**
+ - исправление: тексты в ядре стали platform-aware (`/start` для Telegram, `!start` для Matrix)
+
+### 4.4. Что подтверждено тестами
+
+Для Matrix собран и пройден набор тестов:
+
+- converter tests
+- dispatcher tests
+- reactions tests
+- store tests
+- интеграционные тесты core-сценариев
+
+Примеры покрытых сценариев:
+
+- разбор команд `!new`, `!skills`, `!yes`, `!no`
+- invite onboarding
+- защита от self-loop
+- создание реальной Matrix room на `!new`
+- mapping `room_id -> chat_id`
+
+### 4.5. Ограничение текущей реализации
+
+Главное незакрытое ограничение Matrix на текущий момент:
+
+## encrypted DM пока не поддержан
+
+Причина не в логике бота, а во внешнем crypto-stack:
+
+- для E2EE в `matrix-nio` нужен `python-olm`
+- на текущей macOS/ARM среде сборка `python-olm` не воспроизводится корректно
+- поэтому в рабочем сценарии Matrix пока используется **только незашифрованный room flow**
+
+Это означает:
+
+- незашифрованные комнаты и room-per-chat можно развивать и тестировать уже сейчас
+- encrypted DM нужно рассматривать как отдельную инфраструктурную подзадачу
+
+### 4.6. Что ещё остаётся по Matrix
+
+Открытые направления:
+
+- ручной QA текущего Matrix-бота
+- доработка UX и edge-cases room-per-chat
+- дальнейшее развитие settings-команд
+- возможное возвращение к Space lifecycle как следующему этапу
+- отдельный infrastructure task по E2EE / `python-olm`
+
+Для ручного тестирования создан issue:
+
+- `#14` Manual QA: test Matrix bot and record issues / gaps
+
+---
+
+## 5. Что было сделано с точки зрения git и процесса
+
+### 5.1. Основные изменения были оформлены коммитами
+
+На текущем этапе были сделаны и запушены в репозиторий следующие ключевые коммиты:
+
+- `82eb711` — базовый Matrix adapter + platform-aware command hints
+- `14c091b` — реальное создание новых Matrix rooms на `!new`
+- `6a843e8` — transport timeout tuning для Matrix sync
+- `27f3da8` — обновление README под фактическую архитектуру проекта
+
+### 5.2. Проведён аудит backlog
+
+По открытым issue был выполнен аудит:
+
+- закрыты уже выполненные задачи
+- устаревшие issue переписаны под текущую архитектуру
+- не выполненные и актуальные задачи оставлены открытыми
+
+В частности:
+
+- закрыт issue `#13` по Matrix research
+- актуализированы старые Telegram и Matrix issue под текущие реальные пути, ограничения и UX-модель
+
+---
+
+## 6. Что изменилось по сравнению с изначальным планом
+
+Это важный блок для руководителя: проект движется не просто по “чеклисту задач”, а по реальным ограничениям платформ.
+
+### 6.1. Telegram
+
+Изначально планировался сценарий, где бот создаёт Forum-группу сам.
+
+Фактический результат исследования и реализации показал:
+
+- Telegram Bot API этого не позволяет
+- группа создаётся пользователем вручную
+- бот подключается к уже существующей группе
+
+Это не регресс, а корректная адаптация архитектуры под реальные ограничения API.
+
+### 6.2. Matrix
+
+Изначально планировался Space-first UX.
+
+Фактически первым рабочим этапом стала модель:
+
+- DM-first onboarding
+- затем room-per-chat
+
+Причина:
+
+- так можно получить работающий transport flow раньше
+- это проще в отладке
+- это не блокирует дальнейший переход к Space lifecycle
+
+### 6.3. Платформенный слой
+
+Изначально существовали старые пути и слои, которые затем были пересобраны в более понятную форму.
+
+Итоговое направление:
+
+- `sdk/interface.py`
+- `sdk/mock.py`
+- `core/` как единый уровень бизнес-логики
+- transport adapters отдельно
+
+Это повысило устойчивость архитектуры и упростило дальнейшую замену mock на реальный SDK.
+
+---
+
+## 7. Основные результаты этапа
+
+К концу текущего этапа проект достиг следующих результатов:
+
+### Telegram
+
+- есть рабочий Telegram adapter
+- реализован основной DM flow
+- реализован Forum Topics mode
+- собрана отдельная ветка/worktree под Telegram
+- основные пользовательские сценарии уже можно проверять руками
+
+### Matrix
+
+- есть рабочий Matrix adapter
+- invite/onboarding flow уже функционирует
+- реализована модель room-per-chat
+- устранены основные критические баги цикла и self-processing
+- собран базовый test suite
+
+### Общий уровень проекта
+
+- ядро и контракты унифицированы
+- backlog приведён в соответствие с реальной архитектурой
+- README актуализирован под текущее состояние
+- ручной QA Matrix вынесен в отдельную управляемую задачу
+
+---
+
+## 8. Текущие риски и ограничения
+
+### Технические риски
+
+1. **Matrix E2EE**
+ - blocked внешним crypto-stack
+ - не решается только правками Python-кода в проекте
+
+2. **Telegram forum synchronization**
+ - функциональность уже есть, но остаются edge-cases и UX-недоработки
+
+3. **Расхождение старых документов и новых решений**
+ - backlog уже частично синхронизирован
+ - но часть старых design assumptions всё ещё может встречаться в документации
+
+### Процессные риски
+
+1. требуется более строгий feature-branch workflow для следующих этапов Matrix
+2. для Telegram и Matrix желательно продолжать раздельную работу по веткам/worktree
+3. ручной QA остаётся критичным, особенно для Matrix transport behavior
+
+---
+
+## 9. Следующие шаги
+
+### Ближайшие
+
+1. Провести ручной QA Matrix-бота по issue `#14`
+2. Зафиксировать воспроизводимые проблемы Matrix
+3. Продолжить Telegram в worktree `feat/telegram-adapter`
+4. Довести Telegram forum synchronization gaps по issue `#15`
+
+### Среднесрочные
+
+1. Расширить покрытие тестами
+2. Довести Matrix settings workflow
+3. Уточнить и обновить `docs/api-contract.md`
+4. Отдельно решить вопрос Matrix E2EE support
+
+### Стратегические
+
+1. Подготовить замену `MockPlatformClient` на реальный SDK
+2. Довести обе поверхности до более стабильного demo-ready состояния
+3. Выровнять UX Telegram и Matrix вокруг общих принципов surface protocol
+
+---
+
+## 10. Краткий вывод для руководителя
+
+На текущем этапе команда не просто написала часть кода, а уже собрала работающий каркас двух поверхностей вокруг общего ядра и собственного платформенного контракта.
+
+Главный практический результат:
+
+- Telegram уже находится в стадии реального UX-прототипа
+- Matrix уже имеет рабочий transport-слой и модель отдельных комнат для чатов
+- архитектура проекта стала значительно устойчивее и ближе к реальной интеграции с платформой
+
+При этом команда корректно адаптировала исходные планы под реальные ограничения Telegram Bot API и Matrix ecosystem, не пытаясь “продавить” заведомо неверные решения.
+
+То есть проект движется не по формальному чеклисту, а по зрелой инженерной логике:
+
+- исследование
+- фиксация архитектурных решений
+- рабочая реализация
+- ручной QA
+- корректировка backlog под фактическое состояние системы
+
+Это хороший признак для дальнейшего перехода от прототипа к более устойчивой демонстрационной версии.
+
+
+## 8. Дополнение: итоги отдельной Telegram-сессии по Forum Topics
+
+В рамках отдельной рабочей сессии в Telegram worktree `feat/telegram-adapter` был проведён focused pass по качеству и устойчивости **Forum Topics mode**. Целью этой работы было не просто добавить функциональность, а довести forum-сценарии до состояния, в котором их можно стабильно демонстрировать, вручную тестировать и развивать дальше без постоянных расхождений между UX, кодом и документацией.
+
+### 8.1. Что было выявлено в начале сессии
+
+При аудите Telegram-ветки подтвердилось, что базовая реализация уже существует:
+
+- Telegram adapter реализован
+- Forum Topics mode уже добавлен
+- `/forum` onboarding присутствует
+- forum thread routing реализован
+- confirm callbacks внутри forum thread уже работают
+
+Однако вместе с этим были обнаружены существенные проблемы двух типов.
+
+**Первый тип — расхождение документации и фактической реализации.**
+Часть документов всё ещё описывала старую DM-only или forum-only модель, тогда как код фактически уже работал как hybrid `DM + Forum Topics`.
+
+**Второй тип — реальные поведенческие баги forum mode.**
+Наиболее заметные проблемы:
+
+- нестабильный onboarding подключения forum group
+- слабая диагностика ошибок подключения
+- возможность сломать соответствие `topic -> chat` через команды управления чатами внутри topic
+- неполная согласованность UX внутри forum topics
+
+### 8.2. Исправление документации
+
+Были актуализированы Telegram-документы, чтобы они соответствовали реальному состоянию ветки:
+
+- `docs/telegram-prototype.md`
+- `docs/superpowers/specs/2026-03-31-telegram-adapter-design.md`
+
+Что было отражено в документации:
+
+- Telegram работает как hybrid-модель `DM + Forum Topics`
+- DM остаётся базовой поверхностью
+- Forum Topics — расширенный режим поверх того же chat context
+- `/forum` подключает уже существующую forum-group пользователя
+- один и тот же `chat_id` может быть доступен как из DM, так и из forum topic
+- forum thread routing и confirm callbacks уже входят в реализованную модель адаптера
+
+Практический результат: документация перестала вводить в заблуждение разработчиков и reviewers и теперь описывает не гипотетическую, а фактическую архитектуру Telegram-ветки.
+
+### 8.3. Разбор и исправление проблемного onboarding `/forum`
+
+Изначально `/forum` опирался на пересланное сообщение из супергруппы и ожидал, что Telegram отдаст боту `forward_from_chat`.
+
+В реальном запуске было установлено, что этот сценарий ненадёжен:
+
+- Telegram/aiogram может присылать не `forward_from_chat`, а `forward_origin`
+- в ряде случаев бот видит только `forward_origin_type=user`
+- из такого payload невозможно надёжно восстановить `group_id`
+
+То есть даже при визуально «правильной» пересылке сообщение не обязательно содержит необходимые данные о группе.
+
+Для диагностики в onboarding были добавлены stage-level логи. Теперь логируются:
+
+- запуск `/forum`
+- получение onboarding message
+- тип forward metadata
+- наличие или отсутствие данных о группе
+- тип найденного chat
+- проверка forum-enabled supergroup
+- права бота (`administrator` / `can_manage_topics`)
+- успешная привязка forum group
+- создание и привязка topics
+- завершение onboarding
+
+Это позволило быстро локализовать проблему и убедиться, что узкое место было именно в механике получения `group_id`.
+
+### 8.4. Перевод onboarding на Telegram-native `request_chat`
+
+Вместо ненадёжного forwarding-only flow основной путь подключения forum group был переведён на **Telegram-native выбор чата** через `request_chat`.
+
+Было сделано следующее:
+
+- добавлена новая клавиатура выбора forum-group
+- `/forum` теперь предлагает пользователю выбрать подходящую group кнопкой
+- бот получает `chat_shared.chat_id` напрямую
+- после выбора выполняется проверка реальных прав бота в группе
+- старый forwarding path оставлен как fallback
+
+Это решение даёт несколько преимуществ:
+
+- не зависит от нестабильных forwarded metadata
+- даёт детерминированный `chat_id`
+- лучше соответствует реальному Telegram API
+- делает onboarding заметно понятнее для пользователя
+
+### 8.5. Исправление ошибки `USER_RIGHTS_MISSING`
+
+После внедрения `request_chat` на реальном запуске проявилась новая ошибка:
+
+- `TelegramBadRequest: USER_RIGHTS_MISSING`
+
+Ошибка возникала ещё на этапе отправки кнопки выбора forum-group.
+
+Причина: в `KeyboardButtonRequestChat` был указан слишком жёсткий набор `bot_administrator_rights`, из-за чего Telegram отклонял сам запрос на показ кнопки.
+
+Исправление:
+
+- из `request_chat` были убраны жёсткие `bot_administrator_rights`
+- фактическая проверка нужных прав оставлена на следующем шаге через `get_chat_member`
+
+В результате onboarding сохранил строгую проверку прав, но перестал ломаться на этапе отправки UI.
+
+### 8.6. Исправление опасного поведения внутри forum topics
+
+После успешного onboarding был отдельно проверен UX внутри уже созданных topics. Здесь обнаружился критичный баг: пользователь мог использовать `/chats` в topic-контексте и переключать активный чат через inline callbacks.
+
+Это приводило к рассинхронизации:
+
+- Telegram topic визуально оставался темой одного чата
+- FSM и routing переключались на другой чат
+- пользователь начинал фактически разговаривать «в чате 4 внутри темы чата 2»
+
+Чтобы устранить этот класс ошибок, были введены ограничения для topic-контекста.
+
+Теперь внутри forum topic:
+
+- `/chats` не открывает механизм переключения и сообщает, что эта функция доступна только в DM
+- callback `switch::` запрещён
+- callback `new_chat` из списка чатов запрещён
+
+Это устранило основной сценарий, которым пользователь мог руками сломать привязку `topic -> chat`.
+
+### 8.7. Что покрыто тестами
+
+В рамках этой же сессии были расширены Telegram-specific тесты. Покрыты сценарии:
+
+- forum routing helpers
+- `/forum` переводит FSM в setup state
+- подключение группы через `forward_from_chat`
+- подключение группы через `forward_origin`
+- подключение группы через `chat_shared`
+- негативные сценарии без метаданных группы
+- негативный сценарий для supergroup без Topics
+- routing сообщений в forum thread
+- создание forum topic при `/new` в DM
+- регистрация чата в текущем topic
+- confirm callback внутри forum thread
+- запрет `/chats` внутри topic
+- запрет `switch` callback внутри topic
+- запрет `new_chat` callback внутри topic
+
+Проверка выполнялась командами:
+
+- `pytest tests/adapter/telegram/test_forum.py -q`
+- `pytest tests/core/test_dispatcher.py tests/core/test_integration.py -q`
+
+Результат: ключевые улучшения forum mode закреплены тестами, а не остались только на уровне ручной отладки.
+
+### 8.8. Что ещё осталось как follow-up
+
+Во время сессии были зафиксированы проблемы, которые разумно вынести в отдельную follow-up задачу, а не смешивать с текущими исправлениями.
+
+Оставшиеся gap'ы:
+
+- глобальные команды Telegram всё ещё видны и в topic-контексте, хотя часть из них логически там отключена
+- `/new ` внутри уже связанного topic может переименовать локальный чат, но не переименовывает сам Telegram topic
+- callback `new_chat` из DM-списка пока не синхронизирован с forum topic creation так же, как `/new` в DM
+
+Эти пункты были вынесены в отдельный issue:
+
+- `#15` — `Telegram forum topics: remaining UX and synchronization gaps`
+
+### 8.9. Git-результат Telegram-сессии
+
+По итогам сессии изменения были оформлены отдельным коммитом и опубликованы в удалённую ветку.
+
+**Commit:**
+
+- `a1b7a14` — `Improve Telegram forum onboarding and topic safety`
+
+**Push:**
+
+- `origin/feat/telegram-adapter`
+
+### 8.10. Практический результат этой Telegram-сессии
+
+На выходе Telegram Forum Topics mode стал существенно устойчивее и пригоднее для демонстрации и дальнейшей разработки.
+
+Главные практические улучшения:
+
+- forum onboarding стал надёжнее за счёт `request_chat`
+- диагностика ошибок onboarding стала прозрачной
+- пользователю стало сложнее случайно сломать topic-context
+- документация приведена в соответствие с кодом
+- изменения закреплены тестами
+- остаточные проблемы не потеряны и вынесены в issue tracker
+
+Итог: Telegram forum mode из состояния «уже работает, но легко ломается и плохо диагностируется» был переведён в состояние «работает заметно устойчивее, ограничивает опасные сценарии и имеет понятный backlog дальнейших улучшений».
diff --git a/docs/reports/2026-04-21-platform-streaming-bug-report-ru.md b/docs/reports/2026-04-21-platform-streaming-bug-report-ru.md
new file mode 100644
index 0000000..f183ede
--- /dev/null
+++ b/docs/reports/2026-04-21-platform-streaming-bug-report-ru.md
@@ -0,0 +1,245 @@
+# Баг-репорт: регрессия стриминга платформы после file/tool flow
+
+## Кратко
+
+После обновления до текущих upstream-версий платформы стриминг ответов стал нестабильным в сценариях с вложениями и tool/file flow.
+
+Наблюдаемые симптомы:
+
+- первый текстовый chunk ответа может приходить уже обрезанным
+- соседние ответы могут "протекать" друг в друга
+- после некоторых запросов бот перестаёт присылать финальный ответ
+- платформа присылает дублирующий `END`
+
+До обновления платформы этот класс ошибок у нас не воспроизводился.
+
+## Версии платформы
+
+В рантайме используются upstream-репозитории без локальных правок:
+
+- `platform-agent`: `5e7c2df954cc3cd2f5bf8ae688e10a20038dde61`
+- `platform-agent_api`: `aa480bbec5bbf8e006284dd03aed1c2754e9bbee`
+
+## Контекст интеграции
+
+- поверхность: Matrix
+- транспорт к платформе: websocket через `platform-agent_api`
+- `chat_id` на платформу отправляется как стабильный числовой surrogate id
+- shared workspace: `/workspace`
+
+Важно: vendored platform repos чистые, совпадают с upstream. Проблема воспроизводится без локальных patch'ей в платформу.
+
+## Пользовательские симптомы
+
+Примеры из живого диалога:
+
+- ожидалось: `Моя ошибка: ...`
+- фактически пришло: `оя ошибка: ...`
+
+- ожидалось начало ответа вида `По фото IMG_3183.png ...`
+- фактически пришло: `IMG_3183.png**) — это ...`
+
+Также наблюдалось:
+
+- после вопросов по изображениям бот иногда вообще перестаёт отвечать
+- в том же чате, до attachment/tool flow, ответы приходят корректно
+
+## Шаги воспроизведения
+
+1. Поднять `platform-agent` и Matrix surface на версиях выше.
+2. Отправить несколько обычных текстовых сообщений.
+3. Убедиться, что начальные ответы стримятся корректно.
+4. Отправить изображения/файлы и задать вопросы вида:
+ - `что изображено на фото`
+ - уточняющие follow-up вопросы по тем же вложениям
+5. Затем отправить ещё одно обычное текстовое сообщение.
+6. Наблюдать один или несколько симптомов:
+ - первый chunk начинается с середины слова
+ - ответ начинается с середины фразы
+ - хвост прошлого ответа загрязняет следующий
+ - видимого финального ответа нет вообще
+
+## Что удалось доказать
+
+По debug-логам Matrix surface видно, что обрезание присутствует уже в первом chunk, полученном от платформы.
+
+Корректные первые chunk'и до attachment/tool flow:
+
+- `Hey! How`
+- `Я`
+- `Первый файл не найден — возможно, ...`
+
+Некорректные первые chunk'и после attachment/tool flow:
+
+- `IMG_3183.png**) — это ю...`
+- `оя ошибка: в первом запросе...`
+
+Это логируется сразу после десериализации websocket event на клиентской стороне, до Matrix rendering и до финальной сборки текста ответа. Из этого следует, что повреждение происходит на стороне платформенного стриминга, а не в Matrix sender.
+
+## Дополнительное наблюдение по протоколу
+
+Платформа сейчас отправляет дублирующий `END`.
+
+Релевантные места в upstream:
+
+- `external/platform-agent/src/agent/service.py`
+ - уже `yield MsgEventEnd(...)`
+- `external/platform-agent/src/api/external.py`
+ - после завершения цикла дополнительно отправляет ещё один `MsgEventEnd(...)`
+
+В живых логах это видно как:
+
+- первый `END`
+- второй `END`
+- клиентская suppression логика, которая гасит дубликат
+
+Это само по себе делает границы ответа неоднозначными и повышает риск "протекания" поздних событий в следующий запрос.
+
+## Предполагаемая первопричина
+
+Похоже, что на стороне платформы одновременно есть две проблемы.
+
+### 1. Двойной сигнал завершения стрима
+
+Для одного ответа генерируется два `END`.
+
+Вероятные последствия:
+
+- нечёткая граница ответа
+- поздние события могут относиться не к тому запросу
+- соседние ответы могут смешиваться
+
+### 2. Некорректное извлечение текстового chunk'а
+
+В текущем upstream `platform-agent/src/agent/service.py` текст форвардится из `chunk.content` внутри `on_chat_model_stream`.
+
+Однако в документации LangChain для `astream_events()` примеры используют `event["data"]["chunk"].text`, а в Deep Agents stream дополнительно есть `ns`/`source`, которые важны для отделения main-agent output от tool/subagent/model-internal stream.
+
+Потенциальные последствия:
+
+- первый видимый chunk может быть неполным
+- во внешний клиент может попадать не только финальный пользовательский текст
+- attachment/tool flow сильнее деградирует поведение стрима
+
+## Почему проблема считается платформенной
+
+С нашей стороны были проверены и исключены базовые причины:
+
+- вложения корректно сохраняются в `/workspace`
+- контейнер `platform-agent` видит эти файлы
+- Matrix surface получает уже обрезанный первый chunk от платформы
+- обрезание происходит до сборки финального ответа
+- эксперимент с reconnect на каждый запрос не исправил проблему
+- платформенные vendored repos сейчас совпадают с upstream
+
+## Ожидаемое поведение
+
+Для каждого пользовательского запроса:
+
+- текстовые chunk'и должны начинаться с реального начала ответа модели
+- должен приходить ровно один terminal `END`
+- границы ответов должны быть однозначными
+- file/tool flow не должен ломать следующий ответ
+
+## Фактическое поведение
+
+После attachment/tool flow:
+
+- первый text chunk может быть уже обрезан
+- `END` приходит дважды
+- следующий ответ может начаться с середины слова или фразы
+- отдельные запросы могут не завершаться видимым ответом
+
+## Дополнительный failure mode: большие изображения
+
+В отдельном воспроизведении текстовые запросы до файлового сценария работали корректно, а сбой возникал только после попытки анализа изображений.
+
+По логам видно уже не только stream corruption, но и конкретный image-path failure:
+
+- `platform-agent` рвёт websocket с `1009 (message too big)`
+- провайдер возвращает `400` с причиной:
+ - `Exceeded limit on max bytes per data-uri item : 10485760`
+
+Характерный фрагмент:
+
+```text
+websockets.exceptions.ConnectionClosedError: received 1009 (message too big); then sent 1009 (message too big)
+...
+Agent error (INTERNAL_ERROR): Error code: 400 - {
+ 'error': {
+ 'message': 'Provider returned error',
+ 'metadata': {
+ 'raw': '{"error":{"message":"Exceeded limit on max bytes per data-uri item : 10485760"...}}'
+ }
+ }
+}
+```
+
+Из этого следует:
+
+- текстовый path сам по себе работоспособен
+- image-analysis path в платформе сейчас передаёт изображение как data URI
+- для достаточно больших изображений это утыкается в provider limit `10,485,760` байт на один data-uri item
+- параллельно websocket path между surface и platform-agent неустойчив к этому failure scenario и закрывается с `1009`
+
+То есть в платформе есть как минимум ещё одна отдельная проблема помимо некорректного стриминга:
+
+- отсутствует безопасная обработка больших изображений до отправки в provider
+- отсутствует аккуратная деградация без разрыва websocket-сессии
+
+## Что стоит исправить в платформе
+
+1. Отправлять ровно один `MsgEventEnd` на один ответ.
+2. Перепроверить extraction текста из `on_chat_model_stream`:
+ - вероятно, должен использоваться `chunk.text`, а не `chunk.content`.
+3. Учитывать `ns`/`source` и форвардить наружу только main assistant output.
+4. Перед отправкой изображений в provider проверять размер payload и не пытаться слать oversized data-uri.
+5. Для больших изображений:
+ - либо делать resize/compression,
+ - либо возвращать контролируемую user-facing ошибку без разрыва websocket.
+6. В идеале добавить более жёсткую request boundary в websocket protocol, чтобы late events не могли прилипать к следующему запросу.
+
+## Наши временные mitigation'ы на стороне surface
+
+Они не исправляют корень, только снижают ущерб:
+
+- suppression duplicate `END`
+- короткий post-`END` drain window
+- idle timeout для зависшего стрима
+- transport failures нормализуются в `PlatformError`, чтобы Matrix bot не падал процессом
+
+Эти меры понадобились только потому, что в текущем сценарии platform stream contract нестабилен.
+
+## Приложение: характерный фрагмент логов
+
+```text
+[matrix-bot] text chunk queue=True text='Первый файл не найден — возможно,'
+[matrix-bot] ...
+[matrix-bot] end event queue=True tokens=0
+[matrix-bot] end event queue=True tokens=0
+[matrix-bot] dropped duplicate END tokens=0
+[matrix-bot] text chunk queue=True text='IMG_3183.png**) — это ю'
+[matrix-bot] ...
+[matrix-bot] end event queue=True tokens=0
+[matrix-bot] end event queue=True tokens=0
+[matrix-bot] dropped duplicate END tokens=0
+[matrix-bot] text chunk queue=True text='оя ошибка: в первом запросе я случайно постав'
+```
+
+Этот фрагмент показывает две вещи:
+
+- duplicate `END` действительно приходит от платформы
+- следующий первый chunk уже приходит в клиента обрезанным
+
+## Приложение: характерный фрагмент логов для больших изображений
+
+```text
+platform-agent-1 | websockets.exceptions.ConnectionClosedError: received 1009 (message too big); then sent 1009 (message too big)
+...
+matrix-bot-1 | Agent error (INTERNAL_ERROR): Error code: 400 - {'error': {'message': 'Provider returned error', 'code': 400, 'metadata': {'raw': '{"error":{"message":"Exceeded limit on max bytes per data-uri item : 10485760","type":"invalid_request_error"...}}}}
+```
+
+Этот фрагмент показывает ещё две вещи:
+
+- image path в платформе реально упирается в лимит провайдера на размер data URI
+- при этом ошибка не изолируется корректно и сопровождается разрывом websocket-соединения
diff --git a/docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md b/docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md
new file mode 100644
index 0000000..d03adc6
--- /dev/null
+++ b/docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md
@@ -0,0 +1,294 @@
+# Финальный баг-репорт: потеря начала ответа и сбои streaming/image path в `platform-agent`
+
+## Статус
+
+Это финальный отчёт после полного аудита интеграции `surfaces -> platform-agent_api -> platform-agent`.
+
+Итог:
+
+- текущая реализация `surfaces` рабочая, но проблемная из-за upstream-дефектов платформы
+- баг с пропадающим началом ответа на текущем состоянии **не локализуется в `surfaces`**
+- в воспроизведённом сценарии повреждённый первый текстовый chunk рождается уже внутри `platform-agent`
+- помимо этого подтверждены ещё два независимых platform-side дефекта:
+ - duplicate `END`
+ - некорректная обработка больших изображений (`data-uri > 10 MB`, `WS 1009`)
+
+## Версии и состояние кода
+
+Рантайм воспроизводился на vendored upstream-репозиториях без локальных platform patch’ей:
+
+- `platform-agent`: `5e7c2df954cc3cd2f5bf8ae688e10a20038dde61`
+- `platform-agent_api`: `aa480bbec5bbf8e006284dd03aed1c2754e9bbee`
+
+Со стороны `surfaces` transport layer был предварительно очищен:
+
+- убрана локальная stream-semantics из `sdk/agent_api_wrapper.py`
+- `sdk/real.py` переведён на pinned upstream `platform-agent_api.AgentApi`
+- больше нет локального post-END drain, custom listener и wrapper-owned reclassification events
+
+Это важно: баг воспроизводился **после** удаления наших транспортных костылей.
+
+## Контекст интеграции
+
+- поверхность: Matrix
+- транспорт к платформе: WebSocket через upstream `platform-agent_api.AgentApi`
+- `chat_id` на платформу: стабильный числовой surrogate id, выдаваемый со стороны `surfaces`
+- файловый контракт: shared `/workspace`, вложения передаются как относительные пути в `attachments`
+
+## Пользовательские симптомы
+
+Наблюдались несколько классов сбоев:
+
+1. Начало ответа может пропасть
+- ожидалось: `Моя ошибка: ...`
+- фактически: `оя ошибка: ...`
+
+- ожидалось: `На двух изображениях: ...`
+- фактически: ` двух изображениях: ...`
+
+2. После tool/file flow ответы могут вести себя нестабильно
+- следующий ответ стартует с середины фразы
+- в некоторых сценариях после image/tool path платформа отвечает ошибкой или замолкает
+
+3. На больших изображениях image path падает совсем
+- provider error `Exceeded limit on max bytes per data-uri item : 10485760`
+- websocket закрывается с `1009 (message too big)`
+
+## Что было проверено на стороне `surfaces`
+
+Ниже перечислено, что именно было перепроверено в нашем коде и почему это не выглядит корнем проблемы.
+
+### 1. Мы больше не режем и не переклассифицируем stream локально
+
+В текущем `surfaces`:
+
+- `sdk/agent_api_wrapper.py` — thin construction/factory shim над upstream `AgentApi`
+- `sdk/real.py` — просто итерирует upstream events и склеивает `MsgEventTextChunk.text`
+- `adapter/matrix/bot.py` — отправляет `OutgoingMessage.text` в Matrix без `strip/lstrip`
+
+Наблюдение:
+
+- в текущем коде не осталось места, где строка могла бы превратиться из `Моя ошибка` в `оя ошибка` через локальный slicing
+
+### 2. Сборка ответа у нас линейная и тупая
+
+`sdk/real.py` делает только следующее:
+
+- если пришёл `MsgEventTextChunk` — добавляет `event.text` в `response_parts`
+- если пришёл `MsgEventSendFile` — превращает его в `Attachment`
+- не пытается “восстанавливать” поток после `END`
+
+Следствие:
+
+- если начало уже отсутствует в `event.text`, мы его не можем ни потерять, ни вернуть
+
+### 3. Matrix sender не модифицирует текст
+
+`adapter/matrix/bot.py` передаёт текст дальше как есть.
+
+Следствие:
+
+- Matrix renderer не является объяснением пропажи первого куска
+
+## Что было проверено в `platform-agent_api`
+
+Upstream client всё ещё имеет спорную queue-архитектуру:
+
+- одна активная `_current_queue`
+- `MsgEventEnd` съедается внутри `send_message()`
+- в `finally` очередь отвязывается и дренится orphan messages
+
+Это архитектурно хрупко и может быть источником других boundary bugs.
+
+Но в конкретном воспроизведении этот слой не был точкой порчи текста.
+
+Почему:
+
+- в raw logs клиент получил **ровно тот же** первый text chunk, который сервер уже отправил
+- queue/dequeue не изменили его содержимое
+
+## Что удалось доказать по raw logs
+
+Для финальной проверки была временно добавлена точечная диагностика в:
+
+- `external/platform-agent/src/agent/service.py`
+- `external/platform-agent/src/api/external.py`
+- `external/platform-agent_api/lambda_agent_api/agent_api.py`
+
+Эта диагностика **не входила** в рабочую реализацию и использовалась только для локализации бага.
+
+### Ключевое наблюдение
+
+На проблемном запросе после tool/file flow сервер сам yield’ил уже обрезанный первый chunk:
+
+```text
+platform-agent-1 | [raw-stream][server-yield] chat=1 event=TEXT text=' двух изображениях:\n\n**Первое изображение'
+platform-agent-1 | [raw-stream][ws-send] chat=1 event=AGENT_EVENT_TEXT_CHUNK text=' двух изображениях:\n\n**Первое изображение' path=None
+matrix-bot-1 | [raw-stream][client-listen] agent=matrix-bot chat=1 queue_active=True AGENT_EVENT_TEXT_CHUNK text=' двух изображениях:\n\n**Первое изображение'
+matrix-bot-1 | [raw-stream][client-dequeue] agent=matrix-bot chat=1 request=2 AGENT_EVENT_TEXT_CHUNK text=' двух изображениях:\n\n**Первое изображение'
+```
+
+Это означает:
+
+- порча произошла **до** websocket-клиента
+- `surfaces` transport layer не является источником именно этого дефекта
+- `platform-agent_api` не исказил этот конкретный chunk по дороге
+
+Дополнительно тот же паттерн виден и вне image-сценария:
+
+```text
+platform-agent-1 | [raw-stream][server-yield] chat=1 event=TEXT text='сё работает напрямую'
+...
+matrix-bot-1 | [raw-stream][client-dequeue] ... text='сё работает напрямую'
+```
+
+То есть сервер уже выдаёт `сё`, а не `Всё`.
+
+## Наиболее вероятный root cause
+
+Главный подозреваемый — `external/platform-agent/src/agent/service.py`.
+
+Сейчас он делает следующее:
+
+- читает `self._agent.astream_events(...)`
+- обрабатывает только `kind == "on_chat_model_stream"`
+- берёт `chunk = event["data"]["chunk"]`
+- если `chunk.content`, форвардит `MsgEventTextChunk(text=chunk.content)`
+
+Проблема в том, что это очень грубое преобразование raw event stream в пользовательский текст.
+
+### Почему именно это место выглядит корнем
+
+1. Первый битый chunk уже рождается на server-side
+- это подтверждено логами выше
+
+2. Код берёт только `chunk.content`
+- если начало ответа приходит в другой форме, поле или raw event, оно просто теряется
+
+3. Код не учитывает `ns` / `source`
+- после tool/vision flow у Deep Agents / LangChain может быть более сложная структура потока
+- текущий adapter flatten’ит её слишком агрессивно
+
+4. Код никак не валидирует, что наружу уходит именно main assistant output
+
+Итоговая гипотеза:
+
+> После tool/file/vision flow `platform-agent` неправильно адаптирует `astream_events()` в `MsgEventTextChunk`. Начало итогового пользовательского ответа может находиться не в том raw event, который сейчас читается через `chunk.content`, либо теряться из-за упрощённой фильтрации потока.
+
+## Подтверждённый отдельный баг: duplicate `END`
+
+Это отдельный platform-side дефект.
+
+Сейчас:
+
+- `external/platform-agent/src/agent/service.py` уже делает `yield MsgEventEnd(...)`
+- `external/platform-agent/src/api/external.py` после завершения цикла дополнительно отправляет ещё один `MsgEventEnd(...)`
+
+По логам это выглядит так:
+
+```text
+platform-agent-1 | [raw-stream][server-yield] chat=1 event=END
+platform-agent-1 | [raw-stream][ws-send] chat=1 event=AGENT_EVENT_END text=None path=None
+platform-agent-1 | [raw-stream][ws-send] chat=1 event=AGENT_EVENT_END duplicate_end=true
+matrix-bot-1 | ... AGENT_EVENT_END tokens_used=0
+matrix-bot-1 | ... AGENT_EVENT_END tokens_used=0
+```
+
+Независимая оценка:
+
+- duplicate `END` — реальный баг платформы
+- он делает границу ответа менее надёжной
+- но в текущем воспроизведении он **не объясняет** сам факт потери первого text chunk
+
+То есть это важный, но вторичный дефект.
+
+## Подтверждённый отдельный баг: большие изображения ломают image path
+
+В отдельном воспроизведении платформа падала на анализе изображений с provider error:
+
+```text
+Exceeded limit on max bytes per data-uri item : 10485760
+```
+
+И параллельно websocket рвался с:
+
+```text
+received 1009 (message too big); then sent 1009 (message too big)
+```
+
+Это означает:
+
+- image path отправляет в provider oversized `data:` URI
+- безопасной предвалидации / деградации нет
+- failure scenario сопровождается разрывом websocket-соединения
+
+Независимая оценка:
+
+- это отдельный platform-side bug
+- он не объясняет потерю первого чанка в текстовом сценарии напрямую
+- но подтверждает, что path `tool/file/image -> platform stream` в целом сейчас нестабилен
+
+## Что мы считаем исключённым
+
+С достаточной уверенностью можно исключить:
+
+1. Локальный slicing текста в `surfaces`
+2. Локальную “умную” реконструкцию потока, потому что она была удалена
+3. Matrix sender как источник потери первого чанка
+4. `platform-agent_api` queue/dequeue как primary root cause именно в этом воспроизведении
+
+## Финальная независимая оценка
+
+Текущая оценка вероятностей:
+
+- `75%` — ошибка в `platform-agent`, в адаптере `astream_events() -> MsgEventTextChunk`
+- `15%` — provider/model stream приносит начало ответа в другой raw event/field, а `platform-agent` его некорректно интерпретирует
+- `10%` — вторичные platform-side boundary defects (`duplicate END`, queue semantics и т.д.)
+- `~0-5%` — ошибка в `surfaces`
+
+Итоговый вывод:
+
+> На текущем состоянии кода баг с пропадающим началом ответа следует считать platform-side дефектом. Воспроизведение после cleanup transport layer показывает, что первый повреждённый text chunk формируется уже внутри `platform-agent` до отправки в websocket.
+
+## Что нужно исправить в платформе
+
+### Обязательно
+
+1. Убрать duplicate `END`
+- один ответ должен завершаться ровно одним `MsgEventEnd`
+
+2. Перепроверить адаптацию `astream_events()` в `service.py`
+- логировать и проанализировать raw `event["event"]`
+- проверить `event.get("name")`
+- смотреть `event.get("ns")`
+- сравнить `chunk.content` с тем, что реально лежит в `chunk.text` / raw chunk repr
+
+3. Форвардить наружу только финальный main assistant output
+- не flatten’ить весь поток без учёта `ns/source`
+
+### Желательно
+
+4. Сделать image path устойчивым к oversized payload
+- preflight check размера
+- resize/compress или controlled error без разрыва WS
+
+5. Улучшить client/server protocol boundary
+- более строгая корреляция запроса и ответа
+- более однозначная semantics конца ответа
+
+## Что мы сделали со своей стороны
+
+Со стороны `surfaces` уже выполнено следующее:
+
+- transport layer очищен до thin adapter над upstream `AgentApi`
+- локальные stream-workaround’ы удалены
+- рабочая интеграция сохранена
+- known issue задокументирован
+
+То есть текущая интеграция не “идеальна”, но её поведение теперь достаточно чистое, чтобы platform bug было можно локализовать без смешения ответственности.
+
+## Приложение: короткий диагноз
+
+Если нужна самая короткая формулировка для issue tracker:
+
+> После cleanup transport layer в `surfaces` и воспроизведения на clean upstream platform repos видно, что `platform-agent` иногда сам порождает первый `MsgEventTextChunk` уже обрезанным, особенно после tool/file flow. Дополнительно подтверждены duplicate `END` и отдельный image-path failure на больших `data:` URI.
diff --git a/docs/research/aiogram-architecture-review.md b/docs/research/aiogram-architecture-review.md
new file mode 100644
index 0000000..c0a7946
--- /dev/null
+++ b/docs/research/aiogram-architecture-review.md
@@ -0,0 +1,172 @@
+# Ресёрч: aiogram 3.x Architecture Review
+
+> **Дата:** 2026-03-30
+> **Вердикт:** APPROVED с двумя уточнениями
+
+---
+
+## 1. Структура проекта
+
+**Официальный пример multi_file_bot:**
+```
+multi_file_bot/
+ bot.py
+ handlers/
+ common.py
+ ...
+```
+
+**Best practice для средних проектов (наш случай):**
+```
+adapter/telegram/
+ bot.py ← Dispatcher + include_routers + polling/webhook
+ converter.py ← граница aiogram ↔ core/
+ states.py ← все StatesGroup
+ handlers/ ← по одному Router на модуль
+ keyboards/ ← InlineKeyboardBuilder фабрики
+ middleware.py ← DI + logging + rate limit
+```
+
+**Оценка:** наша структура соответствует стандарту. ✓
+
+---
+
+## 2. Middleware vs Converter
+
+В aiogram 3.x эти два паттерна решают **разные задачи** и должны использоваться вместе.
+
+| | Middleware | Converter |
+|---|---|---|
+| Назначение | Infrastructure | Бизнес-логика |
+| Что делает | Логирование, DI, rate limit, сессия БД | aiogram Event → IncomingEvent |
+| Когда вызывается | До и после хендлера | Внутри хендлера |
+
+**Правильная комбинация:**
+```python
+# middleware.py — только infrastructure
+class DependencyMiddleware(BaseMiddleware):
+ def __init__(self, platform, store):
+ self.platform = platform
+ self.store = store
+
+ async def __call__(self, handler, event, data):
+ data["platform"] = self.platform
+ data["store"] = self.store
+ return await handler(event, data)
+
+# handler — converter вызывается внутри
+async def handle_message(message: Message, platform, store):
+ event = to_incoming_message(message) # converter
+ results = await dispatcher.dispatch(event, platform, store)
+ await send_results(message, results) # converter обратно
+```
+
+**Оценка:** наш converter.py — правильный паттерн. Добавить `middleware.py` для DI. ✓+
+
+---
+
+## 3. Dependency Injection
+
+Стандарт aiogram 3.x — **через middleware + data dict**:
+
+```python
+# Регистрация в bot.py
+dp.message.middleware(DependencyMiddleware(platform=platform_client, store=store))
+
+# Получение в handler (через type hint на имя ключа)
+async def handle_message(message: Message, platform: PlatformClient, store: StateStore):
+ ...
+```
+
+Альтернатива — через `dp["key"] = value` (Dispatcher workflow data):
+```python
+dp["platform"] = platform_client # в bot.py
+
+async def handler(message: Message, platform: PlatformClient): # aiogram сам находит по типу
+ ...
+```
+
+**Оценка:** нужно явно добавить один из этих механизмов, иначе хендлеры не получат platform/store. ⚠️
+
+---
+
+## 4. InlineKeyboardBuilder
+
+`InlineKeyboardBuilder` — рекомендуемый подход в aiogram 3.x. `InlineKeyboardMarkup` с вложенными списками считается устаревшим стилем.
+
+```python
+# keyboards/chat.py
+from aiogram.utils.keyboard import InlineKeyboardBuilder
+
+def chats_keyboard(chats: list[ChatContext]) -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ for chat in chats:
+ builder.button(text=f"💬 {chat.name}", callback_data=f"chat:{chat.chat_id}")
+ builder.button(text="➕ Новый чат", callback_data="new_chat")
+ builder.adjust(1) # одна кнопка в строку
+ return builder.as_markup()
+```
+
+**Оценка:** использовать `InlineKeyboardBuilder` везде. ✓
+
+---
+
+## 5. F-фильтры (MagicFilter)
+
+aiogram 3.x MagicFilter (`F`) — стандарт вместо ручных проверок в хендлерах:
+
+```python
+from aiogram import F
+
+# Вместо if message.text == "/start" внутри хендлера
+router.message.register(start_handler, Command("start"))
+
+# Фильтр по типу вложения
+router.message.register(voice_handler, F.voice)
+router.message.register(photo_handler, F.photo)
+
+# Фильтр по состоянию
+router.message.register(handle_name_input, OnboardingState.waiting_for_name)
+
+# Callback фильтр
+router.callback_query.register(confirm_handler, F.data.startswith("confirm:"))
+```
+
+**Оценка:** использовать F-фильтры при регистрации роутеров — чище, чем if/else в хендлерах. ✓
+
+---
+
+## 6. Сцены (Scenes) — новинка aiogram 3.x
+
+aiogram 3.4+ ввёл `Scene` как улучшенный FSM для сложных диалогов:
+
+```python
+from aiogram.fsm.scene import Scene, on
+
+class OnboardingScene(Scene, state="onboarding"):
+ @on.message.enter()
+ async def on_enter(self, message: Message):
+ await message.answer("Как зовут твоего агента?")
+
+ @on.message()
+ async def on_name(self, message: Message, state: FSMContext):
+ await state.update_data(agent_name=message.text)
+ await self.wizard.goto(OnboardingScene2)
+```
+
+**Оценка:** Scenes — опциональное улучшение для онбординга. Классический FSM через StatesGroup тоже корректен и проще для понимания. Использовать StatesGroup для прототипа, Scenes — в будущем. ✓
+
+---
+
+## Итог
+
+| Решение | Статус |
+|---|---|
+| Router-based архитектура, один Router на модуль | ✅ Стандарт |
+| converter.py как граница aiogram ↔ core/ | ✅ Правильный паттерн |
+| InlineKeyboardBuilder в keyboards/ | ✅ Рекомендуется |
+| SQLiteStorage для FSM | ✅ Стандарт для MVP |
+| **Нужно добавить: DependencyMiddleware** | ⚠️ DI без него не работает |
+| **Нужно добавить: F-фильтры при регистрации** | ⚠️ Иначе проверки в хендлерах |
+
+**Архитектура одобрена.** Два уточнения (middleware.py и F-фильтры) небольшие и органично вписываются в текущую структуру.
diff --git a/docs/superpowers/plans/2026-03-31-forum-topics.md b/docs/superpowers/plans/2026-03-31-forum-topics.md
new file mode 100644
index 0000000..87a92b2
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-31-forum-topics.md
@@ -0,0 +1,704 @@
+# Forum Topics Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Добавить опциональный Forum Topics режим — пользователь подключает Telegram-супергруппу, его DM-чаты синхронизируются с нативными темами форума.
+
+**Architecture:** Каждый `chat` в БД получает опциональный `forum_thread_id`. Адаптер маршрутизирует: пришло из DM → отвечает в DM с тегом, пришло из Forum-темы → отвечает в ту же тему без тега. Core не меняется — `chat_id` (UUID) одинаковый для обеих поверхностей.
+
+**Tech Stack:** aiogram 3.x, SQLite (sqlite3), Python 3.11+
+
+**Working directory:** `/Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram`
+
+---
+
+## File Map
+
+| Файл | Действие | Что меняется |
+|------|----------|--------------|
+| `adapter/telegram/db.py` | Modify | Миграция схемы + 4 новых функции |
+| `adapter/telegram/states.py` | Modify | Добавить `ForumSetupState` |
+| `adapter/telegram/converter.py` | Modify | Добавить `is_forum_message`, `resolve_chat_id` |
+| `adapter/telegram/handlers/forum.py` | Create | `/forum` команда + онбординг |
+| `adapter/telegram/handlers/chat.py` | Modify | `cmd_new_chat` + `handle_message` с Forum-маршрутизацией |
+| `adapter/telegram/bot.py` | Modify | Зарегистрировать `forum.router` |
+| `tests/adapter/test_forum_db.py` | Create | Тесты новых функций БД |
+
+---
+
+## Task 1: DB migration + новые функции
+
+**Files:**
+- Modify: `adapter/telegram/db.py`
+- Create: `tests/adapter/__init__.py`
+- Create: `tests/adapter/test_forum_db.py`
+
+- [ ] **Step 1: Создать тест-файл и написать падающие тесты**
+
+```python
+# tests/adapter/__init__.py
+# (пустой файл)
+```
+
+```python
+# tests/adapter/test_forum_db.py
+from __future__ import annotations
+
+import os
+import tempfile
+import pytest
+
+os.environ["DB_PATH"] = ":memory:"
+
+from adapter.telegram.db import (
+ init_db,
+ get_or_create_tg_user,
+ create_chat,
+ set_forum_group,
+ get_forum_group,
+ set_forum_thread,
+ get_chat_by_thread,
+)
+
+
+@pytest.fixture(autouse=True)
+def fresh_db(tmp_path, monkeypatch):
+ db_file = str(tmp_path / "test.db")
+ monkeypatch.setenv("DB_PATH", db_file)
+ # reload module so DB_PATH is picked up
+ import importlib
+ import adapter.telegram.db as db_mod
+ importlib.reload(db_mod)
+ db_mod.init_db()
+ return db_mod
+
+
+def test_set_and_get_forum_group(fresh_db):
+ db = fresh_db
+ db.get_or_create_tg_user(111, "usr-111", "Alice")
+ assert db.get_forum_group(111) is None
+ db.set_forum_group(111, 999888)
+ assert db.get_forum_group(111) == 999888
+
+
+def test_set_forum_thread_and_get_by_thread(fresh_db):
+ db = fresh_db
+ db.get_or_create_tg_user(222, "usr-222", "Bob")
+ chat_id = db.create_chat(222, "Чат #1")
+ assert db.get_chat_by_thread(222, 42) is None
+ db.set_forum_thread(chat_id, 42)
+ chat = db.get_chat_by_thread(222, 42)
+ assert chat is not None
+ assert chat["chat_id"] == chat_id
+ assert chat["forum_thread_id"] == 42
+
+
+def test_get_chat_by_thread_wrong_user(fresh_db):
+ db = fresh_db
+ db.get_or_create_tg_user(333, "usr-333", "Carol")
+ chat_id = db.create_chat(333, "Чат #1")
+ db.set_forum_thread(chat_id, 77)
+ assert db.get_chat_by_thread(999, 77) is None
+```
+
+- [ ] **Step 2: Запустить тесты — убедиться что падают**
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot
+PYTHONPATH=.worktrees/telegram pytest tests/adapter/test_forum_db.py -v
+```
+
+Ожидаем: `ImportError` — функции ещё не существуют.
+
+- [ ] **Step 3: Добавить миграцию и новые функции в `db.py`**
+
+В `init_db()` добавить после `CREATE TABLE IF NOT EXISTS chats`:
+
+```python
+def init_db() -> None:
+ with _conn() as con:
+ con.executescript("""
+ CREATE TABLE IF NOT EXISTS tg_users (
+ tg_user_id INTEGER PRIMARY KEY,
+ platform_user_id TEXT NOT NULL,
+ display_name TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ forum_group_id INTEGER
+ );
+
+ CREATE TABLE IF NOT EXISTS chats (
+ chat_id TEXT PRIMARY KEY,
+ tg_user_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ archived_at TIMESTAMP,
+ forum_thread_id INTEGER,
+ FOREIGN KEY(tg_user_id) REFERENCES tg_users(tg_user_id)
+ );
+ """)
+ # Миграция для существующих БД
+ try:
+ con.execute("ALTER TABLE tg_users ADD COLUMN forum_group_id INTEGER")
+ except Exception:
+ pass
+ try:
+ con.execute("ALTER TABLE chats ADD COLUMN forum_thread_id INTEGER")
+ except Exception:
+ pass
+```
+
+Добавить в конец файла:
+
+```python
+def set_forum_group(tg_user_id: int, group_id: int) -> None:
+ with _conn() as con:
+ con.execute(
+ "UPDATE tg_users SET forum_group_id = ? WHERE tg_user_id = ?",
+ (group_id, tg_user_id),
+ )
+
+
+def get_forum_group(tg_user_id: int) -> int | None:
+ with _conn() as con:
+ row = con.execute(
+ "SELECT forum_group_id FROM tg_users WHERE tg_user_id = ?",
+ (tg_user_id,),
+ ).fetchone()
+ return row["forum_group_id"] if row else None
+
+
+def set_forum_thread(chat_id: str, thread_id: int) -> None:
+ with _conn() as con:
+ con.execute(
+ "UPDATE chats SET forum_thread_id = ? WHERE chat_id = ?",
+ (thread_id, chat_id),
+ )
+
+
+def get_chat_by_thread(tg_user_id: int, thread_id: int) -> dict | None:
+ with _conn() as con:
+ row = con.execute(
+ "SELECT * FROM chats WHERE tg_user_id = ? AND forum_thread_id = ? "
+ "AND archived_at IS NULL",
+ (tg_user_id, thread_id),
+ ).fetchone()
+ return dict(row) if row else None
+```
+
+- [ ] **Step 4: Запустить тесты — убедиться что проходят**
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot
+PYTHONPATH=.worktrees/telegram pytest tests/adapter/test_forum_db.py -v
+```
+
+Ожидаем: `3 passed`.
+
+- [ ] **Step 5: Убедиться что все тесты проекта не сломались**
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot
+PYTHONPATH=.worktrees/telegram pytest tests/ -v
+```
+
+Ожидаем: все тесты `passed`.
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram
+git add adapter/telegram/db.py ../../tests/adapter/
+git commit -m "feat: db migration + forum_group_id/forum_thread_id functions"
+```
+
+---
+
+## Task 2: ForumSetupState в states.py
+
+**Files:**
+- Modify: `adapter/telegram/states.py`
+
+- [ ] **Step 1: Добавить ForumSetupState**
+
+```python
+# adapter/telegram/states.py
+from aiogram.fsm.state import State, StatesGroup
+
+
+class ChatState(StatesGroup):
+ idle = State()
+ waiting_response = State()
+
+
+class SettingsState(StatesGroup):
+ menu = State()
+ soul_editing = State()
+ confirm_action = State()
+
+
+class ForumSetupState(StatesGroup):
+ waiting_for_group = State() # ждём пересылку из группы
+```
+
+- [ ] **Step 2: Проверить синтаксис**
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot
+uv run python -m py_compile .worktrees/telegram/adapter/telegram/states.py && echo OK
+```
+
+Ожидаем: `OK`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram
+git add adapter/telegram/states.py
+git commit -m "feat: add ForumSetupState"
+```
+
+---
+
+## Task 3: converter.py — is_forum_message и resolve_chat_id
+
+**Files:**
+- Modify: `adapter/telegram/converter.py`
+
+- [ ] **Step 1: Добавить функции в converter.py**
+
+Добавить в конец файла (после `format_outgoing`):
+
+```python
+def is_forum_message(message: Message) -> bool:
+ """Сообщение пришло из Forum-темы (не из General и не из DM)."""
+ return (
+ message.message_thread_id is not None
+ and message.chat.type in ("supergroup", "group")
+ )
+
+
+def resolve_forum_chat_id(message: Message) -> str | None:
+ """
+ Для Forum-сообщения ищет chat_id (UUID) по forum_thread_id в БД.
+ Возвращает None если тема не зарегистрирована.
+ """
+ from adapter.telegram import db
+ tg_user_id = message.from_user.id
+ thread_id = message.message_thread_id
+ chat = db.get_chat_by_thread(tg_user_id, thread_id)
+ return chat["chat_id"] if chat else None
+```
+
+- [ ] **Step 2: Проверить синтаксис**
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot
+uv run python -m py_compile .worktrees/telegram/adapter/telegram/converter.py && echo OK
+```
+
+Ожидаем: `OK`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram
+git add adapter/telegram/converter.py
+git commit -m "feat: add is_forum_message and resolve_forum_chat_id to converter"
+```
+
+---
+
+## Task 4: handlers/forum.py — /forum и онбординг
+
+**Files:**
+- Create: `adapter/telegram/handlers/forum.py`
+
+- [ ] **Step 1: Создать handlers/forum.py**
+
+```python
+# adapter/telegram/handlers/forum.py
+from __future__ import annotations
+
+from aiogram import Bot, F, Router
+from aiogram.filters import Command
+from aiogram.fsm.context import FSMContext
+from aiogram.types import Message
+
+from adapter.telegram import db
+from adapter.telegram.states import ChatState, ForumSetupState
+
+router = Router(name="forum")
+
+
+async def _check_forum_admin(bot: Bot, group_id: int) -> bool:
+ """Проверяет что бот — администратор с правом управления темами."""
+ try:
+ me = await bot.get_me()
+ member = await bot.get_chat_member(group_id, me.id)
+ return (
+ member.status in ("administrator", "creator")
+ and getattr(member, "can_manage_topics", False)
+ )
+ except Exception:
+ return False
+
+
+@router.message(Command("forum"))
+async def cmd_forum(message: Message, state: FSMContext) -> None:
+ await state.set_state(ForumSetupState.waiting_for_group)
+ await message.answer(
+ "📋 Подключение Forum-группы\n\n"
+ "1. Создай супергруппу в Telegram\n"
+ "2. Включи Topics: настройки группы → Topics\n"
+ "3. Добавь меня как администратора с правом управления темами\n"
+ "4. Перешли мне любое сообщение из этой группы\n\n"
+ "Или /cancel чтобы отменить."
+ )
+
+
+@router.message(ForumSetupState.waiting_for_group, Command("cancel"))
+async def cmd_cancel_forum(message: Message, state: FSMContext) -> None:
+ await state.set_state(ChatState.idle)
+ await message.answer("❌ Настройка форума отменена.")
+
+
+@router.message(ForumSetupState.waiting_for_group, F.forward_from_chat)
+async def handle_group_forward(
+ message: Message,
+ state: FSMContext,
+) -> None:
+ group = message.forward_from_chat
+
+ if group.type != "supergroup":
+ await message.answer(
+ "⚠️ Это не супергруппа. Нужна именно супергруппа с включёнными Topics."
+ )
+ return
+
+ group_id = group.id
+
+ if not await _check_forum_admin(message.bot, group_id):
+ await message.answer(
+ "⚠️ Не могу управлять темами в этой группе.\n\n"
+ "Убедись что:\n"
+ "• Я добавлен как администратор\n"
+ "• У меня есть право «Управление темами»"
+ )
+ return
+
+ tg_id = message.from_user.id
+ db.set_forum_group(tg_id, group_id)
+
+ # Создать Forum-темы для всех существующих активных DM-чатов
+ chats = db.get_user_chats(tg_id)
+ created = 0
+ for chat in chats:
+ if chat.get("forum_thread_id"):
+ continue # уже есть тема
+ try:
+ topic = await message.bot.create_forum_topic(
+ chat_id=group_id,
+ name=chat["name"],
+ )
+ db.set_forum_thread(chat["chat_id"], topic.message_thread_id)
+ created += 1
+ except Exception:
+ pass # не страшно — тему можно создать позже через /new
+
+ await state.set_state(ChatState.idle)
+ await message.answer(
+ f"✅ Группа «{group.title}» подключена!\n"
+ f"Создано тем в форуме: {created} из {len(chats)}.\n\n"
+ "Теперь можешь писать как в DM, так и в темах форума."
+ )
+
+
+@router.message(ForumSetupState.waiting_for_group)
+async def handle_forward_wrong(message: Message) -> None:
+ await message.answer(
+ "Жду пересланное сообщение из группы. "
+ "Перешли любое сообщение из своей супергруппы."
+ )
+```
+
+- [ ] **Step 2: Проверить синтаксис**
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot
+uv run python -m py_compile .worktrees/telegram/adapter/telegram/handlers/forum.py && echo OK
+```
+
+Ожидаем: `OK`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram
+git add adapter/telegram/handlers/forum.py
+git commit -m "feat: add handlers/forum.py — /forum onboarding flow"
+```
+
+---
+
+## Task 5: handlers/chat.py — Forum-маршрутизация
+
+**Files:**
+- Modify: `adapter/telegram/handlers/chat.py`
+
+- [ ] **Step 1: Обновить импорты в chat.py**
+
+Заменить блок импортов целиком:
+
+```python
+# adapter/telegram/handlers/chat.py
+from __future__ import annotations
+
+import asyncio
+
+from aiogram import F, Router
+from aiogram.filters import Command
+from aiogram.fsm.context import FSMContext
+from aiogram.types import CallbackQuery, Message
+
+from adapter.telegram import db
+from adapter.telegram.converter import (
+ format_outgoing,
+ from_message,
+ is_forum_message,
+ resolve_forum_chat_id,
+)
+from adapter.telegram.keyboards.chat import chats_list_keyboard
+from adapter.telegram.keyboards.confirm import confirm_keyboard
+from adapter.telegram.states import ChatState
+from core.handler import EventDispatcher
+from core.protocol import OutgoingMessage, OutgoingUI
+
+router = Router(name="chat")
+```
+
+- [ ] **Step 2: Обновить `_send_outgoing` — добавить Forum-вариант**
+
+Заменить функцию `_send_outgoing`:
+
+```python
+async def _send_outgoing(
+ message: Message,
+ chat_name: str,
+ events: list,
+ forum_group_id: int | None = None,
+ forum_thread_id: int | None = None,
+) -> None:
+ for event in events:
+ if forum_group_id and forum_thread_id:
+ # Ответ в Forum-тему (без тега)
+ text = event.text if isinstance(event, (OutgoingMessage, OutgoingUI)) else str(event)
+ if isinstance(event, OutgoingUI) and event.buttons:
+ action_id = event.buttons[0].payload.get("action_id", "unknown")
+ kb = confirm_keyboard(action_id)
+ await message.bot.send_message(
+ forum_group_id, text,
+ message_thread_id=forum_thread_id,
+ reply_markup=kb,
+ )
+ else:
+ await message.bot.send_message(
+ forum_group_id, text,
+ message_thread_id=forum_thread_id,
+ )
+ else:
+ # Ответ в DM с тегом
+ if isinstance(event, OutgoingUI) and event.buttons:
+ action_id = event.buttons[0].payload.get("action_id", "unknown")
+ kb = confirm_keyboard(action_id)
+ await message.answer(format_outgoing(chat_name, event), reply_markup=kb)
+ elif isinstance(event, (OutgoingMessage, OutgoingUI)):
+ await message.answer(format_outgoing(chat_name, event))
+```
+
+- [ ] **Step 3: Обновить `handle_message` — Forum-маршрутизация**
+
+Заменить функцию `handle_message`:
+
+```python
+@router.message(ChatState.idle, (F.text | F.photo | F.document | F.voice) & ~F.text.startswith("/"))
+async def handle_message(
+ message: Message,
+ state: FSMContext,
+ dispatcher: EventDispatcher,
+) -> None:
+ tg_id = message.from_user.id
+
+ # Определяем chat_id и канал ответа
+ if is_forum_message(message):
+ chat_id = resolve_forum_chat_id(message)
+ if not chat_id:
+ await message.reply(
+ "Эта тема не зарегистрирована как чат. "
+ "Введи /new в этой теме чтобы создать чат."
+ )
+ return
+ chat = db.get_chat_by_thread(tg_id, message.message_thread_id)
+ chat_name = chat["name"]
+ forum_group_id = message.chat.id
+ forum_thread_id = message.message_thread_id
+ else:
+ data = await state.get_data()
+ chat_id = data.get("active_chat_id")
+ chat_name = data.get("active_chat_name", "Чат")
+ forum_group_id = None
+ forum_thread_id = None
+
+ if not chat_id:
+ await message.answer("Нет активного чата. Введите /start")
+ return
+
+ await state.set_state(ChatState.waiting_response)
+
+ async def _typing_loop():
+ while True:
+ await message.bot.send_chat_action(message.chat.id, "typing")
+ await asyncio.sleep(4)
+
+ task = asyncio.create_task(_typing_loop())
+ try:
+ tg_user = db.get_or_create_tg_user(tg_id, str(tg_id), message.from_user.full_name)
+ platform_user_id = tg_user.get("platform_user_id", str(tg_id))
+
+ incoming = from_message(message, chat_id)
+ incoming.user_id = platform_user_id
+ events = await dispatcher.dispatch(incoming)
+ finally:
+ task.cancel()
+ try:
+ await task
+ except asyncio.CancelledError:
+ pass
+
+ await state.set_state(ChatState.idle)
+ await _send_outgoing(message, chat_name, events, forum_group_id, forum_thread_id)
+```
+
+- [ ] **Step 4: Обновить `cmd_new_chat` — ветвление DM vs Forum**
+
+Заменить функцию `cmd_new_chat`:
+
+```python
+@router.message(Command("new"))
+async def cmd_new_chat(message: Message, state: FSMContext) -> None:
+ tg_id = message.from_user.id
+ args = message.text.split(maxsplit=1)
+ name = args[1].strip() if len(args) > 1 else None
+
+ if is_forum_message(message):
+ # /new в Forum-теме — регистрируем эту тему как чат
+ thread_id = message.message_thread_id
+ existing = db.get_chat_by_thread(tg_id, thread_id)
+ if existing:
+ await message.reply(f"Эта тема уже зарегистрирована как [{existing['name']}].")
+ return
+
+ count = db.count_chats(tg_id)
+ chat_name = name or f"Чат #{count + 1}"
+ chat_id = db.create_chat(tg_id, chat_name)
+ db.set_forum_thread(chat_id, thread_id)
+
+ await message.reply(f"✅ [{chat_name}] зарегистрирован. Пиши здесь!")
+ else:
+ # /new в DM
+ count = db.count_chats(tg_id)
+ chat_name = name or f"Чат #{count + 1}"
+ chat_id = db.create_chat(tg_id, chat_name)
+
+ # Если есть форум-группа — создать тему и там
+ group_id = db.get_forum_group(tg_id)
+ if group_id:
+ try:
+ topic = await message.bot.create_forum_topic(
+ chat_id=group_id,
+ name=chat_name,
+ )
+ db.set_forum_thread(chat_id, topic.message_thread_id)
+ except Exception:
+ pass # не блокирует создание DM-чата
+
+ await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name)
+ await state.set_state(ChatState.idle)
+ await message.answer(f"✅ [{chat_name}] создан. Пиши!")
+```
+
+- [ ] **Step 5: Проверить синтаксис**
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot
+uv run python -m py_compile .worktrees/telegram/adapter/telegram/handlers/chat.py && echo OK
+```
+
+Ожидаем: `OK`.
+
+- [ ] **Step 6: Запустить все тесты**
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot
+PYTHONPATH=.worktrees/telegram pytest tests/ -v
+```
+
+Ожидаем: все тесты `passed`.
+
+- [ ] **Step 7: Commit**
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram
+git add adapter/telegram/handlers/chat.py
+git commit -m "feat: forum routing in handle_message and cmd_new_chat"
+```
+
+---
+
+## Task 6: bot.py — регистрация forum.router
+
+**Files:**
+- Modify: `adapter/telegram/bot.py`
+
+- [ ] **Step 1: Добавить импорт и регистрацию router**
+
+В блоке импортов добавить:
+
+```python
+from adapter.telegram.handlers import auth, chat, confirm, forum, settings
+```
+
+В `main()` после `dp.include_router(auth.router)`:
+
+```python
+ dp.include_router(auth.router)
+ dp.include_router(forum.router) # ← добавить
+ dp.include_router(chat.router)
+ dp.include_router(settings.router)
+ dp.include_router(confirm.router)
+```
+
+- [ ] **Step 2: Проверить синтаксис**
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot
+uv run python -m py_compile .worktrees/telegram/adapter/telegram/bot.py && echo OK
+```
+
+Ожидаем: `OK`.
+
+- [ ] **Step 3: Финальный прогон всех тестов**
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot
+PYTHONPATH=.worktrees/telegram pytest tests/ -v
+```
+
+Ожидаем: все тесты `passed`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram
+git add adapter/telegram/bot.py
+git commit -m "feat: register forum router in bot.py"
+```
diff --git a/docs/superpowers/plans/2026-03-31-matrix-adapter.md b/docs/superpowers/plans/2026-03-31-matrix-adapter.md
new file mode 100644
index 0000000..7f3ea28
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-31-matrix-adapter.md
@@ -0,0 +1,1681 @@
+# Matrix Adapter Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Implement `adapter/matrix/` — Matrix bot using matrix-nio that connects to the Lambda platform via `EventDispatcher` and `MockPlatformClient`.
+
+**Architecture:** Room-type routing — each incoming event is classified by room type (chat/settings) then dispatched. DM room = C1 (first chat). Space and Settings room created lazily on first `!new`. Core business logic lives in `EventDispatcher`; the adapter converts nio events ↔ protocol events.
+
+**Tech Stack:** matrix-nio 0.21+, Python 3.11+, `SQLiteStore` (key-value), `MockPlatformClient`, pytest-asyncio
+
+---
+
+## File map
+
+| File | Responsibility |
+|------|---------------|
+| `adapter/matrix/store.py` | Key-prefix helpers for room/user metadata in `StateStore` |
+| `adapter/matrix/converter.py` | nio event → `IncomingEvent`, `extract_attachments` |
+| `adapter/matrix/reactions.py` | `add_reaction`, `edit_message`, `build_skills_text` |
+| `adapter/matrix/handlers/auth.py` | Invite → join + register room + welcome message |
+| `adapter/matrix/handlers/chat.py` | Text messages, `!new`, `!chats` |
+| `adapter/matrix/handlers/confirm.py` | 👍/❌ reactions + `!yes`/`!no` |
+| `adapter/matrix/handlers/settings.py` | `!skills` (m.replace), `!soul`, `!safety`, `!plan`, `!status`, `!whoami`, `!connectors` |
+| `adapter/matrix/bot.py` | `AsyncClient`, sync loop, event routing |
+
+Store key conventions (all via `StateStore` KV):
+- `matrix_room:{room_id}` → `{room_type, chat_id, display_name, matrix_user_id}`
+- `matrix_user:{matrix_user_id}` → `{platform_user_id, display_name, space_id, settings_room_id, next_chat_index}`
+- `matrix_state:{room_id}` → `{state}` — one of `idle | waiting_response | confirm_pending | settings_active`
+- `matrix_skills_msg:{room_id}` → `{event_id}` — event_id of the last `!skills` message (for m.replace)
+
+---
+
+### Task 1: Store helpers
+
+**Files:**
+- Create: `adapter/matrix/__init__.py`
+- Create: `adapter/matrix/store.py`
+- Create: `tests/adapter/__init__.py`
+- Create: `tests/adapter/matrix/__init__.py`
+- Create: `tests/adapter/matrix/test_store.py`
+
+- [ ] **Step 1: Write failing test**
+
+```python
+# tests/adapter/matrix/test_store.py
+import pytest
+from core.store import InMemoryStore
+from adapter.matrix.store import (
+ get_room_meta, set_room_meta,
+ get_user_meta, set_user_meta,
+ get_room_state, set_room_state,
+ next_chat_id,
+)
+
+
+@pytest.fixture
+def store():
+ return InMemoryStore()
+
+
+async def test_room_meta_roundtrip(store):
+ meta = {"room_type": "chat", "chat_id": "C1", "display_name": "Чат 1", "matrix_user_id": "@alice:m.org"}
+ await set_room_meta(store, "!r:m.org", meta)
+ assert await get_room_meta(store, "!r:m.org") == meta
+
+
+async def test_room_meta_missing(store):
+ assert await get_room_meta(store, "!nonexistent:m.org") is None
+
+
+async def test_user_meta_roundtrip(store):
+ meta = {"platform_user_id": "usr-1", "display_name": "Alice",
+ "space_id": None, "settings_room_id": None, "next_chat_index": 1}
+ await set_user_meta(store, "@alice:m.org", meta)
+ assert await get_user_meta(store, "@alice:m.org") == meta
+
+
+async def test_room_state_roundtrip(store):
+ await set_room_state(store, "!r:m.org", "idle")
+ assert await get_room_state(store, "!r:m.org") == "idle"
+ await set_room_state(store, "!r:m.org", "waiting_response")
+ assert await get_room_state(store, "!r:m.org") == "waiting_response"
+
+
+async def test_room_state_default_idle(store):
+ assert await get_room_state(store, "!unknown:m.org") == "idle"
+
+
+async def test_next_chat_id_increments(store):
+ uid = "@alice:m.org"
+ await set_user_meta(store, uid, {"next_chat_index": 1})
+ assert await next_chat_id(store, uid) == "C1"
+ assert await next_chat_id(store, uid) == "C2"
+ assert await next_chat_id(store, uid) == "C3"
+```
+
+- [ ] **Step 2: Run — expect ImportError**
+
+```bash
+cd /path/to/surfaces-bot && pytest tests/adapter/matrix/test_store.py -v
+```
+
+- [ ] **Step 3: Create `__init__.py` files**
+
+```bash
+touch adapter/__init__.py adapter/matrix/__init__.py tests/adapter/__init__.py tests/adapter/matrix/__init__.py
+```
+
+- [ ] **Step 4: Implement store.py**
+
+```python
+# adapter/matrix/store.py
+from __future__ import annotations
+from core.store import StateStore
+
+
+async def get_room_meta(store: StateStore, room_id: str) -> dict | None:
+ return await store.get(f"matrix_room:{room_id}")
+
+
+async def set_room_meta(store: StateStore, room_id: str, meta: dict) -> None:
+ await store.set(f"matrix_room:{room_id}", meta)
+
+
+async def get_user_meta(store: StateStore, matrix_user_id: str) -> dict | None:
+ return await store.get(f"matrix_user:{matrix_user_id}")
+
+
+async def set_user_meta(store: StateStore, matrix_user_id: str, meta: dict) -> None:
+ await store.set(f"matrix_user:{matrix_user_id}", meta)
+
+
+async def get_room_state(store: StateStore, room_id: str) -> str:
+ data = await store.get(f"matrix_state:{room_id}")
+ return data["state"] if data else "idle"
+
+
+async def set_room_state(store: StateStore, room_id: str, state: str) -> None:
+ await store.set(f"matrix_state:{room_id}", {"state": state})
+
+
+async def next_chat_id(store: StateStore, matrix_user_id: str) -> str:
+ """Allocate next chat_id (C1, C2, ...) and increment counter in user meta."""
+ meta = await get_user_meta(store, matrix_user_id) or {}
+ index = meta.get("next_chat_index", 1)
+ meta["next_chat_index"] = index + 1
+ await set_user_meta(store, matrix_user_id, meta)
+ return f"C{index}"
+```
+
+- [ ] **Step 5: Run — expect all PASS**
+
+```bash
+pytest tests/adapter/matrix/test_store.py -v
+```
+Expected: 6 tests PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add adapter/__init__.py adapter/matrix/__init__.py adapter/matrix/store.py \
+ tests/adapter/__init__.py tests/adapter/matrix/__init__.py tests/adapter/matrix/test_store.py
+git commit -m "feat(matrix): room/user store helpers"
+```
+
+---
+
+### Task 2: Converter
+
+**Files:**
+- Create: `adapter/matrix/converter.py`
+- Create: `tests/adapter/matrix/test_converter.py`
+
+- [ ] **Step 1: Write failing tests**
+
+```python
+# tests/adapter/matrix/test_converter.py
+from types import SimpleNamespace
+from core.protocol import Attachment, IncomingCallback, IncomingCommand, IncomingMessage
+from adapter.matrix.converter import from_room_event
+
+
+def text_event(body, sender="@a:m.org", event_id="$e1"):
+ return SimpleNamespace(sender=sender, body=body, event_id=event_id,
+ msgtype="m.text", replyto_event_id=None)
+
+
+def file_event(url="mxc://x/y", filename="doc.pdf", mime="application/pdf"):
+ return SimpleNamespace(sender="@a:m.org", body=filename, event_id="$e2",
+ msgtype="m.file", replyto_event_id=None,
+ url=url, mimetype=mime)
+
+
+def image_event(url="mxc://x/img", mime="image/jpeg"):
+ return SimpleNamespace(sender="@a:m.org", body="img.jpg", event_id="$e3",
+ msgtype="m.image", replyto_event_id=None,
+ url=url, mimetype=mime)
+
+
+def audio_event(url="mxc://x/audio", mime="audio/ogg"):
+ return SimpleNamespace(sender="@a:m.org", body="voice.ogg", event_id="$e4",
+ msgtype="m.audio", replyto_event_id=None,
+ url=url, mimetype=mime)
+
+
+def reaction_event(key, reacted_to="$orig"):
+ return SimpleNamespace(sender="@a:m.org", key=key, reacted_to_id=reacted_to, event_id="$r1")
+
+
+async def test_plain_text_to_incoming_message():
+ result = from_room_event(text_event("Hello"), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingMessage)
+ assert result.text == "Hello"
+ assert result.platform == "matrix"
+ assert result.chat_id == "C1"
+ assert result.attachments == []
+
+
+async def test_bang_command_to_incoming_command():
+ result = from_room_event(text_event("!new Analysis"), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingCommand)
+ assert result.command == "new"
+ assert result.args == ["Analysis"]
+
+
+async def test_bang_command_no_args():
+ result = from_room_event(text_event("!skills"), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingCommand)
+ assert result.command == "skills"
+ assert result.args == []
+
+
+async def test_yes_to_callback():
+ result = from_room_event(text_event("!yes"), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingCallback)
+ assert result.action == "confirm"
+
+
+async def test_no_to_callback():
+ result = from_room_event(text_event("!no"), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingCallback)
+ assert result.action == "cancel"
+
+
+async def test_file_attachment():
+ result = from_room_event(file_event(), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingMessage)
+ assert len(result.attachments) == 1
+ a = result.attachments[0]
+ assert a.type == "document"
+ assert a.url == "mxc://x/y"
+ assert a.filename == "doc.pdf"
+ assert a.mime_type == "application/pdf"
+
+
+async def test_image_attachment():
+ result = from_room_event(image_event(), room_id="!r:m.org", chat_id="C1")
+ assert result.attachments[0].type == "image"
+ assert result.attachments[0].mime_type == "image/jpeg"
+
+
+async def test_audio_attachment():
+ result = from_room_event(audio_event(), room_id="!r:m.org", chat_id="C1")
+ assert result.attachments[0].type == "audio"
+
+
+async def test_confirm_reaction():
+ result = from_room_event(reaction_event("👍"), room_id="!r:m.org", chat_id="C1", is_reaction=True)
+ assert isinstance(result, IncomingCallback)
+ assert result.action == "confirm"
+
+
+async def test_cancel_reaction():
+ result = from_room_event(reaction_event("❌"), room_id="!r:m.org", chat_id="C1", is_reaction=True)
+ assert isinstance(result, IncomingCallback)
+ assert result.action == "cancel"
+
+
+async def test_skill_reaction_index():
+ result = from_room_event(reaction_event("4️⃣"), room_id="!r:m.org", chat_id="C1", is_reaction=True)
+ assert isinstance(result, IncomingCallback)
+ assert result.action == "toggle_skill"
+ assert result.payload["skill_index"] == 3 # 0-based
+
+
+async def test_unknown_reaction_returns_none():
+ result = from_room_event(reaction_event("🎉"), room_id="!r:m.org", chat_id="C1", is_reaction=True)
+ assert result is None
+```
+
+- [ ] **Step 2: Run — expect ImportError**
+
+```bash
+pytest tests/adapter/matrix/test_converter.py -v
+```
+
+- [ ] **Step 3: Implement converter.py**
+
+```python
+# adapter/matrix/converter.py
+from __future__ import annotations
+from core.protocol import Attachment, IncomingCallback, IncomingCommand, IncomingEvent, IncomingMessage
+
+SKILL_REACTIONS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣"]
+CONFIRM_REACTIONS = {"👍": "confirm", "❌": "cancel"}
+_CALLBACK_COMMANDS = {"yes": "confirm", "no": "cancel"}
+
+
+def from_room_event(
+ event,
+ room_id: str,
+ chat_id: str,
+ is_reaction: bool = False,
+) -> IncomingEvent | None:
+ """Convert a nio event object to an IncomingEvent. Returns None if unrecognised."""
+ if is_reaction:
+ return _from_reaction(event, chat_id)
+
+ body: str = event.body
+
+ if body.startswith("!"):
+ parts = body[1:].split(maxsplit=1)
+ cmd = parts[0].lower()
+ args = parts[1].split() if len(parts) > 1 else []
+
+ if cmd in _CALLBACK_COMMANDS:
+ return IncomingCallback(
+ user_id=event.sender, platform="matrix", chat_id=chat_id,
+ action=_CALLBACK_COMMANDS[cmd], payload={},
+ )
+ return IncomingCommand(
+ user_id=event.sender, platform="matrix", chat_id=chat_id,
+ command=cmd, args=args,
+ )
+
+ return IncomingMessage(
+ user_id=event.sender, platform="matrix", chat_id=chat_id,
+ text=body if event.msgtype == "m.text" else "",
+ attachments=extract_attachments(event),
+ reply_to=getattr(event, "replyto_event_id", None),
+ )
+
+
+def extract_attachments(event) -> list[Attachment]:
+ msgtype = getattr(event, "msgtype", "m.text")
+ url = getattr(event, "url", None)
+ mime = getattr(event, "mimetype", None)
+
+ if msgtype == "m.image":
+ return [Attachment(type="image", url=url, mime_type=mime)]
+ if msgtype == "m.file":
+ return [Attachment(type="document", url=url, filename=event.body, mime_type=mime)]
+ if msgtype == "m.audio":
+ return [Attachment(type="audio", url=url, mime_type=mime)]
+ return []
+
+
+def _from_reaction(event, chat_id: str) -> IncomingCallback | None:
+ key = event.key
+ if key in CONFIRM_REACTIONS:
+ return IncomingCallback(
+ user_id=event.sender, platform="matrix", chat_id=chat_id,
+ action=CONFIRM_REACTIONS[key],
+ payload={"reacted_to_id": event.reacted_to_id},
+ )
+ if key in SKILL_REACTIONS:
+ return IncomingCallback(
+ user_id=event.sender, platform="matrix", chat_id=chat_id,
+ action="toggle_skill",
+ payload={"skill_index": SKILL_REACTIONS.index(key), "reacted_to_id": event.reacted_to_id},
+ )
+ return None
+```
+
+- [ ] **Step 4: Run — expect all PASS**
+
+```bash
+pytest tests/adapter/matrix/test_converter.py -v
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/converter.py tests/adapter/matrix/test_converter.py
+git commit -m "feat(matrix): event converter"
+```
+
+---
+
+### Task 3: Reactions helpers
+
+**Files:**
+- Create: `adapter/matrix/reactions.py`
+- Create: `tests/adapter/matrix/test_reactions.py`
+
+- [ ] **Step 1: Write failing tests**
+
+```python
+# tests/adapter/matrix/test_reactions.py
+from unittest.mock import AsyncMock
+from adapter.matrix.reactions import add_reaction, edit_message, build_skills_text
+from sdk.interface import UserSettings
+
+
+async def test_add_reaction():
+ client = AsyncMock()
+ await add_reaction(client, "!r:m.org", "$evt", "👍")
+ client.room_send.assert_called_once_with(
+ "!r:m.org", "m.reaction",
+ {"m.relates_to": {"rel_type": "m.annotation", "event_id": "$evt", "key": "👍"}},
+ )
+
+
+async def test_edit_message():
+ client = AsyncMock()
+ await edit_message(client, "!r:m.org", "$orig", "new text")
+ client.room_send.assert_called_once_with(
+ "!r:m.org", "m.room.message",
+ {
+ "msgtype": "m.text",
+ "body": "* new text",
+ "m.new_content": {"msgtype": "m.text", "body": "new text"},
+ "m.relates_to": {"rel_type": "m.replace", "event_id": "$orig"},
+ },
+ )
+
+
+def test_build_skills_text_shows_status():
+ settings = UserSettings(skills={"web-search": True, "browser": False})
+ text = build_skills_text(settings)
+ assert "✅ 1 web-search" in text
+ assert "❌ 2 browser" in text
+
+
+def test_build_skills_text_has_reaction_hint():
+ settings = UserSettings(skills={"web-search": True, "browser": False})
+ text = build_skills_text(settings)
+ assert "1️⃣" in text
+ assert "Реакция" in text
+```
+
+- [ ] **Step 2: Run — expect ImportError**
+
+```bash
+pytest tests/adapter/matrix/test_reactions.py -v
+```
+
+- [ ] **Step 3: Implement reactions.py**
+
+```python
+# adapter/matrix/reactions.py
+from __future__ import annotations
+from adapter.matrix.converter import SKILL_REACTIONS
+from sdk.interface import UserSettings
+
+_SKILL_DESCRIPTIONS: dict[str, str] = {
+ "web-search": "поиск в интернете",
+ "fetch-url": "чтение веб-страниц",
+ "email": "чтение почты",
+ "browser": "управление браузером",
+ "image-gen": "генерация изображений",
+ "video-gen": "генерация видео",
+ "files": "работа с файлами",
+ "calendar": "календарь",
+}
+
+
+async def add_reaction(client, room_id: str, event_id: str, key: str) -> None:
+ await client.room_send(
+ room_id, "m.reaction",
+ {"m.relates_to": {"rel_type": "m.annotation", "event_id": event_id, "key": key}},
+ )
+
+
+async def edit_message(client, room_id: str, original_event_id: str, new_body: str) -> None:
+ await client.room_send(
+ room_id, "m.room.message",
+ {
+ "msgtype": "m.text",
+ "body": f"* {new_body}",
+ "m.new_content": {"msgtype": "m.text", "body": new_body},
+ "m.relates_to": {"rel_type": "m.replace", "event_id": original_event_id},
+ },
+ )
+
+
+def build_skills_text(settings: UserSettings) -> str:
+ skill_names = list(settings.skills.keys())
+ lines = []
+ for i, name in enumerate(skill_names):
+ enabled = settings.skills[name]
+ emoji = "✅" if enabled else "❌"
+ desc = _SKILL_DESCRIPTIONS.get(name, name)
+ lines.append(f"{emoji} {i + 1} {name} — {desc}")
+
+ hint = " ".join(SKILL_REACTIONS[i] for i in range(min(len(skill_names), len(SKILL_REACTIONS))))
+ lines += ["", f"Реакция {hint} = переключить скилл"]
+ return "\n".join(lines)
+```
+
+- [ ] **Step 4: Run — expect all PASS**
+
+```bash
+pytest tests/adapter/matrix/test_reactions.py -v
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/reactions.py tests/adapter/matrix/test_reactions.py
+git commit -m "feat(matrix): reactions and edit helpers"
+```
+
+---
+
+### Task 4: Auth handler — invite → onboarding
+
+**Files:**
+- Create: `adapter/matrix/handlers/__init__.py`
+- Create: `adapter/matrix/handlers/auth.py`
+- Create: `tests/adapter/matrix/test_auth.py`
+
+- [ ] **Step 1: Write failing tests**
+
+```python
+# tests/adapter/matrix/test_auth.py
+import pytest
+from unittest.mock import AsyncMock
+from core.store import InMemoryStore
+from core.auth import AuthManager
+from sdk.mock import MockPlatformClient
+from adapter.matrix.handlers.auth import handle_invite
+from adapter.matrix.store import get_room_meta, get_room_state, get_user_meta
+
+
+@pytest.fixture
+def store():
+ return InMemoryStore()
+
+
+@pytest.fixture
+def platform():
+ return MockPlatformClient()
+
+
+@pytest.fixture
+def client():
+ c = AsyncMock()
+ c.join = AsyncMock()
+ c.room_send = AsyncMock()
+ return c
+
+
+async def test_invite_joins_room(client, store, platform):
+ await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform, display_name="Alice")
+ client.join.assert_called_once_with("!dm:m.org")
+
+
+async def test_invite_sends_welcome_with_name(client, store, platform):
+ await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform, display_name="Alice")
+ body = client.room_send.call_args[0][2]["body"]
+ assert "Alice" in body
+ assert "!new" in body
+
+
+async def test_invite_registers_room_as_c1(client, store, platform):
+ await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform)
+ meta = await get_room_meta(store, "!dm:m.org")
+ assert meta["room_type"] == "chat"
+ assert meta["chat_id"] == "C1"
+ assert meta["matrix_user_id"] == "@alice:m.org"
+
+
+async def test_invite_creates_platform_user(client, store, platform):
+ await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform, display_name="Alice")
+ user_meta = await get_user_meta(store, "@alice:m.org")
+ assert user_meta is not None
+ assert "platform_user_id" in user_meta
+
+
+async def test_invite_authenticates_user(client, store, platform):
+ await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform)
+ auth_mgr = AuthManager(platform, store)
+ assert await auth_mgr.is_authenticated("@alice:m.org")
+
+
+async def test_invite_room_state_idle(client, store, platform):
+ await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform)
+ assert await get_room_state(store, "!dm:m.org") == "idle"
+
+
+async def test_second_invite_gets_c2(client, store, platform):
+ await handle_invite(client, "!dm1:m.org", "@alice:m.org", store, platform)
+ await handle_invite(client, "!dm2:m.org", "@alice:m.org", store, platform)
+ meta = await get_room_meta(store, "!dm2:m.org")
+ assert meta["chat_id"] == "C2"
+```
+
+- [ ] **Step 2: Run — expect ImportError**
+
+```bash
+pytest tests/adapter/matrix/test_auth.py -v
+```
+
+- [ ] **Step 3: Create `__init__.py` and implement auth.py**
+
+```python
+# adapter/matrix/handlers/__init__.py
+# (empty)
+```
+
+```python
+# adapter/matrix/handlers/auth.py
+from __future__ import annotations
+import structlog
+from adapter.matrix.store import (
+ get_user_meta, next_chat_id,
+ set_room_meta, set_room_state, set_user_meta,
+)
+from core.auth import AuthManager
+from sdk.interface import PlatformClient
+
+logger = structlog.get_logger(__name__)
+
+
+async def handle_invite(
+ client,
+ room_id: str,
+ matrix_user_id: str,
+ store,
+ platform: PlatformClient,
+ display_name: str | None = None,
+) -> None:
+ """Accept invite, register DM room as first chat, authenticate user, send welcome."""
+ await client.join(room_id)
+ logger.info("Joined room", room_id=room_id, user=matrix_user_id)
+
+ user = await platform.get_or_create_user(matrix_user_id, "matrix", display_name)
+
+ user_meta = await get_user_meta(store, matrix_user_id)
+ if user_meta is None:
+ user_meta = {
+ "platform_user_id": user.user_id,
+ "display_name": display_name,
+ "space_id": None,
+ "settings_room_id": None,
+ "next_chat_index": 1,
+ }
+ await set_user_meta(store, matrix_user_id, user_meta)
+
+ auth_mgr = AuthManager(platform, store)
+ await auth_mgr.confirm(matrix_user_id)
+
+ chat_id = await next_chat_id(store, matrix_user_id)
+ chat_num = chat_id[1:]
+ await set_room_meta(store, room_id, {
+ "room_type": "chat",
+ "chat_id": chat_id,
+ "display_name": f"Чат {chat_num}",
+ "matrix_user_id": matrix_user_id,
+ })
+ await set_room_state(store, room_id, "idle")
+
+ name = display_name or matrix_user_id.split(":")[0].lstrip("@")
+ welcome = (
+ f"Привет, {name}! Пиши — я здесь.\n\n"
+ "Команды: !new · !chats · !rename · !archive · !skills"
+ )
+ await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": welcome})
+```
+
+- [ ] **Step 4: Run — expect all PASS**
+
+```bash
+pytest tests/adapter/matrix/test_auth.py -v
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/handlers/__init__.py adapter/matrix/handlers/auth.py tests/adapter/matrix/test_auth.py
+git commit -m "feat(matrix): invite handler + onboarding"
+```
+
+---
+
+### Task 5: Chat handler — messages + !new + !chats
+
+**Files:**
+- Create: `adapter/matrix/handlers/chat.py`
+- Create: `tests/adapter/matrix/test_chat_handler.py`
+
+- [ ] **Step 1: Write failing tests**
+
+```python
+# tests/adapter/matrix/test_chat_handler.py
+import pytest
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+from core.store import InMemoryStore
+from core.auth import AuthManager
+from core.chat import ChatManager
+from core.settings import SettingsManager
+from core.handler import EventDispatcher
+from core.handlers import register_all
+from sdk.mock import MockPlatformClient
+from adapter.matrix.store import get_room_meta, set_room_meta, set_room_state, set_user_meta
+from adapter.matrix.handlers.chat import handle_message, handle_new_chat, handle_list_chats
+
+
+@pytest.fixture
+def store():
+ return InMemoryStore()
+
+
+@pytest.fixture
+def platform():
+ return MockPlatformClient()
+
+
+@pytest.fixture
+def dispatcher(platform, store):
+ d = EventDispatcher(
+ platform=platform,
+ chat_mgr=ChatManager(platform, store),
+ auth_mgr=AuthManager(platform, store),
+ settings_mgr=SettingsManager(platform, store),
+ )
+ register_all(d)
+ return d
+
+
+@pytest.fixture
+def client():
+ c = AsyncMock()
+ c.room_send = AsyncMock()
+ c.room_typing = AsyncMock()
+ c.room_create = AsyncMock(return_value=AsyncMock(room_id="!new:m.org"))
+ c.room_invite = AsyncMock()
+ c.room_put_state = AsyncMock()
+ return c
+
+
+async def _setup(store, platform, room_id="!dm:m.org", uid="@alice:m.org"):
+ user = await platform.get_or_create_user(uid, "matrix", "Alice")
+ await set_user_meta(store, uid, {
+ "platform_user_id": user.user_id,
+ "display_name": "Alice",
+ "space_id": None,
+ "settings_room_id": None,
+ "next_chat_index": 2,
+ })
+ await set_room_meta(store, room_id, {
+ "room_type": "chat", "chat_id": "C1",
+ "display_name": "Чат 1", "matrix_user_id": uid,
+ })
+ await set_room_state(store, room_id, "idle")
+ auth = AuthManager(platform, store)
+ await auth.confirm(uid)
+
+
+def _text_event(body, sender="@alice:m.org"):
+ return SimpleNamespace(sender=sender, body=body, event_id="$e1",
+ msgtype="m.text", replyto_event_id=None)
+
+
+async def test_message_gets_response(client, store, platform, dispatcher):
+ await _setup(store, platform)
+ await handle_message(client, "!dm:m.org", _text_event("Hello"), store, platform, dispatcher)
+ texts = [str(c) for c in client.room_send.call_args_list]
+ assert any("[MOCK]" in t for t in texts)
+
+
+async def test_message_sends_typing(client, store, platform, dispatcher):
+ await _setup(store, platform)
+ await handle_message(client, "!dm:m.org", _text_event("Hello"), store, platform, dispatcher)
+ client.room_typing.assert_called()
+
+
+async def test_new_creates_matrix_room(client, store, platform, dispatcher):
+ await _setup(store, platform)
+ await handle_new_chat(client, "!dm:m.org", _text_event("!new Analysis"), store, platform, dispatcher)
+ client.room_create.assert_called()
+ client.room_invite.assert_called()
+
+
+async def test_new_registers_room_meta(client, store, platform, dispatcher):
+ await _setup(store, platform)
+ await handle_new_chat(client, "!dm:m.org", _text_event("!new Analysis"), store, platform, dispatcher)
+ meta = await get_room_meta(store, "!new:m.org")
+ assert meta is not None
+ assert meta["room_type"] == "chat"
+ assert meta["display_name"] == "Analysis"
+
+
+async def test_list_chats_includes_room_name(client, store, platform, dispatcher):
+ await _setup(store, platform)
+ await handle_list_chats(client, "!dm:m.org", "@alice:m.org", store)
+ body = client.room_send.call_args[0][2]["body"]
+ assert "Чат 1" in body
+```
+
+- [ ] **Step 2: Run — expect ImportError**
+
+```bash
+pytest tests/adapter/matrix/test_chat_handler.py -v
+```
+
+- [ ] **Step 3: Implement handlers/chat.py**
+
+```python
+# adapter/matrix/handlers/chat.py
+from __future__ import annotations
+import asyncio
+import structlog
+from adapter.matrix.converter import from_room_event
+from adapter.matrix.store import (
+ get_room_meta, get_user_meta,
+ next_chat_id, set_room_meta, set_room_state, set_user_meta,
+)
+from core.protocol import OutgoingMessage, OutgoingTyping
+from sdk.interface import PlatformClient
+
+logger = structlog.get_logger(__name__)
+_TYPING_INTERVAL = 25 # nio typing expires ~30s
+
+
+async def handle_message(client, room_id: str, event, store, platform: PlatformClient, dispatcher) -> None:
+ room_meta = await get_room_meta(store, room_id)
+ if room_meta is None:
+ return
+
+ incoming = from_room_event(event, room_id=room_id, chat_id=room_meta["chat_id"])
+ if incoming is None:
+ return
+
+ await set_room_state(store, room_id, "waiting_response")
+ await client.room_typing(room_id, True, timeout=_TYPING_INTERVAL * 1000)
+
+ typing_task = asyncio.create_task(_keep_typing(client, room_id, _TYPING_INTERVAL))
+ try:
+ outgoing_events = await dispatcher.dispatch(incoming)
+ finally:
+ typing_task.cancel()
+ await client.room_typing(room_id, False, timeout=0)
+
+ await set_room_state(store, room_id, "idle")
+ for out in outgoing_events:
+ await _send(client, room_id, out)
+
+
+async def handle_new_chat(client, room_id: str, event, store, platform: PlatformClient, dispatcher) -> None:
+ room_meta = await get_room_meta(store, room_id)
+ if room_meta is None:
+ return
+
+ matrix_user_id = room_meta["matrix_user_id"]
+ parts = event.body[1:].split(maxsplit=1) # "!new Analysis" → ["new", "Analysis"]
+ display_name_arg = parts[1] if len(parts) > 1 else None
+
+ chat_id = await next_chat_id(store, matrix_user_id)
+ chat_num = chat_id[1:]
+ display_name = display_name_arg or f"Чат {chat_num}"
+
+ response = await client.room_create(name=display_name)
+ new_room_id = response.room_id
+ await client.room_invite(new_room_id, matrix_user_id)
+
+ user_meta = await get_user_meta(store, matrix_user_id) or {}
+ space_id = user_meta.get("space_id")
+ if space_id is None:
+ space_id = await _create_space(client, store, matrix_user_id, user_meta)
+
+ await client.room_put_state(space_id, "m.space.child", {"via": []}, state_key=new_room_id)
+ await client.room_put_state(space_id, "m.space.child", {"via": []}, state_key=room_id)
+
+ await set_room_meta(store, new_room_id, {
+ "room_type": "chat", "chat_id": chat_id,
+ "display_name": display_name, "matrix_user_id": matrix_user_id,
+ })
+ await set_room_state(store, new_room_id, "idle")
+
+ await client.room_send(
+ room_id, "m.room.message",
+ {"msgtype": "m.text", "body": f"✅ [{display_name}] создан. Перейди в комнату."},
+ )
+
+
+async def handle_list_chats(client, room_id: str, matrix_user_id: str, store) -> None:
+ all_keys = await store.keys("matrix_room:")
+ chats = []
+ for key in all_keys:
+ meta = await store.get(key)
+ if (meta and meta.get("matrix_user_id") == matrix_user_id
+ and meta.get("room_type") == "chat"):
+ chats.append(meta)
+
+ if not chats:
+ body = "Нет активных чатов. Напиши !new чтобы создать."
+ else:
+ lines = ["Твои чаты:"]
+ for chat in chats:
+ lines.append(f" {chat['display_name']} ({chat['chat_id']})")
+ body = "\n".join(lines)
+
+ await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body})
+
+
+async def _create_space(client, store, matrix_user_id: str, user_meta: dict) -> str:
+ name = user_meta.get("display_name") or matrix_user_id.split(":")[0].lstrip("@")
+ space_resp = await client.room_create(
+ name=f"Lambda — {name}",
+ initial_state=[{"type": "m.room.create", "content": {"type": "m.space"}}],
+ )
+ space_id = space_resp.room_id
+ await client.room_invite(space_id, matrix_user_id)
+
+ settings_resp = await client.room_create(name="⚙️ Настройки")
+ settings_room_id = settings_resp.room_id
+ await client.room_invite(settings_room_id, matrix_user_id)
+ await client.room_put_state(space_id, "m.space.child", {"via": []}, state_key=settings_room_id)
+
+ await set_room_meta(store, settings_room_id, {
+ "room_type": "settings", "chat_id": None,
+ "display_name": "Настройки", "matrix_user_id": matrix_user_id,
+ })
+ await set_room_state(store, settings_room_id, "settings_active")
+
+ user_meta["space_id"] = space_id
+ user_meta["settings_room_id"] = settings_room_id
+ await set_user_meta(store, matrix_user_id, user_meta)
+ return space_id
+
+
+async def _keep_typing(client, room_id: str, interval: int) -> None:
+ try:
+ while True:
+ await asyncio.sleep(interval)
+ await client.room_typing(room_id, True, timeout=interval * 1000)
+ except asyncio.CancelledError:
+ pass
+
+
+async def _send(client, room_id: str, event) -> None:
+ if isinstance(event, OutgoingMessage):
+ await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": event.text})
+ elif isinstance(event, OutgoingTyping):
+ await client.room_typing(room_id, event.is_typing, timeout=0)
+```
+
+- [ ] **Step 4: Run — expect all PASS**
+
+```bash
+pytest tests/adapter/matrix/test_chat_handler.py -v
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/handlers/chat.py tests/adapter/matrix/test_chat_handler.py
+git commit -m "feat(matrix): chat handler — messages, !new, !chats"
+```
+
+---
+
+### Task 6: Confirm handler — 👍/❌ + !yes/!no
+
+**Files:**
+- Create: `adapter/matrix/handlers/confirm.py`
+- Create: `tests/adapter/matrix/test_confirm.py`
+
+- [ ] **Step 1: Write failing tests**
+
+```python
+# tests/adapter/matrix/test_confirm.py
+import pytest
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+from core.store import InMemoryStore
+from core.auth import AuthManager
+from core.chat import ChatManager
+from core.settings import SettingsManager
+from core.handler import EventDispatcher
+from core.handlers import register_all
+from sdk.mock import MockPlatformClient
+from adapter.matrix.store import get_room_state, set_room_meta, set_room_state
+from adapter.matrix.handlers.confirm import handle_confirm_callback
+
+
+@pytest.fixture
+def store():
+ return InMemoryStore()
+
+
+@pytest.fixture
+def platform():
+ return MockPlatformClient()
+
+
+@pytest.fixture
+def dispatcher(platform, store):
+ d = EventDispatcher(
+ platform=platform,
+ chat_mgr=ChatManager(platform, store),
+ auth_mgr=AuthManager(platform, store),
+ settings_mgr=SettingsManager(platform, store),
+ )
+ register_all(d)
+ return d
+
+
+@pytest.fixture
+def client():
+ return AsyncMock()
+
+
+async def _setup(store, platform, room_id="!dm:m.org", uid="@alice:m.org"):
+ await platform.get_or_create_user(uid, "matrix", "Alice")
+ await set_room_meta(store, room_id, {
+ "room_type": "chat", "chat_id": "C1",
+ "display_name": "Чат 1", "matrix_user_id": uid,
+ })
+ await set_room_state(store, room_id, "confirm_pending")
+ await AuthManager(platform, store).confirm(uid)
+
+
+async def test_yes_command_transitions_to_idle(client, store, platform, dispatcher):
+ await _setup(store, platform)
+ event = SimpleNamespace(sender="@alice:m.org", body="!yes", event_id="$e1",
+ msgtype="m.text", replyto_event_id=None)
+ await handle_confirm_callback(client, "!dm:m.org", event, store, platform, dispatcher, is_reaction=False)
+ assert await get_room_state(store, "!dm:m.org") == "idle"
+
+
+async def test_no_command_transitions_to_idle(client, store, platform, dispatcher):
+ await _setup(store, platform)
+ event = SimpleNamespace(sender="@alice:m.org", body="!no", event_id="$e1",
+ msgtype="m.text", replyto_event_id=None)
+ await handle_confirm_callback(client, "!dm:m.org", event, store, platform, dispatcher, is_reaction=False)
+ assert await get_room_state(store, "!dm:m.org") == "idle"
+
+
+async def test_thumbs_up_reaction_transitions_to_idle(client, store, platform, dispatcher):
+ await _setup(store, platform)
+ event = SimpleNamespace(sender="@alice:m.org", key="👍",
+ reacted_to_id="$orig", event_id="$r1")
+ await handle_confirm_callback(client, "!dm:m.org", event, store, platform, dispatcher, is_reaction=True)
+ assert await get_room_state(store, "!dm:m.org") == "idle"
+
+
+async def test_confirm_sends_response(client, store, platform, dispatcher):
+ await _setup(store, platform)
+ event = SimpleNamespace(sender="@alice:m.org", body="!yes", event_id="$e1",
+ msgtype="m.text", replyto_event_id=None)
+ await handle_confirm_callback(client, "!dm:m.org", event, store, platform, dispatcher, is_reaction=False)
+ client.room_send.assert_called()
+
+
+async def test_noop_when_state_not_confirm_pending(client, store, platform, dispatcher):
+ await _setup(store, platform)
+ await set_room_state(store, "!dm:m.org", "idle") # wrong state
+ event = SimpleNamespace(sender="@alice:m.org", body="!yes", event_id="$e1",
+ msgtype="m.text", replyto_event_id=None)
+ await handle_confirm_callback(client, "!dm:m.org", event, store, platform, dispatcher, is_reaction=False)
+ client.room_send.assert_not_called()
+```
+
+- [ ] **Step 2: Run — expect ImportError**
+
+```bash
+pytest tests/adapter/matrix/test_confirm.py -v
+```
+
+- [ ] **Step 3: Implement handlers/confirm.py**
+
+```python
+# adapter/matrix/handlers/confirm.py
+from __future__ import annotations
+import structlog
+from adapter.matrix.converter import from_room_event
+from adapter.matrix.store import get_room_meta, get_room_state, set_room_state
+from core.protocol import OutgoingMessage
+from sdk.interface import PlatformClient
+
+logger = structlog.get_logger(__name__)
+
+
+async def handle_confirm_callback(
+ client,
+ room_id: str,
+ event,
+ store,
+ platform: PlatformClient,
+ dispatcher,
+ is_reaction: bool = False,
+) -> None:
+ if await get_room_state(store, room_id) != "confirm_pending":
+ return
+
+ room_meta = await get_room_meta(store, room_id)
+ if room_meta is None:
+ return
+
+ incoming = from_room_event(event, room_id=room_id,
+ chat_id=room_meta["chat_id"], is_reaction=is_reaction)
+ if incoming is None or getattr(incoming, "action", None) not in ("confirm", "cancel"):
+ return
+
+ await set_room_state(store, room_id, "idle")
+ outgoing_events = await dispatcher.dispatch(incoming)
+
+ for out in outgoing_events:
+ if isinstance(out, OutgoingMessage):
+ await client.room_send(room_id, "m.room.message",
+ {"msgtype": "m.text", "body": out.text})
+```
+
+- [ ] **Step 4: Run — expect all PASS**
+
+```bash
+pytest tests/adapter/matrix/test_confirm.py -v
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/handlers/confirm.py tests/adapter/matrix/test_confirm.py
+git commit -m "feat(matrix): confirm handler — reactions and !yes/!no"
+```
+
+---
+
+### Task 7: Settings handler — !skills (m.replace) + other commands
+
+**Files:**
+- Create: `adapter/matrix/handlers/settings.py`
+- Create: `tests/adapter/matrix/test_settings_handler.py`
+
+- [ ] **Step 1: Write failing tests**
+
+```python
+# tests/adapter/matrix/test_settings_handler.py
+import pytest
+from unittest.mock import AsyncMock
+from core.store import InMemoryStore
+from core.auth import AuthManager
+from core.chat import ChatManager
+from core.settings import SettingsManager
+from core.handler import EventDispatcher
+from core.handlers import register_all
+from sdk.mock import MockPlatformClient
+from adapter.matrix.store import set_room_meta, set_room_state, set_user_meta
+from adapter.matrix.handlers.settings import handle_skills, handle_skill_toggle, handle_text_setting
+
+
+@pytest.fixture
+def store():
+ return InMemoryStore()
+
+
+@pytest.fixture
+def platform():
+ return MockPlatformClient()
+
+
+@pytest.fixture
+def dispatcher(platform, store):
+ d = EventDispatcher(
+ platform=platform,
+ chat_mgr=ChatManager(platform, store),
+ auth_mgr=AuthManager(platform, store),
+ settings_mgr=SettingsManager(platform, store),
+ )
+ register_all(d)
+ return d
+
+
+@pytest.fixture
+def client():
+ c = AsyncMock()
+ c.room_send = AsyncMock(return_value=AsyncMock(event_id="$skills_msg"))
+ return c
+
+
+async def _setup(store, platform, uid="@alice:m.org", room_id="!s:m.org"):
+ user = await platform.get_or_create_user(uid, "matrix", "Alice")
+ await set_user_meta(store, uid, {
+ "platform_user_id": user.user_id, "display_name": "Alice",
+ "space_id": None, "settings_room_id": room_id, "next_chat_index": 2,
+ })
+ await set_room_meta(store, room_id, {
+ "room_type": "settings", "chat_id": None,
+ "display_name": "Настройки", "matrix_user_id": uid,
+ })
+ await set_room_state(store, room_id, "settings_active")
+ await AuthManager(platform, store).confirm(uid)
+
+
+async def test_skills_sends_list(client, store, platform, dispatcher):
+ await _setup(store, platform)
+ await handle_skills(client, "!s:m.org", "@alice:m.org", store, platform, dispatcher)
+ body = client.room_send.call_args[0][2]["body"]
+ assert "web-search" in body
+ assert "Реакция" in body
+
+
+async def test_skills_stores_event_id(client, store, platform, dispatcher):
+ await _setup(store, platform)
+ await handle_skills(client, "!s:m.org", "@alice:m.org", store, platform, dispatcher)
+ stored = await store.get("matrix_skills_msg:!s:m.org")
+ assert stored is not None
+ assert stored["event_id"] == "$skills_msg"
+
+
+async def test_skill_toggle_edits_message(client, store, platform, dispatcher):
+ await _setup(store, platform)
+ await store.set("matrix_skills_msg:!s:m.org", {"event_id": "$skills_msg"})
+ from types import SimpleNamespace
+ reaction = SimpleNamespace(sender="@alice:m.org", key="1️⃣",
+ reacted_to_id="$skills_msg", event_id="$r1")
+ await handle_skill_toggle(client, "!s:m.org", reaction, store, platform, dispatcher)
+ content = client.room_send.call_args[0][2]
+ assert content.get("m.relates_to", {}).get("rel_type") == "m.replace"
+
+
+async def test_whoami_contains_user_id(client, store, platform, dispatcher):
+ await _setup(store, platform)
+ await handle_text_setting(client, "!s:m.org", "@alice:m.org", "whoami", [], store, platform)
+ body = client.room_send.call_args[0][2]["body"]
+ assert "@alice:m.org" in body
+
+
+async def test_status_response(client, store, platform, dispatcher):
+ await _setup(store, platform)
+ await handle_text_setting(client, "!s:m.org", "@alice:m.org", "status", [], store, platform)
+ body = client.room_send.call_args[0][2]["body"]
+ assert "Статус" in body
+
+
+async def test_plan_shows_tokens(client, store, platform, dispatcher):
+ await _setup(store, platform)
+ await handle_text_setting(client, "!s:m.org", "@alice:m.org", "plan", [], store, platform)
+ body = client.room_send.call_args[0][2]["body"]
+ assert "Beta" in body
+ assert "/" in body # "0 / 1000"
+```
+
+- [ ] **Step 2: Run — expect ImportError**
+
+```bash
+pytest tests/adapter/matrix/test_settings_handler.py -v
+```
+
+- [ ] **Step 3: Implement handlers/settings.py**
+
+```python
+# adapter/matrix/handlers/settings.py
+from __future__ import annotations
+import structlog
+from adapter.matrix.converter import SKILL_REACTIONS
+from adapter.matrix.reactions import build_skills_text, edit_message
+from adapter.matrix.store import get_room_meta, get_user_meta
+from core.protocol import SettingsAction
+from sdk.interface import PlatformClient
+
+logger = structlog.get_logger(__name__)
+
+_SKILL_NAMES_ORDER = ["web-search", "fetch-url", "email", "browser",
+ "image-gen", "video-gen", "files", "calendar"]
+
+
+async def handle_skills(
+ client, room_id: str, matrix_user_id: str, store, platform: PlatformClient, dispatcher,
+) -> None:
+ """Send skills list and store its event_id for later m.replace edits."""
+ user_meta = await get_user_meta(store, matrix_user_id) or {}
+ platform_user_id = user_meta.get("platform_user_id", matrix_user_id)
+ settings = await platform.get_settings(platform_user_id)
+ body = build_skills_text(settings)
+ response = await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body})
+ event_id = getattr(response, "event_id", None)
+ if event_id:
+ await store.set(f"matrix_skills_msg:{room_id}", {"event_id": event_id})
+
+
+async def handle_skill_toggle(
+ client, room_id: str, reaction_event, store, platform: PlatformClient, dispatcher,
+) -> None:
+ """Toggle a skill based on numbered reaction, then edit the skills message."""
+ key = reaction_event.key
+ if key not in SKILL_REACTIONS:
+ return
+ skill_index = SKILL_REACTIONS.index(key)
+ if skill_index >= len(_SKILL_NAMES_ORDER):
+ return
+
+ skill_name = _SKILL_NAMES_ORDER[skill_index]
+ room_meta = await get_room_meta(store, room_id)
+ if room_meta is None:
+ return
+
+ matrix_user_id = room_meta["matrix_user_id"]
+ user_meta = await get_user_meta(store, matrix_user_id) or {}
+ platform_user_id = user_meta.get("platform_user_id", matrix_user_id)
+
+ settings = await platform.get_settings(platform_user_id)
+ current = settings.skills.get(skill_name, False)
+ action = SettingsAction(action="toggle_skill",
+ payload={"skill": skill_name, "enabled": not current})
+ await platform.update_settings(platform_user_id, action)
+
+ updated = await platform.get_settings(platform_user_id)
+ new_body = build_skills_text(updated)
+
+ msg_data = await store.get(f"matrix_skills_msg:{room_id}")
+ if msg_data:
+ await edit_message(client, room_id, msg_data["event_id"], new_body)
+ else:
+ await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": new_body})
+
+
+async def handle_text_setting(
+ client, room_id: str, matrix_user_id: str,
+ command: str, args: list[str], store, platform: PlatformClient,
+) -> None:
+ """Handle !connectors, !soul, !safety, !plan, !status, !whoami."""
+ user_meta = await get_user_meta(store, matrix_user_id) or {}
+ platform_user_id = user_meta.get("platform_user_id", matrix_user_id)
+
+ if command == "whoami":
+ name = user_meta.get("display_name") or matrix_user_id
+ body = f"Аккаунт: {matrix_user_id}\nПлатформа: {platform_user_id}\nИмя: {name}"
+
+ elif command == "status":
+ body = f"Статус платформы: ✅ доступна\nАккаунт: {matrix_user_id}"
+
+ elif command == "plan":
+ settings = await platform.get_settings(platform_user_id)
+ plan = settings.plan
+ name_plan = plan.get("name", "Beta")
+ used = plan.get("tokens_used", 0)
+ limit = plan.get("tokens_limit", 1000)
+ pct = used * 10 // limit if limit else 0
+ bar = "━" * pct + "░" * (10 - pct)
+ body = f"Подписка: {name_plan}\nТокены: {used} / {limit}\n{bar} {used * 100 // limit if limit else 0}%"
+
+ elif command == "soul":
+ if len(args) >= 2:
+ field, value = args[0], " ".join(args[1:])
+ await platform.update_settings(platform_user_id,
+ SettingsAction(action="set_soul",
+ payload={"field": field, "value": value}))
+ body = f"✅ soul.{field} = «{value}»"
+ else:
+ settings = await platform.get_settings(platform_user_id)
+ lines = [f"{k}: {v}" for k, v in settings.soul.items()] if settings.soul else ["(пусто)"]
+ body = "Soul:\n" + "\n".join(lines)
+
+ elif command == "safety":
+ if args and args[0] in ("on", "off"):
+ enabled = args[0] == "on"
+ trigger = " ".join(args[1:])
+ await platform.update_settings(platform_user_id,
+ SettingsAction(action="set_safety",
+ payload={"trigger": trigger, "enabled": enabled}))
+ body = f"✅ safety.{trigger} = {'включено' if enabled else 'выключено'}"
+ else:
+ settings = await platform.get_settings(platform_user_id)
+ lines = [f"{'✅' if v else '❌'} {k}" for k, v in settings.safety.items()]
+ body = "Безопасность:\n" + ("\n".join(lines) if lines else "(пусто)")
+
+ elif command == "connectors":
+ settings = await platform.get_settings(platform_user_id)
+ if settings.connectors:
+ lines = [f"✅ {k}" for k in settings.connectors]
+ body = "Коннекторы:\n" + "\n".join(lines)
+ else:
+ body = "Коннекторы:\n❌ Нет подключённых сервисов\n\n!connect gmail — подключить Gmail"
+
+ else:
+ body = f"Неизвестная команда: !{command}"
+
+ await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body})
+```
+
+- [ ] **Step 4: Run — expect all PASS**
+
+```bash
+pytest tests/adapter/matrix/test_settings_handler.py -v
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/handlers/settings.py tests/adapter/matrix/test_settings_handler.py
+git commit -m "feat(matrix): settings handler — !skills m.replace + commands"
+```
+
+---
+
+### Task 8: Bot entry point — sync loop + event routing
+
+**Files:**
+- Create: `adapter/matrix/bot.py`
+- Create: `tests/adapter/matrix/test_bot.py`
+
+- [ ] **Step 1: Write failing tests**
+
+```python
+# tests/adapter/matrix/test_bot.py
+import pytest
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+from core.store import InMemoryStore
+from sdk.mock import MockPlatformClient
+from adapter.matrix.bot import create_dispatcher, route_message_event, route_reaction_event
+from adapter.matrix.store import set_room_meta, set_room_state, set_user_meta
+from core.auth import AuthManager
+from core.handler import EventDispatcher
+
+
+@pytest.fixture
+def store():
+ return InMemoryStore()
+
+
+@pytest.fixture
+def platform():
+ return MockPlatformClient()
+
+
+@pytest.fixture
+def dispatcher(platform, store):
+ return create_dispatcher(platform, store)
+
+
+@pytest.fixture
+def client():
+ c = AsyncMock()
+ c.user_id = "@bot:m.org"
+ c.room_create = AsyncMock(return_value=AsyncMock(room_id="!new:m.org"))
+ c.room_invite = AsyncMock()
+ c.room_put_state = AsyncMock()
+ return c
+
+
+async def _setup(store, platform, room_id="!dm:m.org", uid="@alice:m.org"):
+ user = await platform.get_or_create_user(uid, "matrix", "Alice")
+ await set_user_meta(store, uid, {
+ "platform_user_id": user.user_id, "display_name": "Alice",
+ "space_id": None, "settings_room_id": None, "next_chat_index": 2,
+ })
+ await set_room_meta(store, room_id, {
+ "room_type": "chat", "chat_id": "C1",
+ "display_name": "Чат 1", "matrix_user_id": uid,
+ })
+ await set_room_state(store, room_id, "idle")
+ await AuthManager(platform, store).confirm(uid)
+
+
+async def test_create_dispatcher_returns_event_dispatcher(platform, store):
+ d = create_dispatcher(platform, store)
+ assert isinstance(d, EventDispatcher)
+
+
+async def test_route_text_message(client, store, platform, dispatcher):
+ await _setup(store, platform)
+ event = SimpleNamespace(sender="@alice:m.org", body="Hello", event_id="$e1",
+ msgtype="m.text", replyto_event_id=None)
+ room = SimpleNamespace(room_id="!dm:m.org")
+ await route_message_event(client, room, event, store, platform, dispatcher)
+ client.room_send.assert_called()
+ body_calls = [str(c) for c in client.room_send.call_args_list]
+ assert any("[MOCK]" in c for c in body_calls)
+
+
+async def test_route_new_command(client, store, platform, dispatcher):
+ await _setup(store, platform)
+ event = SimpleNamespace(sender="@alice:m.org", body="!new Test", event_id="$e2",
+ msgtype="m.text", replyto_event_id=None)
+ room = SimpleNamespace(room_id="!dm:m.org")
+ await route_message_event(client, room, event, store, platform, dispatcher)
+ client.room_create.assert_called()
+
+
+async def test_route_skills_command(client, store, platform, dispatcher):
+ await _setup(store, platform)
+ event = SimpleNamespace(sender="@alice:m.org", body="!skills", event_id="$e3",
+ msgtype="m.text", replyto_event_id=None)
+ room = SimpleNamespace(room_id="!dm:m.org")
+ await route_message_event(client, room, event, store, platform, dispatcher)
+ body = client.room_send.call_args[0][2]["body"]
+ assert "web-search" in body
+
+
+async def test_bot_ignores_own_messages(client, store, platform, dispatcher):
+ await _setup(store, platform)
+ event = SimpleNamespace(sender="@bot:m.org", body="Hello", event_id="$e4",
+ msgtype="m.text", replyto_event_id=None)
+ room = SimpleNamespace(room_id="!dm:m.org")
+ await route_message_event(client, room, event, store, platform, dispatcher)
+ client.room_send.assert_not_called()
+
+
+async def test_route_confirm_reaction(client, store, platform, dispatcher):
+ await _setup(store, platform)
+ await set_room_state(store, "!dm:m.org", "confirm_pending")
+ event = SimpleNamespace(sender="@alice:m.org", key="👍",
+ reacted_to_id="$orig", event_id="$r1",
+ source={"content": {"m.relates_to": {"key": "👍", "event_id": "$orig"}}})
+ room = SimpleNamespace(room_id="!dm:m.org")
+ await route_reaction_event(client, room, event, store, platform, dispatcher)
+ client.room_send.assert_called()
+```
+
+- [ ] **Step 2: Run — expect ImportError**
+
+```bash
+pytest tests/adapter/matrix/test_bot.py -v
+```
+
+- [ ] **Step 3: Implement bot.py**
+
+```python
+# adapter/matrix/bot.py
+from __future__ import annotations
+import os
+import structlog
+from nio import AsyncClient, InviteMemberEvent, RoomMessageText, UnknownEvent
+from adapter.matrix.converter import CONFIRM_REACTIONS, SKILL_REACTIONS
+from adapter.matrix.handlers.auth import handle_invite
+from adapter.matrix.handlers.chat import handle_list_chats, handle_message, handle_new_chat
+from adapter.matrix.handlers.confirm import handle_confirm_callback
+from adapter.matrix.handlers.settings import handle_skill_toggle, handle_skills, handle_text_setting
+from adapter.matrix.store import get_room_meta, get_room_state
+from core.auth import AuthManager
+from core.chat import ChatManager
+from core.handler import EventDispatcher
+from core.handlers import register_all
+from core.settings import SettingsManager
+from core.store import SQLiteStore
+from sdk.interface import PlatformClient
+from sdk.mock import MockPlatformClient
+
+logger = structlog.get_logger(__name__)
+
+_SETTINGS_COMMANDS = {"connectors", "soul", "safety", "plan", "status", "whoami"}
+
+
+def create_dispatcher(platform: PlatformClient, store) -> EventDispatcher:
+ d = EventDispatcher(
+ platform=platform,
+ chat_mgr=ChatManager(platform, store),
+ auth_mgr=AuthManager(platform, store),
+ settings_mgr=SettingsManager(platform, store),
+ )
+ register_all(d)
+ return d
+
+
+async def route_message_event(client, room, event, store, platform, dispatcher) -> None:
+ room_id = room.room_id
+ sender = event.sender
+ if sender == client.user_id:
+ return
+
+ room_meta = await get_room_meta(store, room_id)
+ if room_meta is None:
+ return
+
+ body: str = event.body or ""
+ state = await get_room_state(store, room_id)
+
+ if state == "confirm_pending" and body.startswith("!") and body[1:].split()[0] in ("yes", "no"):
+ await handle_confirm_callback(client, room_id, event, store, platform, dispatcher, is_reaction=False)
+ return
+
+ if body.startswith("!"):
+ parts = body[1:].split(maxsplit=1)
+ cmd = parts[0].lower()
+ args = parts[1].split() if len(parts) > 1 else []
+
+ if cmd == "new":
+ await handle_new_chat(client, room_id, event, store, platform, dispatcher)
+ elif cmd == "chats":
+ await handle_list_chats(client, room_id, sender, store)
+ elif cmd == "skills":
+ await handle_skills(client, room_id, sender, store, platform, dispatcher)
+ elif cmd in _SETTINGS_COMMANDS:
+ await handle_text_setting(client, room_id, sender, cmd, args, store, platform)
+ else:
+ # Unknown command — treat as regular message
+ await handle_message(client, room_id, event, store, platform, dispatcher)
+ else:
+ await handle_message(client, room_id, event, store, platform, dispatcher)
+
+
+async def route_reaction_event(client, room, event, store, platform, dispatcher) -> None:
+ room_id = room.room_id
+ sender = getattr(event, "sender", None)
+ if sender == client.user_id:
+ return
+
+ # nio may give us a ReactionEvent or UnknownEvent; normalise key access
+ key = getattr(event, "key", None)
+ reacted_to_id = getattr(event, "reacted_to_id", None)
+ if key is None:
+ relates = event.source.get("content", {}).get("m.relates_to", {})
+ key = relates.get("key", "")
+ reacted_to_id = relates.get("event_id", "")
+
+ from types import SimpleNamespace
+ norm = SimpleNamespace(sender=sender, key=key, reacted_to_id=reacted_to_id,
+ event_id=event.event_id)
+
+ state = await get_room_state(store, room_id)
+ if state == "confirm_pending" and key in CONFIRM_REACTIONS:
+ await handle_confirm_callback(client, room_id, norm, store, platform, dispatcher, is_reaction=True)
+ elif key in SKILL_REACTIONS:
+ await handle_skill_toggle(client, room_id, norm, store, platform, dispatcher)
+
+
+async def main() -> None:
+ homeserver = os.getenv("MATRIX_HOMESERVER", "https://matrix.org")
+ user_id = os.getenv("MATRIX_USER_ID", "@lambda-bot:matrix.org")
+ password = os.getenv("MATRIX_PASSWORD", "")
+
+ store = SQLiteStore("matrix_bot.db")
+ platform = MockPlatformClient()
+ dispatcher = create_dispatcher(platform, store)
+
+ client = AsyncClient(homeserver, user_id)
+ await client.login(password)
+ logger.info("Logged in", user_id=user_id)
+
+ async def on_message(room, event: RoomMessageText) -> None:
+ await route_message_event(client, room, event, store, platform, dispatcher)
+
+ async def on_invite(room, event: InviteMemberEvent) -> None:
+ if event.membership == "invite" and event.state_key == client.user_id:
+ display_name = getattr(event, "display_name", None)
+ await handle_invite(client, room.room_id, event.sender, store, platform, display_name)
+
+ async def on_unknown(room, event: UnknownEvent) -> None:
+ if event.type == "m.reaction":
+ await route_reaction_event(client, room, event, store, platform, dispatcher)
+
+ client.add_event_callback(on_message, RoomMessageText)
+ client.add_event_callback(on_invite, InviteMemberEvent)
+ client.add_event_callback(on_unknown, UnknownEvent)
+
+ logger.info("Starting sync loop")
+ await client.sync_forever(timeout=30000)
+
+
+if __name__ == "__main__":
+ import asyncio
+ asyncio.run(main())
+```
+
+- [ ] **Step 4: Run matrix tests**
+
+```bash
+pytest tests/adapter/matrix/ -v
+```
+Expected: all PASS.
+
+- [ ] **Step 5: Run full suite — verify no regressions**
+
+```bash
+pytest tests/ -v
+```
+Expected: all tests PASS including pre-existing `tests/core/` and `tests/platform/`.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add adapter/matrix/bot.py tests/adapter/matrix/test_bot.py
+git commit -m "feat(matrix): bot entry point — sync loop and event routing"
+```
diff --git a/docs/superpowers/plans/2026-04-01-telegram-forum-redesign.md b/docs/superpowers/plans/2026-04-01-telegram-forum-redesign.md
new file mode 100644
index 0000000..3592485
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-01-telegram-forum-redesign.md
@@ -0,0 +1,1308 @@
+# Telegram Forum Redesign Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Rewrite the Telegram adapter to use Bot API 9.3 Threaded Mode — private chat becomes a forum, each topic is an isolated agent context, no supergroup required.
+
+**Architecture:** New branch `feat/telegram-forum` from `main`. Cherry-pick `keyboards/settings.py` and `keyboards/confirm.py` from `feat/telegram-adapter`. Everything else is written from scratch using `(user_id, thread_id)` as the context key, `core/store.py` for state (no aiogram FSM for topic routing), and `sdk/interface.py`'s `stream_message()` for streaming responses.
+
+**Tech Stack:** Python 3.11+, aiogram 3.4+, SQLite (via stdlib `sqlite3`), pytest + pytest-asyncio (asyncio_mode=auto), `sdk.mock.MockPlatformClient` as platform stub.
+
+**Spec:** `docs/superpowers/specs/2026-04-01-telegram-forum-redesign.md`
+
+---
+
+## File Map
+
+| File | Action | Notes |
+|------|--------|-------|
+| `adapter/telegram/db.py` | Rewrite | New schema: `chats(user_id, thread_id PK, ...)` |
+| `adapter/telegram/converter.py` | Rewrite | context_key = `(user_id, thread_id)`, keep `_extract_attachments` |
+| `adapter/telegram/handlers/start.py` | New | `/start` — create first topic, health-check existing ones |
+| `adapter/telegram/handlers/topic_events.py` | New | `forum_topic_created / edited / closed` |
+| `adapter/telegram/handlers/commands.py` | New | `/new`, `/archive`, `/rename`, `/settings` |
+| `adapter/telegram/handlers/message.py` | New | Incoming messages with streaming |
+| `adapter/telegram/handlers/settings.py` | Cherry-pick + adapt | Drop FSM state dependency for topic routing; keep SettingsState for soul modal |
+| `adapter/telegram/keyboards/settings.py` | Cherry-pick | No changes needed |
+| `adapter/telegram/keyboards/confirm.py` | Cherry-pick | No changes needed |
+| `adapter/telegram/states.py` | Minimal | Only `SettingsState` (soul editing modal), no topic FSM |
+| `adapter/telegram/bot.py` | Rewrite | New router list, same middleware pattern |
+| `adapter/telegram/__init__.py` | Keep | No changes |
+| `tests/adapter/test_forum_db.py` | Rewrite | Tests for new schema |
+| `tests/adapter/telegram/test_converter.py` | New | |
+| `tests/adapter/telegram/test_topic_events.py` | New | |
+| `tests/adapter/telegram/test_commands.py` | New | |
+
+**Delete from `feat/telegram-adapter` (do not carry over):**
+- `adapter/telegram/handlers/forum.py` — supergroup onboarding
+- `adapter/telegram/handlers/chat.py` — chat switching
+- `adapter/telegram/handlers/auth.py` — auth flow
+- `adapter/telegram/handlers/confirm.py` — confirm modal
+- `adapter/telegram/keyboards/chat.py`
+- `adapter/telegram/keyboards/forum.py`
+
+---
+
+## Task 0: Create Branch and Cherry-Pick Keyboards
+
+**Files:**
+- Create branch: `feat/telegram-forum`
+- Cherry-pick: `adapter/telegram/keyboards/settings.py`
+- Cherry-pick: `adapter/telegram/keyboards/confirm.py`
+
+- [ ] **Step 1: Create new branch from main**
+
+```bash
+git checkout main
+git checkout -b feat/telegram-forum
+```
+
+- [ ] **Step 2: Copy keyboards from feat/telegram-adapter**
+
+```bash
+mkdir -p adapter/telegram/keyboards
+git show feat/telegram-adapter:adapter/telegram/keyboards/__init__.py > adapter/telegram/keyboards/__init__.py
+git show feat/telegram-adapter:adapter/telegram/keyboards/settings.py > adapter/telegram/keyboards/settings.py
+git show feat/telegram-adapter:adapter/telegram/keyboards/confirm.py > adapter/telegram/keyboards/confirm.py
+```
+
+- [ ] **Step 3: Create package stubs**
+
+```bash
+mkdir -p adapter/telegram/handlers
+touch adapter/__init__.py
+touch adapter/telegram/__init__.py
+touch adapter/telegram/handlers/__init__.py
+```
+
+- [ ] **Step 4: Verify keyboards import cleanly**
+
+```bash
+python -c "from adapter.telegram.keyboards.settings import settings_main_keyboard; print('ok')"
+```
+
+Expected: `ok`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/
+git commit -m "chore: init feat/telegram-forum, cherry-pick keyboards"
+```
+
+---
+
+## Task 1: Database Layer
+
+**Files:**
+- Create: `adapter/telegram/db.py`
+- Rewrite: `tests/adapter/test_forum_db.py`
+
+- [ ] **Step 1: Write failing tests**
+
+Write `tests/adapter/test_forum_db.py`:
+
+```python
+from __future__ import annotations
+
+import importlib
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def fresh_db(tmp_path, monkeypatch):
+ monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db"))
+ import adapter.telegram.db as db_mod
+ importlib.reload(db_mod)
+ db_mod.init_db()
+ return db_mod
+
+
+def test_create_and_get_chat(fresh_db):
+ db = fresh_db
+ db.create_chat(user_id=1, thread_id=100, chat_name="Чат #1")
+ chat = db.get_chat(user_id=1, thread_id=100)
+ assert chat is not None
+ assert chat["chat_name"] == "Чат #1"
+ assert chat["archived_at"] is None
+
+
+def test_get_chat_missing(fresh_db):
+ assert fresh_db.get_chat(user_id=1, thread_id=999) is None
+
+
+def test_archive_chat(fresh_db):
+ db = fresh_db
+ db.create_chat(1, 100, "Чат #1")
+ db.archive_chat(1, 100)
+ chat = db.get_chat(1, 100)
+ assert chat["archived_at"] is not None
+
+
+def test_rename_chat(fresh_db):
+ db = fresh_db
+ db.create_chat(1, 100, "Чат #1")
+ db.rename_chat(1, 100, "Новое имя")
+ assert db.get_chat(1, 100)["chat_name"] == "Новое имя"
+
+
+def test_get_active_chats(fresh_db):
+ db = fresh_db
+ db.create_chat(1, 100, "Чат #1")
+ db.create_chat(1, 200, "Чат #2")
+ db.archive_chat(1, 100)
+ chats = db.get_active_chats(1)
+ assert len(chats) == 1
+ assert chats[0]["thread_id"] == 200
+
+
+def test_display_number(fresh_db):
+ db = fresh_db
+ db.create_chat(1, 100, "Чат #1")
+ db.create_chat(1, 200, "Чат #2")
+ db.create_chat(1, 300, "Чат #3")
+ assert db.get_display_number(1, 100) == 1
+ assert db.get_display_number(1, 200) == 2
+ assert db.get_display_number(1, 300) == 3
+
+
+def test_count_active_chats(fresh_db):
+ db = fresh_db
+ db.create_chat(1, 100, "Чат #1")
+ db.create_chat(1, 200, "Чат #2")
+ db.archive_chat(1, 100)
+ assert db.count_active_chats(1) == 1
+
+
+def test_different_users_isolated(fresh_db):
+ db = fresh_db
+ db.create_chat(1, 100, "Чат #1")
+ db.create_chat(2, 100, "Чат #1") # same thread_id, different user
+ assert db.get_chat(1, 100)["chat_name"] == "Чат #1"
+ assert db.get_chat(2, 100)["chat_name"] == "Чат #1"
+ db.archive_chat(1, 100)
+ assert db.get_chat(1, 100)["archived_at"] is not None
+ assert db.get_chat(2, 100)["archived_at"] is None
+```
+
+- [ ] **Step 2: Run tests — verify they fail**
+
+```bash
+pytest tests/adapter/test_forum_db.py -v
+```
+
+Expected: `ModuleNotFoundError` or `AttributeError` (db.py doesn't exist yet)
+
+- [ ] **Step 3: Implement db.py**
+
+Create `adapter/telegram/db.py`:
+
+```python
+from __future__ import annotations
+
+import os
+import sqlite3
+from contextlib import contextmanager
+
+DB_PATH = os.environ.get("DB_PATH", "lambda_bot.db")
+
+
+@contextmanager
+def _conn():
+ con = sqlite3.connect(DB_PATH)
+ con.row_factory = sqlite3.Row
+ try:
+ yield con
+ con.commit()
+ finally:
+ con.close()
+
+
+def init_db() -> None:
+ with _conn() as con:
+ con.executescript("""
+ CREATE TABLE IF NOT EXISTS chats (
+ user_id INTEGER NOT NULL,
+ thread_id INTEGER NOT NULL,
+ chat_name TEXT NOT NULL DEFAULT 'Чат #1',
+ archived_at DATETIME,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (user_id, thread_id)
+ );
+ """)
+
+
+def create_chat(user_id: int, thread_id: int, chat_name: str) -> None:
+ with _conn() as con:
+ con.execute(
+ "INSERT OR IGNORE INTO chats (user_id, thread_id, chat_name) VALUES (?, ?, ?)",
+ (user_id, thread_id, chat_name),
+ )
+
+
+def get_chat(user_id: int, thread_id: int) -> dict | None:
+ with _conn() as con:
+ row = con.execute(
+ "SELECT * FROM chats WHERE user_id = ? AND thread_id = ?",
+ (user_id, thread_id),
+ ).fetchone()
+ return dict(row) if row else None
+
+
+def get_active_chats(user_id: int) -> list[dict]:
+ with _conn() as con:
+ rows = con.execute(
+ "SELECT * FROM chats WHERE user_id = ? AND archived_at IS NULL "
+ "ORDER BY created_at ASC",
+ (user_id,),
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+
+def count_active_chats(user_id: int) -> int:
+ with _conn() as con:
+ row = con.execute(
+ "SELECT COUNT(*) FROM chats WHERE user_id = ? AND archived_at IS NULL",
+ (user_id,),
+ ).fetchone()
+ return row[0]
+
+
+def archive_chat(user_id: int, thread_id: int) -> None:
+ with _conn() as con:
+ con.execute(
+ "UPDATE chats SET archived_at = CURRENT_TIMESTAMP "
+ "WHERE user_id = ? AND thread_id = ?",
+ (user_id, thread_id),
+ )
+
+
+def rename_chat(user_id: int, thread_id: int, new_name: str) -> None:
+ with _conn() as con:
+ con.execute(
+ "UPDATE chats SET chat_name = ? WHERE user_id = ? AND thread_id = ?",
+ (new_name, user_id, thread_id),
+ )
+
+
+def get_display_number(user_id: int, thread_id: int) -> int:
+ """Return 1-based display number for a chat (by creation order)."""
+ with _conn() as con:
+ row = con.execute(
+ """
+ SELECT rn FROM (
+ SELECT thread_id,
+ ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) AS rn
+ FROM chats
+ WHERE user_id = ?
+ ) WHERE thread_id = ?
+ """,
+ (user_id, thread_id),
+ ).fetchone()
+ return row[0] if row else 1
+```
+
+- [ ] **Step 4: Run tests — verify they pass**
+
+```bash
+pytest tests/adapter/test_forum_db.py -v
+```
+
+Expected: all 8 tests pass
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/telegram/db.py tests/adapter/test_forum_db.py
+git commit -m "feat(tg): new db schema — (user_id, thread_id) PK"
+```
+
+---
+
+## Task 2: Converter
+
+**Files:**
+- Create: `adapter/telegram/converter.py`
+- Create: `tests/adapter/telegram/test_converter.py`
+
+- [ ] **Step 1: Write failing tests**
+
+Create `tests/adapter/telegram/test_converter.py`:
+
+```python
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+from adapter.telegram.converter import from_message, format_outgoing
+from core.protocol import OutgoingMessage, OutgoingUI
+
+
+def make_message(*, text="hello", thread_id=42, user_id=1):
+ m = SimpleNamespace()
+ m.text = text
+ m.caption = None
+ m.photo = None
+ m.document = None
+ m.voice = None
+ m.message_thread_id = thread_id
+ m.from_user = SimpleNamespace(id=user_id, full_name="Alice")
+ return m
+
+
+def test_from_message_in_topic():
+ msg = make_message(thread_id=42, user_id=7)
+ result = from_message(msg)
+ assert result is not None
+ assert result.user_id == "7"
+ assert result.chat_id == "42"
+ assert result.text == "hello"
+ assert result.platform == "telegram"
+
+
+def test_from_message_in_general_returns_none():
+ msg = make_message(thread_id=None)
+ assert from_message(msg) is None
+
+
+def test_from_message_uses_caption_if_no_text():
+ msg = make_message(text=None, thread_id=10)
+ msg.caption = "caption text"
+ result = from_message(msg)
+ assert result.text == "caption text"
+
+
+def test_format_outgoing_message():
+ event = OutgoingMessage(chat_id="42", text="response")
+ assert format_outgoing(event) == "response"
+
+
+def test_format_outgoing_ui():
+ event = OutgoingUI(chat_id="42", text="choose")
+ assert format_outgoing(event) == "choose"
+```
+
+- [ ] **Step 2: Run tests — verify they fail**
+
+```bash
+pytest tests/adapter/telegram/test_converter.py -v
+```
+
+Expected: `ModuleNotFoundError`
+
+- [ ] **Step 3: Implement converter.py**
+
+Create `adapter/telegram/converter.py`:
+
+```python
+from __future__ import annotations
+
+from aiogram.types import Message
+
+from core.protocol import Attachment, IncomingMessage, OutgoingEvent, OutgoingMessage, OutgoingUI
+
+
+def from_message(message: Message) -> IncomingMessage | None:
+ """Convert aiogram Message to IncomingMessage. Returns None for General topic."""
+ thread_id = message.message_thread_id
+ if thread_id is None:
+ return None
+ return IncomingMessage(
+ user_id=str(message.from_user.id),
+ chat_id=str(thread_id),
+ text=message.text or message.caption or "",
+ attachments=_extract_attachments(message),
+ platform="telegram",
+ )
+
+
+def _extract_attachments(message: Message) -> list[Attachment]:
+ attachments: list[Attachment] = []
+ if message.photo:
+ file = message.photo[-1]
+ attachments.append(Attachment(
+ type="image",
+ url=f"tg://file/{file.file_id}",
+ mime_type="image/jpeg",
+ ))
+ if message.document:
+ attachments.append(Attachment(
+ type="document",
+ url=f"tg://file/{message.document.file_id}",
+ mime_type=message.document.mime_type or "application/octet-stream",
+ filename=message.document.file_name,
+ ))
+ if message.voice:
+ attachments.append(Attachment(
+ type="audio",
+ url=f"tg://file/{message.voice.file_id}",
+ mime_type="audio/ogg",
+ ))
+ return attachments
+
+
+def format_outgoing(event: OutgoingEvent) -> str:
+ """Extract text from an outgoing event for sending to Telegram."""
+ if isinstance(event, (OutgoingMessage, OutgoingUI)):
+ return event.text
+ return str(event)
+```
+
+- [ ] **Step 4: Run tests — verify they pass**
+
+```bash
+pytest tests/adapter/telegram/test_converter.py -v
+```
+
+Expected: all 5 tests pass
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/telegram/converter.py tests/adapter/telegram/test_converter.py
+git commit -m "feat(tg): converter — context_key=(user_id, thread_id)"
+```
+
+---
+
+## Task 3: Topic Event Handlers
+
+**Files:**
+- Create: `adapter/telegram/handlers/topic_events.py`
+- Create: `tests/adapter/telegram/test_topic_events.py`
+
+- [ ] **Step 1: Write failing tests**
+
+Create `tests/adapter/telegram/test_topic_events.py`:
+
+```python
+from __future__ import annotations
+
+import importlib
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def fresh_db(tmp_path, monkeypatch):
+ monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db"))
+ import adapter.telegram.db as db_mod
+ importlib.reload(db_mod)
+ db_mod.init_db()
+ return db_mod
+
+
+def make_service_message(*, user_id=1, thread_id=42, chat_id=1):
+ m = SimpleNamespace()
+ m.message_thread_id = thread_id
+ m.from_user = SimpleNamespace(id=user_id, full_name="Alice")
+ m.chat = SimpleNamespace(id=chat_id)
+ m.forum_topic_created = SimpleNamespace(name="Мой чат")
+ m.forum_topic_edited = SimpleNamespace(name="Новое имя")
+ m.forum_topic_closed = SimpleNamespace()
+ m.answer = AsyncMock()
+ return m
+
+
+async def test_on_topic_created_registers_chat(fresh_db, monkeypatch):
+ from adapter.telegram.handlers.topic_events import on_topic_created
+ msg = make_service_message(user_id=5, thread_id=99)
+ await on_topic_created(msg)
+ chat = fresh_db.get_chat(5, 99)
+ assert chat is not None
+ assert chat["chat_name"] == "Мой чат"
+
+
+async def test_on_topic_edited_renames_chat(fresh_db, monkeypatch):
+ from adapter.telegram.handlers.topic_events import on_topic_edited
+ fresh_db.create_chat(5, 99, "Старое имя")
+ msg = make_service_message(user_id=5, thread_id=99)
+ await on_topic_edited(msg)
+ assert fresh_db.get_chat(5, 99)["chat_name"] == "Новое имя"
+
+
+async def test_on_topic_edited_unknown_chat_is_noop(fresh_db):
+ from adapter.telegram.handlers.topic_events import on_topic_edited
+ msg = make_service_message(user_id=5, thread_id=999)
+ await on_topic_edited(msg) # should not raise
+
+
+async def test_on_topic_closed_archives_chat(fresh_db):
+ from adapter.telegram.handlers.topic_events import on_topic_closed
+ fresh_db.create_chat(5, 99, "Чат #1")
+ msg = make_service_message(user_id=5, thread_id=99)
+ await on_topic_closed(msg)
+ assert fresh_db.get_chat(5, 99)["archived_at"] is not None
+
+
+async def test_on_topic_closed_unknown_chat_is_noop(fresh_db):
+ from adapter.telegram.handlers.topic_events import on_topic_closed
+ msg = make_service_message(user_id=5, thread_id=999)
+ await on_topic_closed(msg) # should not raise
+```
+
+- [ ] **Step 2: Run tests — verify they fail**
+
+```bash
+pytest tests/adapter/telegram/test_topic_events.py -v
+```
+
+Expected: `ModuleNotFoundError`
+
+- [ ] **Step 3: Implement topic_events.py**
+
+Create `adapter/telegram/handlers/topic_events.py`:
+
+```python
+from __future__ import annotations
+
+import structlog
+from aiogram import F, Router
+from aiogram.types import Message
+
+from adapter.telegram import db
+
+logger = structlog.get_logger(__name__)
+
+router = Router(name="topic_events")
+
+
+@router.message(F.forum_topic_created)
+async def on_topic_created(message: Message) -> None:
+ """User created a topic via Telegram UI — register it as a new chat."""
+ user_id = message.from_user.id
+ thread_id = message.message_thread_id
+ name = message.forum_topic_created.name
+ db.create_chat(user_id=user_id, thread_id=thread_id, chat_name=name)
+ logger.info("topic_created", user_id=user_id, thread_id=thread_id, name=name)
+
+
+@router.message(F.forum_topic_edited)
+async def on_topic_edited(message: Message) -> None:
+ """User renamed a topic via Telegram UI — sync chat_name in DB."""
+ user_id = message.from_user.id
+ thread_id = message.message_thread_id
+ new_name = message.forum_topic_edited.name
+ existing = db.get_chat(user_id=user_id, thread_id=thread_id)
+ if existing is None:
+ return
+ db.rename_chat(user_id=user_id, thread_id=thread_id, new_name=new_name)
+ logger.info("topic_renamed", user_id=user_id, thread_id=thread_id, new_name=new_name)
+
+
+@router.message(F.forum_topic_closed)
+async def on_topic_closed(message: Message) -> None:
+ """User closed a topic via Telegram UI — auto-archive the chat."""
+ user_id = message.from_user.id
+ thread_id = message.message_thread_id
+ existing = db.get_chat(user_id=user_id, thread_id=thread_id)
+ if existing is None:
+ return
+ db.archive_chat(user_id=user_id, thread_id=thread_id)
+ logger.info("topic_closed_archived", user_id=user_id, thread_id=thread_id)
+```
+
+- [ ] **Step 4: Run tests — verify they pass**
+
+```bash
+pytest tests/adapter/telegram/test_topic_events.py -v
+```
+
+Expected: all 5 tests pass
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/telegram/handlers/topic_events.py tests/adapter/telegram/test_topic_events.py
+git commit -m "feat(tg): handle forum_topic_created/edited/closed events"
+```
+
+---
+
+## Task 4: Command Handlers
+
+**Files:**
+- Create: `adapter/telegram/handlers/commands.py`
+- Create: `tests/adapter/telegram/test_commands.py`
+
+- [ ] **Step 1: Write failing tests**
+
+Create `tests/adapter/telegram/test_commands.py`:
+
+```python
+from __future__ import annotations
+
+import importlib
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def fresh_db(tmp_path, monkeypatch):
+ monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db"))
+ import adapter.telegram.db as db_mod
+ importlib.reload(db_mod)
+ db_mod.init_db()
+ return db_mod
+
+
+def make_message(*, user_id=1, thread_id=42, chat_id=1, args=None):
+ m = SimpleNamespace()
+ m.from_user = SimpleNamespace(id=user_id, full_name="Alice")
+ m.message_thread_id = thread_id
+ m.chat = SimpleNamespace(id=chat_id)
+ m.answer = AsyncMock()
+ m.reply = AsyncMock()
+ m.bot = MagicMock()
+ m.bot.create_forum_topic = AsyncMock(
+ return_value=SimpleNamespace(message_thread_id=200)
+ )
+ m.bot.close_forum_topic = AsyncMock()
+ m.bot.edit_forum_topic = AsyncMock()
+ m.bot.send_message = AsyncMock()
+ return m
+
+
+async def test_cmd_new_creates_topic(fresh_db):
+ from adapter.telegram.handlers.commands import cmd_new
+ msg = make_message(user_id=1, thread_id=42, chat_id=100)
+ fresh_db.create_chat(1, 42, "Чат #1") # 1 existing chat
+ await cmd_new(msg)
+ msg.bot.create_forum_topic.assert_called_once()
+ call_kwargs = msg.bot.create_forum_topic.call_args
+ assert "Чат #2" in str(call_kwargs)
+ new_chat = fresh_db.get_chat(1, 200)
+ assert new_chat is not None
+ assert new_chat["chat_name"] == "Чат #2"
+
+
+async def test_cmd_archive_closes_and_archives(fresh_db):
+ from adapter.telegram.handlers.commands import cmd_archive
+ fresh_db.create_chat(1, 42, "Чат #1")
+ msg = make_message(user_id=1, thread_id=42, chat_id=100)
+ await cmd_archive(msg)
+ msg.bot.close_forum_topic.assert_called_once_with(
+ chat_id=100, message_thread_id=42
+ )
+ assert fresh_db.get_chat(1, 42)["archived_at"] is not None
+
+
+async def test_cmd_archive_unknown_topic_replies_error(fresh_db):
+ from adapter.telegram.handlers.commands import cmd_archive
+ msg = make_message(user_id=1, thread_id=999, chat_id=100)
+ await cmd_archive(msg)
+ msg.answer.assert_called_once()
+ assert "не найден" in msg.answer.call_args[0][0].lower() or \
+ "not found" in msg.answer.call_args[0][0].lower() or \
+ len(msg.answer.call_args[0][0]) > 0 # some error message
+
+
+async def test_cmd_rename_updates_db_and_topic(fresh_db):
+ from adapter.telegram.handlers.commands import cmd_rename
+ fresh_db.create_chat(1, 42, "Чат #1")
+ msg = make_message(user_id=1, thread_id=42, chat_id=100)
+ await cmd_rename(msg, new_name="Работа")
+ msg.bot.edit_forum_topic.assert_called_once_with(
+ chat_id=100, message_thread_id=42, name="Работа"
+ )
+ assert fresh_db.get_chat(1, 42)["chat_name"] == "Работа"
+```
+
+- [ ] **Step 2: Run tests — verify they fail**
+
+```bash
+pytest tests/adapter/telegram/test_commands.py -v
+```
+
+Expected: `ModuleNotFoundError`
+
+- [ ] **Step 3: Implement commands.py**
+
+Create `adapter/telegram/handlers/commands.py`:
+
+```python
+from __future__ import annotations
+
+import structlog
+from aiogram import Router
+from aiogram.filters import Command
+from aiogram.types import Message
+
+from adapter.telegram import db
+from adapter.telegram.keyboards.settings import settings_main_keyboard
+
+logger = structlog.get_logger(__name__)
+
+router = Router(name="commands")
+
+
+@router.message(Command("new"))
+async def cmd_new(message: Message) -> None:
+ """Create a new topic and register it as a new chat."""
+ user_id = message.from_user.id
+ chat_id = message.chat.id
+ n = db.count_active_chats(user_id) + 1
+ new_name = f"Чат #{n}"
+ topic = await message.bot.create_forum_topic(chat_id=chat_id, name=new_name)
+ thread_id = topic.message_thread_id
+ db.create_chat(user_id=user_id, thread_id=thread_id, chat_name=new_name)
+ await message.bot.send_message(
+ chat_id=chat_id,
+ message_thread_id=thread_id,
+ text=f"Создан {new_name}. Напиши что-нибудь.",
+ )
+ logger.info("cmd_new", user_id=user_id, thread_id=thread_id, name=new_name)
+
+
+@router.message(Command("archive"))
+async def cmd_archive(message: Message) -> None:
+ """Archive the current topic."""
+ user_id = message.from_user.id
+ thread_id = message.message_thread_id
+ chat = db.get_chat(user_id=user_id, thread_id=thread_id)
+ if chat is None or chat["archived_at"] is not None:
+ await message.answer("Этот чат не найден или уже архивирован.")
+ return
+ await message.bot.close_forum_topic(
+ chat_id=message.chat.id, message_thread_id=thread_id
+ )
+ db.archive_chat(user_id=user_id, thread_id=thread_id)
+ logger.info("cmd_archive", user_id=user_id, thread_id=thread_id)
+
+
+@router.message(Command("rename"))
+async def cmd_rename(message: Message, new_name: str = "") -> None:
+ """Rename the current topic. Usage: /rename New Name"""
+ user_id = message.from_user.id
+ thread_id = message.message_thread_id
+ if not new_name:
+ # Parse from message text: /rename New Name
+ parts = (message.text or "").split(maxsplit=1)
+ new_name = parts[1].strip() if len(parts) > 1 else ""
+ if not new_name:
+ await message.answer("Использование: /rename Новое название")
+ return
+ chat = db.get_chat(user_id=user_id, thread_id=thread_id)
+ if chat is None:
+ await message.answer("Этот чат не найден.")
+ return
+ await message.bot.edit_forum_topic(
+ chat_id=message.chat.id,
+ message_thread_id=thread_id,
+ name=new_name[:128],
+ )
+ db.rename_chat(user_id=user_id, thread_id=thread_id, new_name=new_name[:128])
+ logger.info("cmd_rename", user_id=user_id, thread_id=thread_id, new_name=new_name)
+
+
+@router.message(Command("settings"))
+async def cmd_settings(message: Message) -> None:
+ """Open settings menu."""
+ await message.answer("⚙️ Настройки", reply_markup=settings_main_keyboard())
+```
+
+- [ ] **Step 4: Run tests — verify they pass**
+
+```bash
+pytest tests/adapter/telegram/test_commands.py -v
+```
+
+Expected: all 4 tests pass
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/telegram/handlers/commands.py tests/adapter/telegram/test_commands.py
+git commit -m "feat(tg): command handlers — /new /archive /rename /settings"
+```
+
+---
+
+## Task 5: /start Handler
+
+**Files:**
+- Create: `adapter/telegram/handlers/start.py`
+
+No separate test file — behaviour is verified via integration in Task 7. Unit testing `/start` requires heavy bot mocking; the key logic (stale topic detection) is thin enough to verify manually.
+
+- [ ] **Step 1: Implement start.py**
+
+Create `adapter/telegram/handlers/start.py`:
+
+```python
+from __future__ import annotations
+
+import structlog
+from aiogram import Router
+from aiogram.exceptions import TelegramBadRequest
+from aiogram.filters import Command, CommandStart
+from aiogram.types import Message
+
+from adapter.telegram import db
+
+logger = structlog.get_logger(__name__)
+
+router = Router(name="start")
+
+
+@router.message(CommandStart())
+async def cmd_start(message: Message) -> None:
+ """
+ Bootstrap the user's forum.
+
+ First visit: create Чат #1, hide General topic.
+ Returning visit: health-check all active topics, archive stale ones.
+ """
+ user_id = message.from_user.id
+ chat_id = message.chat.id
+
+ # Health-check existing topics — archive any that Telegram no longer knows about
+ await _check_and_prune_stale_topics(message, user_id, chat_id)
+
+ active = db.get_active_chats(user_id)
+
+ if not active:
+ # First visit or all topics were pruned — create the first one
+ try:
+ topic = await message.bot.create_forum_topic(
+ chat_id=chat_id, name="Чат #1"
+ )
+ thread_id = topic.message_thread_id
+ db.create_chat(user_id=user_id, thread_id=thread_id, chat_name="Чат #1")
+ logger.info("start_created_first_topic", user_id=user_id, thread_id=thread_id)
+ except TelegramBadRequest as e:
+ if "not modified" not in str(e).lower():
+ logger.warning("start_create_topic_failed", error=str(e))
+ await message.answer(
+ "Не удалось создать топик. Убедись, что в @BotFather включён "
+ "Threaded Mode для этого бота."
+ )
+ return
+
+ # Hide General topic so it doesn't distract
+ try:
+ await message.bot.hide_general_forum_topic(chat_id=chat_id)
+ except TelegramBadRequest:
+ pass # Not critical — may not be available in all API versions
+
+ await message.answer(
+ "Привет! Это твоё личное пространство с AI-агентом Lambda. "
+ "Каждый топик — отдельный контекст. Напиши что-нибудь."
+ )
+ else:
+ await message.answer(
+ f"Снова привет! У тебя {len(active)} активных чатов. "
+ "Напиши /new чтобы создать новый."
+ )
+
+
+async def _check_and_prune_stale_topics(
+ message: Message, user_id: int, chat_id: int
+) -> None:
+ """
+ Send typing action to each active topic.
+ If Telegram returns an error — the topic was deleted; archive it.
+ """
+ active = db.get_active_chats(user_id)
+ for chat in active:
+ thread_id = chat["thread_id"]
+ try:
+ await message.bot.send_chat_action(
+ chat_id=chat_id,
+ action="typing",
+ message_thread_id=thread_id,
+ )
+ except TelegramBadRequest:
+ db.archive_chat(user_id=user_id, thread_id=thread_id)
+ logger.info("pruned_stale_topic", user_id=user_id, thread_id=thread_id)
+```
+
+- [ ] **Step 2: Verify it imports cleanly**
+
+```bash
+python -c "from adapter.telegram.handlers.start import router; print('ok')"
+```
+
+Expected: `ok`
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add adapter/telegram/handlers/start.py
+git commit -m "feat(tg): /start handler with topic bootstrap and stale-topic pruning"
+```
+
+---
+
+## Task 6: Message Handler with Streaming
+
+**Files:**
+- Create: `adapter/telegram/handlers/message.py`
+
+- [ ] **Step 1: Implement message.py**
+
+Create `adapter/telegram/handlers/message.py`:
+
+```python
+from __future__ import annotations
+
+import asyncio
+import time
+
+import structlog
+from aiogram import F, Router
+from aiogram.exceptions import TelegramBadRequest
+from aiogram.types import Message
+
+from adapter.telegram import converter, db
+from core.handler import EventDispatcher
+
+logger = structlog.get_logger(__name__)
+
+router = Router(name="message")
+
+STREAM_EDIT_INTERVAL = 1.5 # seconds between edit_text calls
+STREAM_MIN_DELTA = 100 # minimum new chars before editing
+TELEGRAM_MAX_LEN = 4096
+
+
+@router.message(F.text & F.message_thread_id)
+async def handle_topic_message(message: Message, dispatcher: EventDispatcher) -> None:
+ """Route a text message in a topic to the platform and stream the response."""
+ user_id = message.from_user.id
+ thread_id = message.message_thread_id
+
+ chat = db.get_chat(user_id=user_id, thread_id=thread_id)
+ if chat is None or chat["archived_at"] is not None:
+ # Unregistered or archived topic — silently ignore
+ return
+
+ incoming = converter.from_message(message)
+ if incoming is None:
+ return
+
+ platform_user = await dispatcher._platform.get_or_create_user(
+ external_id=str(user_id),
+ platform="telegram",
+ display_name=message.from_user.full_name,
+ )
+
+ placeholder = await message.reply("...")
+
+ accumulated = ""
+ last_edit_time = 0.0
+ last_edit_len = 0
+
+ try:
+ async for chunk in dispatcher._platform.stream_message(
+ user_id=platform_user.user_id,
+ chat_id=str(thread_id),
+ text=incoming.text,
+ attachments=None,
+ ):
+ accumulated += chunk.delta
+ now = time.monotonic()
+ delta = len(accumulated) - last_edit_len
+ if delta >= STREAM_MIN_DELTA and (now - last_edit_time) >= STREAM_EDIT_INTERVAL:
+ await _safe_edit(placeholder, accumulated)
+ last_edit_time = now
+ last_edit_len = len(accumulated)
+
+ # Final edit with complete response
+ await _safe_edit(placeholder, accumulated or "...")
+
+ except TelegramBadRequest as e:
+ if "thread not found" in str(e).lower():
+ db.archive_chat(user_id=user_id, thread_id=thread_id)
+ logger.warning("topic_deleted_during_message", thread_id=thread_id)
+ else:
+ logger.error("telegram_error", error=str(e))
+ except Exception:
+ logger.exception("platform_error", user_id=user_id, thread_id=thread_id)
+ await _safe_edit(placeholder, "Сервис временно недоступен, попробуй позже")
+
+
+async def _safe_edit(message: Message, text: str) -> None:
+ """Edit message text, truncating to Telegram limit. Swallows 'not modified'."""
+ truncated = text[:TELEGRAM_MAX_LEN]
+ try:
+ await message.edit_text(truncated)
+ except TelegramBadRequest as e:
+ if "not modified" not in str(e).lower():
+ raise
+```
+
+- [ ] **Step 2: Verify it imports cleanly**
+
+```bash
+python -c "from adapter.telegram.handlers.message import router; print('ok')"
+```
+
+Expected: `ok`
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add adapter/telegram/handlers/message.py
+git commit -m "feat(tg): message handler with streaming via sdk.stream_message"
+```
+
+---
+
+## Task 7: Settings Handler (Cherry-Pick + Adapt)
+
+**Files:**
+- Create: `adapter/telegram/states.py`
+- Create: `adapter/telegram/handlers/settings.py`
+
+The settings handler from `feat/telegram-adapter` already works well. We adapt it to drop `db.get_or_create_tg_user` (no longer needed — platform resolves users by `str(tg_id)`) and remove topic-FSM dependency.
+
+- [ ] **Step 1: Create states.py (SettingsState only)**
+
+Create `adapter/telegram/states.py`:
+
+```python
+from __future__ import annotations
+
+from aiogram.fsm.state import State, StatesGroup
+
+
+class SettingsState(StatesGroup):
+ menu = State()
+ soul_editing = State()
+```
+
+- [ ] **Step 2: Cherry-pick settings handler**
+
+```bash
+git show feat/telegram-adapter:adapter/telegram/handlers/settings.py > adapter/telegram/handlers/settings.py
+```
+
+- [ ] **Step 3: Patch settings handler — remove get_or_create_tg_user calls**
+
+In `adapter/telegram/handlers/settings.py`, replace all blocks that call `db.get_or_create_tg_user` with a direct string cast. Find every occurrence of:
+
+```python
+from adapter.telegram import db as tgdb
+tg_id = callback.from_user.id
+tg_user = tgdb.get_or_create_tg_user(tg_id, str(tg_id), callback.from_user.full_name)
+platform_user_id = tg_user.get("platform_user_id", str(tg_id))
+```
+
+Replace with:
+
+```python
+platform_user_id = str(callback.from_user.id)
+```
+
+And for message handlers (soul editing), replace the analogous block with:
+
+```python
+platform_user_id = str(message.from_user.id)
+```
+
+Also remove the import of `ChatState` from `adapter.telegram.states` — it no longer exists:
+Find: `from adapter.telegram.states import ChatState, SettingsState`
+Replace: `from adapter.telegram.states import SettingsState`
+
+- [ ] **Step 4: Verify settings handler imports cleanly**
+
+```bash
+python -c "from adapter.telegram.handlers.settings import router; print('ok')"
+```
+
+Expected: `ok`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/telegram/states.py adapter/telegram/handlers/settings.py
+git commit -m "feat(tg): cherry-pick settings handler, drop get_or_create_tg_user"
+```
+
+---
+
+## Task 8: Wire Everything in bot.py
+
+**Files:**
+- Create: `adapter/telegram/bot.py`
+
+- [ ] **Step 1: Implement bot.py**
+
+Create `adapter/telegram/bot.py`:
+
+```python
+from __future__ import annotations
+
+import asyncio
+import os
+
+import structlog
+from aiogram import Bot, Dispatcher
+from aiogram.fsm.storage.memory import MemoryStorage
+from aiogram.types import BotCommand
+
+from adapter.telegram import db
+from adapter.telegram.handlers import commands, message, settings, start, topic_events
+from core.auth import AuthManager
+from core.chat import ChatManager
+from core.handler import EventDispatcher
+from core.settings import SettingsManager
+from core.store import InMemoryStore
+from sdk.mock import MockPlatformClient
+
+logger = structlog.get_logger(__name__)
+
+
+class PlatformMiddleware:
+ """Injects EventDispatcher (with platform inside) into every handler."""
+
+ def __init__(self, dispatcher: EventDispatcher) -> None:
+ self._dispatcher = dispatcher
+
+ async def __call__(self, handler, event, data):
+ data["dispatcher"] = self._dispatcher
+ return await handler(event, data)
+
+
+def build_event_dispatcher() -> EventDispatcher:
+ platform = MockPlatformClient()
+ store = InMemoryStore()
+ chat_mgr = ChatManager(platform, store)
+ auth_mgr = AuthManager(platform, store)
+ settings_mgr = SettingsManager(platform, store)
+ return EventDispatcher(
+ platform=platform,
+ chat_mgr=chat_mgr,
+ auth_mgr=auth_mgr,
+ settings_mgr=settings_mgr,
+ )
+
+
+async def main() -> None:
+ token = os.environ.get("BOT_TOKEN")
+ if not token:
+ raise RuntimeError("BOT_TOKEN env variable is not set")
+
+ db.init_db()
+
+ bot = Bot(token=token)
+ storage = MemoryStorage()
+ dp = Dispatcher(storage=storage)
+
+ event_dispatcher = build_event_dispatcher()
+
+ dp.message.middleware(PlatformMiddleware(event_dispatcher))
+ dp.callback_query.middleware(PlatformMiddleware(event_dispatcher))
+
+ # Register routers — order matters (most specific first)
+ dp.include_router(topic_events.router) # service messages
+ dp.include_router(start.router) # /start
+ dp.include_router(commands.router) # /new /archive /rename /settings
+ dp.include_router(settings.router) # settings callbacks + soul FSM
+ dp.include_router(message.router) # text messages in topics (last)
+
+ await bot.set_my_commands([
+ BotCommand(command="start", description="Начать / восстановить сессию"),
+ BotCommand(command="new", description="Создать новый чат"),
+ BotCommand(command="archive", description="Архивировать текущий чат"),
+ BotCommand(command="rename", description="Переименовать текущий чат"),
+ BotCommand(command="settings", description="Настройки"),
+ ])
+
+ logger.info("bot_starting")
+ await dp.start_polling(
+ bot,
+ allowed_updates=[
+ "message",
+ "callback_query",
+ ],
+ )
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+```
+
+- [ ] **Step 2: Verify full import chain**
+
+```bash
+python -c "from adapter.telegram.bot import main; print('ok')"
+```
+
+Expected: `ok`
+
+- [ ] **Step 3: Run all tests**
+
+```bash
+pytest tests/adapter/ -v
+```
+
+Expected: all tests pass, no import errors
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add adapter/telegram/bot.py
+git commit -m "feat(tg): wire forum-first adapter in bot.py"
+```
+
+---
+
+## Task 9: Final Cleanup and Module Entry Point
+
+**Files:**
+- Verify: `adapter/telegram/__init__.py`
+
+- [ ] **Step 1: Ensure `python -m adapter.telegram.bot` works**
+
+```bash
+python -m adapter.telegram.bot --help 2>&1 | head -5 || echo "needs BOT_TOKEN"
+```
+
+Expected: either `needs BOT_TOKEN` or a clean import error (not `ModuleNotFoundError`)
+
+- [ ] **Step 2: Run full test suite**
+
+```bash
+pytest tests/ -v --tb=short
+```
+
+Expected: all tests pass (including core/ and matrix/ tests from main)
+
+- [ ] **Step 3: Final commit**
+
+```bash
+git add -A
+git status # verify no unintended files
+git commit -m "feat(tg): forum-first adapter complete — threaded mode, (user_id, thread_id) context"
+```
+
+---
+
+## Self-Review Checklist
+
+Spec requirements vs tasks:
+
+| Spec requirement | Task |
+|-----------------|------|
+| `(user_id, thread_id)` PK | Task 1 |
+| `forum_topic_created` → register | Task 3 |
+| `forum_topic_edited` → sync name | Task 3 |
+| `forum_topic_closed` → auto-archive | Task 3 |
+| `/new` creates topic | Task 4 |
+| `/archive` closes + archives | Task 4 |
+| `/rename` edits topic + DB | Task 4 |
+| `/settings` global keyboard | Task 4 + Task 7 |
+| `/start` bootstrap + health-check | Task 5 |
+| Hide General topic | Task 5 |
+| Threaded Mode not enabled → explain | Task 5 |
+| Streaming via `stream_message` | Task 6 |
+| General topic messages ignored | Task 6 (thread_id None guard in converter) |
+| Stale topic auto-archive on send | Task 6 |
+| `core/store.py` for state, no FSM | All tasks (no FSMContext in message/topic handlers) |
+| platform resolves workspace | Implicit — adapter passes `str(thread_id)` as `chat_id` |
diff --git a/docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md b/docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md
new file mode 100644
index 0000000..e9a9921
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md
@@ -0,0 +1,515 @@
+# Matrix Direct-Agent Prototype Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace `MockPlatformClient` with a real Matrix-only direct-agent prototype backend while keeping Matrix adapter logic stable and preserving a clean future migration path.
+
+**Architecture:** Introduce a thin compatibility layer under `sdk/` that splits direct WebSocket agent messaging from local prototype-only control-plane state. Keep `core/` and Matrix handlers on the existing `PlatformClient` contract, and limit surface changes to runtime wiring and tests.
+
+**Tech Stack:** Python 3.11, `aiohttp` WebSocket client, `matrix-nio`, `pydantic`, `pytest`, `pytest-asyncio`
+
+---
+
+## File Structure
+
+- Create: `sdk/agent_session.py`
+ Purpose: Minimal direct WebSocket client for the real agent, including thread-key derivation, request/response parsing, and sync/stream helpers.
+
+- Create: `sdk/prototype_state.py`
+ Purpose: Local prototype-only user mapping and settings store kept behind a small API.
+
+- Create: `sdk/real.py`
+ Purpose: `PlatformClient` implementation that composes `AgentSessionClient` and `PrototypeStateStore`.
+
+- Modify: `sdk/__init__.py`
+ Purpose: export `RealPlatformClient` if useful for runtime imports.
+
+- Modify: `adapter/matrix/bot.py`
+ Purpose: runtime/backend selection and env-based configuration for mock vs real backend.
+
+- Create: `tests/platform/test_agent_session.py`
+ Purpose: transport-level tests for direct agent communication.
+
+- Create: `tests/platform/test_prototype_state.py`
+ Purpose: unit tests for local user/settings behavior.
+
+- Create: `tests/platform/test_real.py`
+ Purpose: contract tests for `RealPlatformClient`.
+
+- Modify: `tests/core/test_integration.py`
+ Purpose: prove the new platform implementation preserves core behavior.
+
+- Modify: `README.md`
+ Purpose: document backend selection and prototype limitations after code is working.
+
+---
+
+### Task 1: Add Direct Agent Session Transport
+
+**Files:**
+- Create: `sdk/agent_session.py`
+- Test: `tests/platform/test_agent_session.py`
+
+- [ ] **Step 1: Write the failing transport tests**
+
+```python
+import pytest
+
+from sdk.agent_session import AgentSessionClient, build_thread_key
+
+
+def test_build_thread_key_uses_surface_user_and_chat_id():
+ assert build_thread_key("matrix", "@alice:example.org", "C1") == "matrix:@alice:example.org:C1"
+
+
+@pytest.mark.asyncio
+async def test_send_message_collects_text_chunks_and_tokens(aiohttp_server):
+ ...
+
+
+@pytest.mark.asyncio
+async def test_stream_message_yields_incremental_chunks(aiohttp_server):
+ ...
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pytest tests/platform/test_agent_session.py -q`
+Expected: FAIL with `ModuleNotFoundError: No module named 'sdk.agent_session'`
+
+- [ ] **Step 3: Write minimal transport implementation**
+
+```python
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import AsyncIterator
+
+import aiohttp
+
+from sdk.interface import MessageChunk, MessageResponse, PlatformError
+
+
+def build_thread_key(platform: str, user_id: str, chat_id: str) -> str:
+ return f"{platform}:{user_id}:{chat_id}"
+
+
+@dataclass
+class AgentSessionConfig:
+ base_ws_url: str
+ timeout_seconds: float = 30.0
+
+
+class AgentSessionClient:
+ def __init__(self, config: AgentSessionConfig) -> None:
+ self._config = config
+
+ async def send_message(self, *, thread_key: str, text: str) -> MessageResponse:
+ chunks = []
+ tokens_used = 0
+ async for chunk in self.stream_message(thread_key=thread_key, text=text):
+ chunks.append(chunk.delta)
+ tokens_used = chunk.tokens_used or tokens_used
+ return MessageResponse(
+ message_id=thread_key,
+ response="".join(chunks),
+ tokens_used=tokens_used,
+ finished=True,
+ )
+
+ async def stream_message(self, *, thread_key: str, text: str) -> AsyncIterator[MessageChunk]:
+ url = f"{self._config.base_ws_url}?thread_id={thread_key}"
+ async with aiohttp.ClientSession() as session:
+ async with session.ws_connect(url, heartbeat=30) as ws:
+ status_msg = await ws.receive_json(timeout=self._config.timeout_seconds)
+ if status_msg.get("type") != "STATUS":
+ raise PlatformError("Agent did not send STATUS", code="AGENT_PROTOCOL_ERROR")
+
+ await ws.send_json({"type": "USER_MESSAGE", "text": text})
+
+ while True:
+ payload = await ws.receive_json(timeout=self._config.timeout_seconds)
+ msg_type = payload.get("type")
+ if msg_type == "AGENT_EVENT_TEXT_CHUNK":
+ yield MessageChunk(message_id=thread_key, delta=payload["text"], finished=False)
+ elif msg_type == "AGENT_EVENT_END":
+ yield MessageChunk(
+ message_id=thread_key,
+ delta="",
+ finished=True,
+ tokens_used=payload.get("tokens_used", 0),
+ )
+ return
+ elif msg_type == "ERROR":
+ raise PlatformError(payload.get("details", "Agent error"), code=payload.get("code", "AGENT_ERROR"))
+ else:
+ raise PlatformError(f"Unexpected agent message: {payload}", code="AGENT_PROTOCOL_ERROR")
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pytest tests/platform/test_agent_session.py -q`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add sdk/agent_session.py tests/platform/test_agent_session.py
+git commit -m "feat: add direct agent session transport"
+```
+
+---
+
+### Task 2: Add Local Prototype State For Users And Settings
+
+**Files:**
+- Create: `sdk/prototype_state.py`
+- Test: `tests/platform/test_prototype_state.py`
+
+- [ ] **Step 1: Write the failing state tests**
+
+```python
+import pytest
+
+from core.protocol import SettingsAction
+from sdk.prototype_state import PrototypeStateStore
+
+
+@pytest.mark.asyncio
+async def test_get_or_create_user_is_stable_per_surface_identity():
+ ...
+
+
+@pytest.mark.asyncio
+async def test_settings_defaults_match_existing_mock_shape():
+ ...
+
+
+@pytest.mark.asyncio
+async def test_update_settings_supports_toggle_skill_and_setters():
+ ...
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pytest tests/platform/test_prototype_state.py -q`
+Expected: FAIL with `ModuleNotFoundError: No module named 'sdk.prototype_state'`
+
+- [ ] **Step 3: Write minimal state implementation**
+
+```python
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+from sdk.interface import User, UserSettings
+
+# Defaults are defined here, not imported from sdk.mock, to keep real backend
+# isolated from the mock. Copy-paste intentional.
+DEFAULT_SKILLS: dict[str, bool] = {
+ "web-search": True,
+ "fetch-url": True,
+ "email": False,
+ "browser": False,
+ "image-gen": False,
+ "files": True,
+}
+DEFAULT_SAFETY: dict[str, bool] = {"email-send": True, "file-delete": True, "social-post": True}
+DEFAULT_SOUL: dict[str, str] = {"name": "Лямбда", "instructions": ""}
+DEFAULT_PLAN: dict = {"name": "Beta", "tokens_used": 0, "tokens_limit": 1000}
+
+
+class PrototypeStateStore:
+ def __init__(self) -> None:
+ self._users: dict[str, User] = {}
+ self._settings: dict[str, dict] = {}
+
+ async def get_or_create_user(
+ self,
+ *,
+ external_id: str,
+ platform: str,
+ display_name: str | None = None,
+ ) -> User:
+ key = f"{platform}:{external_id}"
+ existing = self._users.get(key)
+ if existing is not None:
+ return existing.model_copy(update={"is_new": False})
+
+ user = User(
+ user_id=f"usr-{platform}-{external_id}",
+ external_id=external_id,
+ platform=platform,
+ display_name=display_name,
+ created_at=datetime.now(UTC),
+ is_new=True,
+ )
+ self._users[key] = user.model_copy(update={"is_new": False})
+ return user
+
+ async def get_settings(self, user_id: str) -> UserSettings:
+ stored = self._settings.get(user_id, {})
+ return UserSettings(
+ skills={**DEFAULT_SKILLS, **stored.get("skills", {})},
+ connectors=stored.get("connectors", {}),
+ soul={**DEFAULT_SOUL, **stored.get("soul", {})},
+ safety={**DEFAULT_SAFETY, **stored.get("safety", {})},
+ plan={**DEFAULT_PLAN, **stored.get("plan", {})},
+ )
+
+ async def update_settings(self, user_id: str, action) -> None:
+ settings = self._settings.setdefault(user_id, {})
+ if action.action == "toggle_skill":
+ skills = settings.setdefault("skills", DEFAULT_SKILLS.copy())
+ skills[action.payload["skill"]] = action.payload.get("enabled", True)
+ elif action.action == "set_soul":
+ soul = settings.setdefault("soul", DEFAULT_SOUL.copy())
+ soul[action.payload["field"]] = action.payload["value"]
+ elif action.action == "set_safety":
+ safety = settings.setdefault("safety", DEFAULT_SAFETY.copy())
+ safety[action.payload["trigger"]] = action.payload.get("enabled", True)
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pytest tests/platform/test_prototype_state.py -q`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add sdk/prototype_state.py tests/platform/test_prototype_state.py
+git commit -m "feat: add prototype local state store"
+```
+
+---
+
+### Task 3: Implement RealPlatformClient Compatibility Layer
+
+**Files:**
+- Create: `sdk/real.py`
+- Modify: `sdk/__init__.py`
+- Test: `tests/platform/test_real.py`
+- Test: `tests/core/test_integration.py`
+
+- [ ] **Step 1: Write the failing compatibility tests**
+
+```python
+import pytest
+
+from core.protocol import SettingsAction
+from sdk.real import RealPlatformClient
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_get_or_create_user_uses_local_state():
+ ...
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_send_message_uses_thread_key():
+ ...
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_settings_are_local():
+ ...
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pytest tests/platform/test_real.py -q`
+Expected: FAIL with `ModuleNotFoundError: No module named 'sdk.real'`
+
+- [ ] **Step 3: Write minimal compatibility wrapper**
+
+```python
+from __future__ import annotations
+
+from typing import AsyncIterator
+
+from sdk.agent_session import AgentSessionClient, build_thread_key
+from sdk.interface import Attachment, MessageChunk, MessageResponse, PlatformClient, User, UserSettings
+from sdk.prototype_state import PrototypeStateStore
+
+
+class RealPlatformClient(PlatformClient):
+ def __init__(
+ self,
+ agent_sessions: AgentSessionClient,
+ prototype_state: PrototypeStateStore,
+ platform: str = "matrix",
+ ) -> None:
+ self._agent_sessions = agent_sessions
+ self._prototype_state = prototype_state
+ self._platform = platform # surface name used in thread key; pass explicitly for future surfaces
+
+ async def get_or_create_user(
+ self,
+ external_id: str,
+ platform: str,
+ display_name: str | None = None,
+ ) -> User:
+ return await self._prototype_state.get_or_create_user(
+ external_id=external_id,
+ platform=platform,
+ display_name=display_name,
+ )
+
+ async def send_message(
+ self,
+ user_id: str,
+ chat_id: str,
+ text: str,
+ attachments: list[Attachment] | None = None,
+ ) -> MessageResponse:
+ # user_id here is the internal id (e.g. "usr-matrix-@alice:example.org"), which is
+ # unique per user and stable — acceptable as thread identity for v1 prototype.
+ thread_key = build_thread_key(self._platform, user_id, chat_id)
+ return await self._agent_sessions.send_message(thread_key=thread_key, text=text)
+
+ async def stream_message(
+ self,
+ user_id: str,
+ chat_id: str,
+ text: str,
+ attachments: list[Attachment] | None = None,
+ ) -> AsyncIterator[MessageChunk]:
+ thread_key = build_thread_key(self._platform, user_id, chat_id)
+ async for chunk in self._agent_sessions.stream_message(thread_key=thread_key, text=text):
+ yield chunk
+
+ async def get_settings(self, user_id: str) -> UserSettings:
+ return await self._prototype_state.get_settings(user_id)
+
+ async def update_settings(self, user_id: str, action) -> None:
+ await self._prototype_state.update_settings(user_id, action)
+```
+
+- [ ] **Step 4: Run tests to verify the contract holds**
+
+Run: `pytest tests/platform/test_real.py tests/core/test_integration.py -q`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add sdk/real.py sdk/__init__.py tests/platform/test_real.py tests/core/test_integration.py
+git commit -m "feat: add real platform compatibility layer"
+```
+
+---
+
+### Task 4: Wire Matrix Runtime To Real Backend And Document Usage
+
+**Files:**
+- Modify: `adapter/matrix/bot.py`
+- Modify: `README.md`
+- Modify: `tests/adapter/matrix/test_dispatcher.py`
+
+- [ ] **Step 1: Write the failing runtime wiring tests**
+
+```python
+import os
+
+from adapter.matrix.bot import build_runtime
+from sdk.real import RealPlatformClient
+
+
+def test_build_runtime_uses_real_platform_when_matrix_backend_is_real(monkeypatch):
+ monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real")
+ monkeypatch.setenv("AGENT_WS_URL", "ws://agent.example/agent_ws/")
+ runtime = build_runtime()
+ assert isinstance(runtime.platform, RealPlatformClient)
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pytest tests/adapter/matrix/test_dispatcher.py -q`
+Expected: FAIL because runtime still always constructs `MockPlatformClient`
+
+- [ ] **Step 3: Implement backend selection and docs**
+
+```python
+# adapter/matrix/bot.py — add these imports at the top
+from sdk.agent_session import AgentSessionClient, AgentSessionConfig
+from sdk.interface import PlatformClient
+from sdk.prototype_state import PrototypeStateStore
+from sdk.real import RealPlatformClient
+
+
+def _build_platform_from_env() -> PlatformClient:
+ backend = os.environ.get("MATRIX_PLATFORM_BACKEND", "mock")
+ if backend == "real":
+ ws_url = os.environ["AGENT_WS_URL"]
+ return RealPlatformClient(
+ agent_sessions=AgentSessionClient(AgentSessionConfig(base_ws_url=ws_url)),
+ prototype_state=PrototypeStateStore(),
+ platform="matrix",
+ )
+ return MockPlatformClient()
+
+
+# Update build_runtime to use env-based selection when no platform is injected:
+def build_runtime(
+ platform: PlatformClient | None = None, # was MockPlatformClient | None
+ store: StateStore | None = None,
+ client: AsyncClient | None = None,
+) -> MatrixRuntime:
+ platform = platform or _build_platform_from_env()
+ ... # rest unchanged
+```
+
+Also update the `MatrixRuntime` dataclass field type from `MockPlatformClient` to `PlatformClient` so the type annotation matches the runtime behavior.
+
+```markdown
+# README.md
+
+Matrix prototype backend selection:
+
+- `MATRIX_PLATFORM_BACKEND=mock` uses `sdk/mock.py`
+- `MATRIX_PLATFORM_BACKEND=real` uses direct agent WebSocket integration
+- `AGENT_WS_URL=ws://host:port/agent_ws/` is required for the real backend
+
+Current real-backend limitations:
+- text chat only
+- local settings storage
+- no attachments or async task callbacks yet
+```
+
+- [ ] **Step 4: Run targeted verification**
+
+Run: `pytest tests/adapter/matrix/test_dispatcher.py tests/platform/test_agent_session.py tests/platform/test_prototype_state.py tests/platform/test_real.py -q`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/bot.py README.md tests/adapter/matrix/test_dispatcher.py
+git commit -m "feat: wire matrix runtime to real backend"
+```
+
+---
+
+## Self-Review
+
+- Spec coverage:
+ - direct-agent transport: Task 1
+ - local settings/user state: Task 2
+ - stable `PlatformClient` wrapper: Task 3
+ - Matrix runtime wiring and docs: Task 4
+- Placeholder scan: no `TBD`, `TODO`, or “implement later” steps remain in the plan.
+- Type consistency:
+ - `build_thread_key(platform, user_id, chat_id)` is used consistently.
+ - `RealPlatformClient` remains the only bot-facing implementation.
+ - local settings stay in `PrototypeStateStore`.
+
+## Execution Handoff
+
+Plan complete and saved to `docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md`. Two execution options:
+
+**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration
+
+**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints
+
+**Which approach?**
diff --git a/docs/superpowers/plans/2026-04-19-matrix-per-chat-context.md b/docs/superpowers/plans/2026-04-19-matrix-per-chat-context.md
new file mode 100644
index 0000000..ed4b80e
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-19-matrix-per-chat-context.md
@@ -0,0 +1,480 @@
+# Matrix Per-Chat Context Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Move the Matrix surface from a shared agent context to true per-room platform contexts mapped through `platform_chat_id`, including `!new`, `!branch`, lazy migration, and per-room context commands.
+
+**Architecture:** Matrix keeps owning UX chats (`C1`, `C2`, rooms, spaces). Each working room stores a `platform_chat_id` in `room_meta`, and platform-facing operations use that mapping. Legacy rooms without a mapping lazily create one on first context-aware use.
+
+**Tech Stack:** Python 3.11, Matrix nio adapter, local state store, `lambda_agent_api`, pytest
+
+---
+
+### Task 1: Add `platform_chat_id` to Matrix metadata and tests
+
+**Files:**
+- Modify: `adapter/matrix/store.py`
+- Test: `tests/adapter/matrix/test_store.py`
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+async def test_room_meta_roundtrip_with_platform_chat_id(store: InMemoryStore):
+ meta = {
+ "chat_id": "C1",
+ "matrix_user_id": "@alice:example.org",
+ "platform_chat_id": "chat-platform-1",
+ }
+ await set_room_meta(store, "!r:m.org", meta)
+ saved = await get_room_meta(store, "!r:m.org")
+ assert saved is not None
+ assert saved["platform_chat_id"] == "chat-platform-1"
+```
+
+- [ ] **Step 2: Run test to verify it fails or proves missing coverage**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py -q`
+Expected: either FAIL on missing assertion coverage or PASS after adding the new test with current generic storage behavior
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# adapter/matrix/store.py
+# No schema gate is required because room metadata is already stored as a dict.
+# Keep helpers unchanged, but add focused helper functions if they reduce repeated logic:
+
+async def get_platform_chat_id(store: StateStore, room_id: str) -> str | None:
+ meta = await get_room_meta(store, room_id)
+ return meta.get("platform_chat_id") if meta else None
+
+
+async def set_platform_chat_id(store: StateStore, room_id: str, platform_chat_id: str) -> None:
+ meta = await get_room_meta(store, room_id) or {}
+ meta["platform_chat_id"] = platform_chat_id
+ await set_room_meta(store, room_id, meta)
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py -q`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/store.py tests/adapter/matrix/test_store.py
+git commit -m "feat: add platform chat id room metadata helpers"
+```
+
+### Task 2: Extend the platform wrapper to support context-aware API calls
+
+**Files:**
+- Modify: `sdk/agent_api_wrapper.py`
+- Modify: `sdk/real.py`
+- Test: `tests/platform/test_real.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+@pytest.mark.asyncio
+async def test_real_client_send_message_uses_platform_chat_id():
+ api = FakeAgentApi()
+ client = RealPlatformClient(agent_api=api, prototype_state=PrototypeStateStore())
+
+ await client.send_message("@alice:example.org", "chat-platform-1", "hello")
+
+ assert api.sent == [("chat-platform-1", "hello")]
+
+
+@pytest.mark.asyncio
+async def test_real_client_create_and_branch_context_delegate_to_agent_api():
+ api = FakeAgentApi(create_ids=["chat-new", "chat-branch"])
+ client = RealPlatformClient(agent_api=api, prototype_state=PrototypeStateStore())
+
+ created = await client.create_chat_context("@alice:example.org")
+ branched = await client.branch_chat_context("@alice:example.org", "chat-source")
+
+ assert created == "chat-new"
+ assert branched == "chat-branch"
+ assert api.branch_calls == ["chat-source"]
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `PYTHONPATH=. uv run pytest tests/platform/test_real.py -q`
+Expected: FAIL because `RealPlatformClient` does not yet expose context-aware methods or pass a platform context id through
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# sdk/agent_api_wrapper.py
+class AgentApiWrapper(AgentApi):
+ async def create_chat(self) -> str:
+ ...
+
+ async def branch_chat(self, chat_id: str) -> str:
+ ...
+
+ async def send_message(self, chat_id: str, text: str):
+ ...
+
+ async def save_context(self, chat_id: str, name: str) -> None:
+ ...
+
+ async def load_context(self, chat_id: str, name: str) -> None:
+ ...
+
+
+# sdk/real.py
+class RealPlatformClient(PlatformClient):
+ async def create_chat_context(self, user_id: str) -> str:
+ return await self._agent_api.create_chat()
+
+ async def branch_chat_context(self, user_id: str, from_chat_id: str) -> str:
+ return await self._agent_api.branch_chat(from_chat_id)
+
+ async def save_chat_context(self, user_id: str, chat_id: str, name: str) -> None:
+ await self._agent_api.save_context(chat_id, name)
+
+ async def load_chat_context(self, user_id: str, chat_id: str, name: str) -> None:
+ await self._agent_api.load_context(chat_id, name)
+
+ async def stream_message(...):
+ async for event in self._agent_api.send_message(chat_id, text):
+ ...
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `PYTHONPATH=. uv run pytest tests/platform/test_real.py -q`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add sdk/agent_api_wrapper.py sdk/real.py tests/platform/test_real.py
+git commit -m "feat: add context-aware real platform client methods"
+```
+
+### Task 3: Create Matrix-side resolver for mapped or lazy-created platform contexts
+
+**Files:**
+- Modify: `adapter/matrix/bot.py`
+- Modify: `adapter/matrix/store.py`
+- Test: `tests/adapter/matrix/test_dispatcher.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+async def test_existing_room_without_platform_chat_id_gets_lazy_mapping_on_message():
+ runtime = build_runtime(platform=FakeRealPlatformClient(create_ids=["chat-platform-1"]))
+ await set_room_meta(runtime.store, "!room:example.org", {
+ "chat_id": "C1",
+ "matrix_user_id": "@alice:example.org",
+ })
+ client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
+ bot = MatrixBot(client, runtime)
+ room = SimpleNamespace(room_id="!room:example.org")
+ event = SimpleNamespace(sender="@alice:example.org", body="hello")
+
+ await bot.on_room_message(room, event)
+
+ meta = await get_room_meta(runtime.store, "!room:example.org")
+ assert meta["platform_chat_id"] == "chat-platform-1"
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q`
+Expected: FAIL because no lazy mapping exists
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# adapter/matrix/bot.py
+async def _ensure_platform_chat_id(self, room_id: str, user_id: str) -> str:
+ meta = await get_room_meta(self.runtime.store, room_id)
+ if meta is None:
+ raise ValueError("room metadata is required")
+ platform_chat_id = meta.get("platform_chat_id")
+ if platform_chat_id:
+ return platform_chat_id
+ if not hasattr(self.runtime.platform, "create_chat_context"):
+ raise ValueError("real platform backend required")
+ platform_chat_id = await self.runtime.platform.create_chat_context(user_id)
+ meta["platform_chat_id"] = platform_chat_id
+ await set_room_meta(self.runtime.store, room_id, meta)
+ return platform_chat_id
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/bot.py adapter/matrix/store.py tests/adapter/matrix/test_dispatcher.py
+git commit -m "feat: lazily assign platform chat ids to matrix rooms"
+```
+
+### Task 4: Make `!new` and workspace bootstrap create independent platform contexts
+
+**Files:**
+- Modify: `adapter/matrix/handlers/chat.py`
+- Modify: `adapter/matrix/handlers/auth.py`
+- Modify: `adapter/matrix/bot.py`
+- Test: `tests/adapter/matrix/test_chat_space.py`
+- Test: `tests/adapter/matrix/test_invite_space.py`
+- Test: `tests/adapter/matrix/test_dispatcher.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+async def test_new_chat_assigns_new_platform_chat_id():
+ client = SimpleNamespace(
+ room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r2:example")),
+ room_put_state=AsyncMock(),
+ room_invite=AsyncMock(),
+ )
+ platform = FakeRealPlatformClient(create_ids=["chat-platform-7"])
+ runtime = build_runtime(platform=platform, client=client)
+ await set_user_meta(runtime.store, "u1", {"space_id": "!space:example", "next_chat_index": 7})
+
+ result = await runtime.dispatcher.dispatch(
+ IncomingCommand(user_id="u1", platform="matrix", chat_id="C3", command="new", args=["Research"])
+ )
+
+ meta = await get_room_meta(runtime.store, "!r2:example")
+ assert meta["platform_chat_id"] == "chat-platform-7"
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_dispatcher.py -q`
+Expected: FAIL because new chats do not yet store a platform context id
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# adapter/matrix/handlers/chat.py
+# adapter/matrix/handlers/auth.py
+platform_chat_id = None
+if hasattr(platform, "create_chat_context"):
+ platform_chat_id = await platform.create_chat_context(event.user_id)
+
+await set_room_meta(store, room_id, {
+ "chat_id": chat_id,
+ "matrix_user_id": event.user_id,
+ "space_id": space_id,
+ "platform_chat_id": platform_chat_id,
+})
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_dispatcher.py -q`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/handlers/chat.py adapter/matrix/handlers/auth.py adapter/matrix/bot.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_dispatcher.py
+git commit -m "feat: assign platform contexts when creating matrix chats"
+```
+
+### Task 5: Make per-room save, load, and context use the mapped platform context
+
+**Files:**
+- Modify: `adapter/matrix/handlers/context_commands.py`
+- Modify: `adapter/matrix/bot.py`
+- Modify: `sdk/prototype_state.py`
+- Test: `tests/adapter/matrix/test_context_commands.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+@pytest.mark.asyncio
+async def test_save_command_uses_room_platform_chat_id():
+ platform = MatrixCommandPlatform()
+ runtime = build_runtime(platform=platform)
+ await set_room_meta(runtime.store, "!room:example.org", {
+ "chat_id": "C1",
+ "matrix_user_id": "u1",
+ "platform_chat_id": "chat-platform-1",
+ })
+ event = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="save", args=["session-a"])
+
+ result = await make_handle_save(...)(event, runtime.auth_mgr, platform, runtime.chat_mgr, runtime.settings_mgr)
+
+ assert platform.saved_calls == [("chat-platform-1", "session-a")]
+
+
+@pytest.mark.asyncio
+async def test_context_command_reports_current_room_platform_chat_id():
+ ...
+ assert "chat-platform-1" in result[0].text
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_context_commands.py -q`
+Expected: FAIL because save/load/context do not currently use room-level platform mappings
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# adapter/matrix/handlers/context_commands.py
+room_id = await _resolve_room_id(event, chat_mgr)
+meta = await get_room_meta(store, room_id)
+platform_chat_id = meta.get("platform_chat_id")
+
+await platform.save_chat_context(event.user_id, platform_chat_id, name)
+await platform.load_chat_context(event.user_id, platform_chat_id, name)
+
+# sdk/prototype_state.py
+# store current loaded session per user+platform_chat_id instead of only per user when needed for Matrix `!context`
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_context_commands.py -q`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/handlers/context_commands.py adapter/matrix/bot.py sdk/prototype_state.py tests/adapter/matrix/test_context_commands.py
+git commit -m "feat: bind matrix context commands to platform chat ids"
+```
+
+### Task 6: Add `!branch` and help-text updates
+
+**Files:**
+- Modify: `adapter/matrix/handlers/chat.py`
+- Modify: `adapter/matrix/handlers/__init__.py`
+- Modify: `adapter/matrix/handlers/settings.py`
+- Modify: `adapter/matrix/handlers/auth.py`
+- Modify: `adapter/matrix/bot.py`
+- Test: `tests/adapter/matrix/test_chat_space.py`
+- Test: `tests/adapter/matrix/test_dispatcher.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+async def test_branch_creates_new_room_with_branched_platform_chat_id():
+ client = SimpleNamespace(
+ room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r3:example")),
+ room_put_state=AsyncMock(),
+ room_invite=AsyncMock(),
+ )
+ platform = FakeRealPlatformClient(branch_ids=["chat-platform-branch"])
+ runtime = build_runtime(platform=platform, client=client)
+ await set_room_meta(runtime.store, "!current:example", {
+ "chat_id": "C2",
+ "matrix_user_id": "u1",
+ "space_id": "!space:example",
+ "platform_chat_id": "chat-platform-source",
+ })
+
+ result = await runtime.dispatcher.dispatch(
+ IncomingCommand(user_id="u1", platform="matrix", chat_id="C2", command="branch", args=["Fork"])
+ )
+
+ meta = await get_room_meta(runtime.store, "!r3:example")
+ assert meta["platform_chat_id"] == "chat-platform-branch"
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_dispatcher.py -q`
+Expected: FAIL because `branch` is not implemented
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# adapter/matrix/handlers/chat.py
+def make_handle_branch(client, store):
+ async def handle_branch(event, auth_mgr, platform, chat_mgr, settings_mgr):
+ source_room_id = ...
+ source_meta = await get_room_meta(store, source_room_id)
+ platform_chat_id = await platform.branch_chat_context(event.user_id, source_meta["platform_chat_id"])
+ ...
+ await set_room_meta(store, new_room_id, {
+ "chat_id": new_chat_id,
+ "matrix_user_id": event.user_id,
+ "space_id": space_id,
+ "platform_chat_id": platform_chat_id,
+ })
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_dispatcher.py -q`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/handlers/chat.py adapter/matrix/handlers/__init__.py adapter/matrix/handlers/settings.py adapter/matrix/handlers/auth.py adapter/matrix/bot.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_dispatcher.py
+git commit -m "feat: add matrix branch command for platform contexts"
+```
+
+### Task 7: Verify the full Matrix flow and clean up legacy assumptions
+
+**Files:**
+- Modify: `tests/platform/test_real.py`
+- Modify: `tests/adapter/matrix/test_dispatcher.py`
+- Modify: `tests/adapter/matrix/test_context_commands.py`
+- Modify: `tests/core/test_integration.py`
+
+- [ ] **Step 1: Add integration coverage for independent room contexts**
+
+```python
+@pytest.mark.asyncio
+async def test_two_rooms_send_messages_into_different_platform_contexts():
+ platform = FakeRealPlatformClient()
+ runtime = build_runtime(platform=platform)
+ await set_room_meta(runtime.store, "!r1:example", {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "chat-1"})
+ await set_room_meta(runtime.store, "!r2:example", {"chat_id": "C2", "matrix_user_id": "u1", "platform_chat_id": "chat-2"})
+ ...
+ assert platform.sent == [("chat-1", "hello"), ("chat-2", "world")]
+```
+
+- [ ] **Step 2: Run the focused verification suite**
+
+Run: `PYTHONPATH=. uv run pytest tests/platform/test_real.py tests/adapter/matrix/test_store.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py tests/core/test_integration.py -q`
+Expected: PASS
+
+- [ ] **Step 3: Run the full Matrix suite**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix -q`
+Expected: PASS
+
+- [ ] **Step 4: Inspect help text and command visibility**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q`
+Expected: PASS with `!branch` present in help and hidden commands still absent
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add tests/platform/test_real.py tests/adapter/matrix/test_store.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py tests/core/test_integration.py
+git commit -m "test: verify matrix per-chat platform context flow"
+```
+
+## Self-Review
+
+- Spec coverage:
+ - `surface_chat -> platform_chat_id` mapping is covered by Tasks 1, 3, and 4.
+ - `!new` independent contexts are covered by Task 4.
+ - `!branch` snapshot flow is covered by Task 6.
+ - per-room `!save`, `!load`, and `!context` are covered by Task 5.
+ - lazy migration for legacy rooms is covered by Task 3.
+ - verification across rooms is covered by Task 7.
+- Placeholder scan:
+ - No `TODO` or `TBD` placeholders remain.
+ - Commands and file paths are concrete.
+- Type consistency:
+ - The plan consistently uses `platform_chat_id` for stored mapping and `create_chat_context` / `branch_chat_context` / `save_chat_context` / `load_chat_context` for platform-facing methods.
diff --git a/docs/superpowers/plans/2026-04-20-matrix-shared-workspace-file-flow.md b/docs/superpowers/plans/2026-04-20-matrix-shared-workspace-file-flow.md
new file mode 100644
index 0000000..65c2018
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-20-matrix-shared-workspace-file-flow.md
@@ -0,0 +1,624 @@
+# Matrix Shared Workspace File Flow Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Move the Matrix surface to a prod-like shared-workspace file flow where incoming files are downloaded into `/workspace`, passed to `platform-agent` as relative attachment paths, and outbound `send_file` events are delivered back to the Matrix room.
+
+**Architecture:** Keep the platform contract path-based and surface-agnostic. Extend the surface attachment model with a workspace-relative path, update the real SDK bridge to forward attachments and modern agent events, then add a Matrix-specific storage helper plus a shared-volume runtime. This preserves room/context semantics while aligning file flow with upstream `platform-agent`.
+
+**Tech Stack:** Python 3.11, matrix-nio, aiohttp, Docker Compose, pytest, pytest-asyncio
+
+---
+
+## File Structure
+
+- Modify: `core/protocol.py`
+ Purpose: add a workspace-relative attachment field that future surfaces can also use.
+- Modify: `sdk/interface.py`
+ Purpose: keep the platform-side attachment shape aligned with the surface model.
+- Modify: `core/handlers/message.py`
+ Purpose: stop dropping attachments before platform dispatch.
+- Modify: `sdk/agent_api_wrapper.py`
+ Purpose: accept modern upstream agent events and modern WS route semantics.
+- Modify: `sdk/real.py`
+ Purpose: convert attachment objects into workspace-relative paths and forward them to the agent API.
+- Create: `adapter/matrix/files.py`
+ Purpose: Matrix-specific download/upload helper for shared `/workspace`.
+- Modify: `adapter/matrix/bot.py`
+ Purpose: persist incoming Matrix files into shared workspace and render outbound file events back to Matrix.
+- Modify: `tests/core/test_integration.py`
+ Purpose: prove message dispatch keeps attachments and platform send path receives them.
+- Modify: `tests/platform/test_real.py`
+ Purpose: verify attachment forwarding and outbound file events.
+- Create: `tests/adapter/matrix/test_files.py`
+ Purpose: unit coverage for Matrix workspace path building, download handling, and outbound upload behavior.
+- Modify: `tests/adapter/matrix/test_dispatcher.py`
+ Purpose: verify Matrix bot file receive/send integration.
+- Modify: `docker-compose.yml`
+ Purpose: define shared `/workspace` runtime between `matrix-bot` and `platform-agent`.
+- Modify: `README.md`
+ Purpose: document the new default runtime and file flow.
+- Modify: `.env.example`
+ Purpose: update real-backend defaults to in-compose service URLs and workspace-aware runtime.
+
+### Task 1: Preserve Attachment Metadata Through Core Message Dispatch
+
+**Files:**
+- Modify: `core/protocol.py`
+- Modify: `sdk/interface.py`
+- Modify: `core/handlers/message.py`
+- Test: `tests/core/test_dispatcher.py`
+- Test: `tests/core/test_integration.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+# tests/core/test_integration.py
+class RecordingAgentApi:
+ def __init__(self) -> None:
+ self.calls: list[tuple[str, list[str]]] = []
+ self.last_tokens_used = 0
+
+ async def send_message(self, text: str, attachments: list[str] | None = None):
+ self.calls.append((text, attachments or []))
+ yield type("Chunk", (), {"text": f"[REAL] {text}"})()
+ self.last_tokens_used = 5
+
+
+async def test_full_flow_with_real_platform_forwards_workspace_attachment(real_dispatcher):
+ dispatcher, agent_api = real_dispatcher
+
+ start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start")
+ await dispatcher.dispatch(start)
+
+ msg = IncomingMessage(
+ user_id="u1",
+ platform="matrix",
+ chat_id="C1",
+ text="Посмотри файл",
+ attachments=[
+ Attachment(
+ type="document",
+ filename="report.pdf",
+ mime_type="application/pdf",
+ workspace_path="surfaces/matrix/u1/room/inbox/report.pdf",
+ )
+ ],
+ )
+ await dispatcher.dispatch(msg)
+
+ assert agent_api.calls == [
+ ("Посмотри файл", ["surfaces/matrix/u1/room/inbox/report.pdf"])
+ ]
+```
+
+```python
+# tests/core/test_dispatcher.py
+async def test_dispatch_routes_document_before_catchall(dispatcher):
+ async def doc_handler(event, **kwargs):
+ return [OutgoingMessage(chat_id=event.chat_id, text="document")]
+
+ async def catch_all(event, **kwargs):
+ return [OutgoingMessage(chat_id=event.chat_id, text="text")]
+
+ dispatcher.register(IncomingMessage, "document", doc_handler)
+ dispatcher.register(IncomingMessage, "*", catch_all)
+
+ doc_msg = IncomingMessage(
+ user_id="u1",
+ platform="matrix",
+ chat_id="C1",
+ text="",
+ attachments=[Attachment(type="document", workspace_path="surfaces/matrix/u1/file.txt")],
+ )
+
+ assert (await dispatcher.dispatch(doc_msg))[0].text == "document"
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `PYTHONPATH=. uv run pytest tests/core/test_dispatcher.py tests/core/test_integration.py -q`
+
+Expected:
+- FAIL because `Attachment` has no `workspace_path`
+- FAIL because `handle_message(...)` still sends `attachments=[]`
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# core/protocol.py
+@dataclass
+class Attachment:
+ type: str
+ url: str | None = None
+ content: bytes | None = None
+ filename: str | None = None
+ mime_type: str | None = None
+ workspace_path: str | None = None
+```
+
+```python
+# sdk/interface.py
+class Attachment(BaseModel):
+ url: str | None = None
+ mime_type: str | None = None
+ size: int | None = None
+ filename: str | None = None
+ workspace_path: str | None = None
+```
+
+```python
+# core/handlers/message.py
+response = await platform.send_message(
+ user_id=event.user_id,
+ chat_id=event.chat_id,
+ text=event.text,
+ attachments=event.attachments,
+)
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `PYTHONPATH=. uv run pytest tests/core/test_dispatcher.py tests/core/test_integration.py -q`
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add core/protocol.py sdk/interface.py core/handlers/message.py tests/core/test_dispatcher.py tests/core/test_integration.py
+git commit -m "feat: preserve workspace attachments through message dispatch"
+```
+
+### Task 2: Upgrade the Real SDK Bridge to Modern Agent File Events
+
+**Files:**
+- Modify: `sdk/agent_api_wrapper.py`
+- Modify: `sdk/real.py`
+- Test: `tests/platform/test_real.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+# tests/platform/test_real.py
+class FakeSendFileEvent:
+ def __init__(self, path: str) -> None:
+ self.path = path
+
+
+class FakeChatAgentApi:
+ ...
+ async def send_message(self, text: str, attachments: list[str] | None = None):
+ self.calls.append((text, attachments or []))
+ midpoint = len(text) // 2
+ yield FakeChunk(text[:midpoint])
+ yield FakeChunk(text[midpoint:])
+ self.last_tokens_used = 3
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_send_message_forwards_workspace_paths():
+ agent_api = FakeAgentApiFactory()
+ client = RealPlatformClient(
+ agent_api=agent_api,
+ prototype_state=PrototypeStateStore(),
+ platform="matrix",
+ )
+
+ await client.send_message(
+ "@alice:example.org",
+ "chat-7",
+ "hello",
+ attachments=[
+ type("Attachment", (), {"workspace_path": "surfaces/matrix/alice/room/file.pdf"})()
+ ],
+ )
+
+ assert agent_api.instances["chat-7"].calls == [
+ ("hello", ["surfaces/matrix/alice/room/file.pdf"])
+ ]
+
+
+def test_agent_api_wrapper_treats_send_file_as_known_event(monkeypatch):
+ seen = []
+
+ class FakeSendFile:
+ type = "AGENT_EVENT_SEND_FILE"
+ path = "docs/result.pdf"
+
+ monkeypatch.setattr(
+ "sdk.agent_api_wrapper.ServerMessage.validate_json",
+ lambda raw: FakeSendFile(),
+ )
+
+ wrapper = AgentApiWrapper(agent_id="agent-1", base_url="https://agent.example.com", chat_id="7")
+ wrapper.callback = seen.append
+ wrapper._current_queue = None
+
+ # use the wrapper's dispatch branch directly inside _listen test harness
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `PYTHONPATH=. uv run pytest tests/platform/test_real.py -q`
+
+Expected:
+- FAIL because `RealPlatformClient` ignores attachments
+- FAIL because `AgentApiWrapper` only recognizes text/end/error/disconnect events
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# sdk/real.py
+def _attachment_paths(self, attachments) -> list[str]:
+ if not attachments:
+ return []
+ paths = []
+ for attachment in attachments:
+ path = getattr(attachment, "workspace_path", None)
+ if path:
+ paths.append(path)
+ return paths
+
+async def stream_message(...):
+ attachment_paths = self._attachment_paths(attachments)
+ ...
+ async for event in chat_api.send_message(text, attachments=attachment_paths):
+ if hasattr(event, "path"):
+ yield MessageChunk(
+ message_id=user_id,
+ delta="",
+ finished=False,
+ )
+ continue
+ yield MessageChunk(...)
+```
+
+```python
+# sdk/agent_api_wrapper.py
+from lambda_agent_api.server import (
+ MsgError,
+ MsgEventCustomUpdate,
+ MsgEventEnd,
+ MsgEventSendFile,
+ MsgEventTextChunk,
+ MsgEventToolCallChunk,
+ MsgEventToolResult,
+ MsgGracefulDisconnect,
+ ServerMessage,
+)
+
+KNOWN_STREAM_EVENTS = (
+ MsgEventTextChunk,
+ MsgEventToolCallChunk,
+ MsgEventToolResult,
+ MsgEventCustomUpdate,
+ MsgEventSendFile,
+ MsgEventEnd,
+)
+
+if isinstance(outgoing_msg, KNOWN_STREAM_EVENTS):
+ if isinstance(outgoing_msg, MsgEventEnd):
+ self.last_tokens_used = outgoing_msg.tokens_used
+ if self._current_queue:
+ await self._current_queue.put(outgoing_msg)
+ elif self.callback:
+ self.callback(outgoing_msg)
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `PYTHONPATH=. uv run pytest tests/platform/test_real.py -q`
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add sdk/agent_api_wrapper.py sdk/real.py tests/platform/test_real.py
+git commit -m "feat: support attachment paths and file events in real sdk bridge"
+```
+
+### Task 3: Implement Matrix Shared-Workspace Receive/Send File Flow
+
+**Files:**
+- Create: `adapter/matrix/files.py`
+- Modify: `adapter/matrix/bot.py`
+- Test: `tests/adapter/matrix/test_files.py`
+- Test: `tests/adapter/matrix/test_dispatcher.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+# tests/adapter/matrix/test_files.py
+from pathlib import Path
+
+import pytest
+
+from adapter.matrix.files import build_workspace_attachment_path
+
+
+def test_build_workspace_attachment_path_scopes_by_surface_user_and_room(tmp_path):
+ rel_path, abs_path = build_workspace_attachment_path(
+ workspace_root=tmp_path,
+ matrix_user_id="@alice:example.org",
+ room_id="!room:example.org",
+ filename="report.pdf",
+ timestamp="20260420-153000",
+ )
+
+ assert rel_path == "surfaces/matrix/alice_example.org/room_example.org/inbox/20260420-153000-report.pdf"
+ assert abs_path == tmp_path / rel_path
+```
+
+```python
+# tests/adapter/matrix/test_dispatcher.py
+async def test_bot_downloads_matrix_file_to_workspace_before_dispatch(tmp_path):
+ runtime = build_runtime(platform=MockPlatformClient())
+ await set_room_meta(
+ runtime.store,
+ "!chat1:example.org",
+ {
+ "chat_id": "C1",
+ "matrix_user_id": "@alice:example.org",
+ "platform_chat_id": "matrix:ctx-1",
+ },
+ )
+ client = SimpleNamespace(
+ user_id="@bot:example.org",
+ download=AsyncMock(return_value=SimpleNamespace(body=b"%PDF-1.7")),
+ )
+ bot = MatrixBot(client, runtime)
+ bot._send_all = AsyncMock()
+ runtime.dispatcher.dispatch = AsyncMock(return_value=[])
+ room = SimpleNamespace(room_id="!chat1:example.org")
+ event = SimpleNamespace(
+ sender="@alice:example.org",
+ body="Посмотри",
+ msgtype="m.file",
+ url="mxc://server/id",
+ mimetype="application/pdf",
+ replyto_event_id=None,
+ )
+
+ await bot.on_room_message(room, event)
+
+ dispatched = runtime.dispatcher.dispatch.await_args.args[0]
+ assert dispatched.attachments[0].workspace_path.endswith(".pdf")
+```
+
+```python
+# tests/adapter/matrix/test_dispatcher.py
+async def test_send_outgoing_uploads_file_attachment_to_matrix(tmp_path):
+ path = tmp_path / "result.txt"
+ path.write_text("ready")
+ client = SimpleNamespace(
+ upload=AsyncMock(return_value=(SimpleNamespace(content_uri="mxc://server/file"), {})),
+ room_send=AsyncMock(),
+ )
+
+ await send_outgoing(
+ client,
+ "!room:example.org",
+ OutgoingMessage(
+ chat_id="!room:example.org",
+ text="Файл готов",
+ attachments=[
+ Attachment(
+ type="document",
+ filename="result.txt",
+ mime_type="text/plain",
+ workspace_path=str(path),
+ )
+ ],
+ ),
+ )
+
+ client.upload.assert_awaited()
+ client.room_send.assert_awaited()
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_files.py tests/adapter/matrix/test_dispatcher.py -q`
+
+Expected:
+- FAIL because `adapter.matrix.files` does not exist
+- FAIL because Matrix bot does not persist files before dispatch
+- FAIL because `send_outgoing(...)` only sends text
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# adapter/matrix/files.py
+from __future__ import annotations
+
+from pathlib import Path
+from datetime import UTC, datetime
+import re
+
+from core.protocol import Attachment
+
+
+def _sanitize_component(value: str) -> str:
+ stripped = re.sub(r"[^A-Za-z0-9._-]+", "_", value)
+ return stripped.strip("._-") or "unknown"
+
+
+def build_workspace_attachment_path(
+ *,
+ workspace_root: Path,
+ matrix_user_id: str,
+ room_id: str,
+ filename: str,
+ timestamp: str | None = None,
+) -> tuple[str, Path]:
+ stamp = timestamp or datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
+ safe_user = _sanitize_component(matrix_user_id.lstrip("@"))
+ safe_room = _sanitize_component(room_id.lstrip("!"))
+ safe_name = _sanitize_component(filename) or "attachment.bin"
+ rel_path = Path("surfaces") / "matrix" / safe_user / safe_room / "inbox" / f"{stamp}-{safe_name}"
+ return rel_path.as_posix(), workspace_root / rel_path
+```
+
+```python
+# adapter/matrix/bot.py
+from adapter.matrix.files import download_matrix_attachments, load_workspace_attachment
+
+...
+incoming = from_room_event(event, room_id=room.room_id, chat_id=dispatch_chat_id)
+if isinstance(incoming, IncomingMessage) and incoming.attachments:
+ incoming = await self._materialize_attachments(room.room_id, sender, incoming)
+...
+
+async def _materialize_attachments(...):
+ workspace_root = Path(os.environ.get("SURFACES_WORKSPACE_DIR", "/workspace"))
+ attachments = await download_matrix_attachments(...)
+ return IncomingMessage(..., attachments=attachments, ...)
+```
+
+```python
+# adapter/matrix/bot.py
+if isinstance(event, OutgoingMessage) and event.attachments:
+ for attachment in event.attachments:
+ if attachment.workspace_path:
+ await _send_matrix_file(client, room_id, attachment)
+ if event.text:
+ await client.room_send(...)
+ return
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_files.py tests/adapter/matrix/test_dispatcher.py -q`
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/files.py adapter/matrix/bot.py tests/adapter/matrix/test_files.py tests/adapter/matrix/test_dispatcher.py
+git commit -m "feat: add matrix shared-workspace file receive and send flow"
+```
+
+### Task 4: Make Shared Workspace the Default Local Runtime
+
+**Files:**
+- Modify: `docker-compose.yml`
+- Modify: `README.md`
+- Modify: `.env.example`
+
+- [ ] **Step 1: Write the failing configuration checks**
+
+```bash
+python - <<'PY'
+from pathlib import Path
+text = Path("docker-compose.yml").read_text()
+assert "platform-agent" in text
+assert "/workspace" in text
+assert "matrix-bot" in text
+PY
+```
+
+```bash
+python - <<'PY'
+from pathlib import Path
+readme = Path("README.md").read_text()
+assert "docker compose up" in readme
+assert "/workspace" in readme
+assert "platform-agent" in readme
+PY
+```
+
+- [ ] **Step 2: Run checks to verify they fail**
+
+Run: `python - <<'PY' ... PY`
+
+Expected:
+- FAIL because root compose only defines `matrix-bot`
+- FAIL because README still documents standalone `uvicorn` launch and old WS route
+
+- [ ] **Step 3: Write minimal implementation**
+
+```yaml
+# docker-compose.yml
+services:
+ platform-agent:
+ build:
+ context: ./external/platform-agent
+ target: development
+ additional_contexts:
+ agent_api: ./external/platform-agent_api
+ env_file:
+ - ./external/platform-agent/.env
+ volumes:
+ - workspace:/workspace
+ - ./external/platform-agent/src:/app/src
+ - ./external/platform-agent_api:/agent_api
+ ports:
+ - "8000:8000"
+
+ matrix-bot:
+ build: .
+ env_file: .env
+ depends_on:
+ - platform-agent
+ volumes:
+ - workspace:/workspace
+ restart: unless-stopped
+
+volumes:
+ workspace:
+```
+
+```env
+# .env.example
+AGENT_WS_URL=ws://platform-agent:8000/v1/agent_ws/0/
+AGENT_BASE_URL=http://platform-agent:8000
+SURFACES_WORKSPACE_DIR=/workspace
+MATRIX_PLATFORM_BACKEND=real
+```
+
+```md
+# README.md
+- make the root `docker compose up` path the primary local runtime
+- describe shared `/workspace` as the file contract
+- remove the statement that real backend is text-only and has no attachments
+- replace the old standalone `uvicorn` instructions with compose-first instructions
+```
+
+- [ ] **Step 4: Run checks to verify they pass**
+
+Run: `python - <<'PY' ... PY`
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add docker-compose.yml README.md .env.example
+git commit -m "chore: make shared workspace runtime the default local setup"
+```
+
+## Self-Review
+
+- Spec coverage:
+ - shared `/workspace` runtime: Task 4
+ - incoming Matrix file persistence: Task 3
+ - attachment path propagation to agent API: Tasks 1-2
+ - outbound `send_file` flow: Tasks 2-3
+ - future-surface-friendly attachment contract: Task 1
+- Placeholder scan:
+ - no `TODO`, `TBD`, or “similar to”
+ - each task has explicit test, run, implementation, verify, commit steps
+- Type consistency:
+ - `workspace_path` is introduced in both attachment models and consumed consistently in Tasks 1-3
+ - path-based contract is always relative to `/workspace` until Matrix upload resolution step
+
+## Execution Handoff
+
+User already selected parallel subagent execution. Use subagent-driven development and split ownership like this:
+
+- Worker A: `docker-compose.yml`, `README.md`, `.env.example`
+- Worker B: `sdk/agent_api_wrapper.py`, `sdk/real.py`, `tests/platform/test_real.py`
+- Main session / later worker: `core/protocol.py`, `sdk/interface.py`, `core/handlers/message.py`, `adapter/matrix/files.py`, `adapter/matrix/bot.py`, matrix/core tests
diff --git a/docs/superpowers/plans/2026-04-20-matrix-staged-attachments.md b/docs/superpowers/plans/2026-04-20-matrix-staged-attachments.md
new file mode 100644
index 0000000..cfa8f01
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-20-matrix-staged-attachments.md
@@ -0,0 +1,555 @@
+# Matrix Staged Attachments Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add Matrix-side staged attachments so file-only events are stored per `(chat_id, user_id)`, acknowledged in chat, and committed to the agent on the next normal user message.
+
+**Architecture:** Keep the existing shared-workspace file contract unchanged and add a thin Matrix-specific staging layer above it. The Matrix adapter will store staged attachment metadata in `adapter.matrix.store`, parse short staging commands in `adapter.matrix.converter`, and orchestrate commit/remove/list behavior in `adapter.matrix.bot` before calling the existing dispatcher.
+
+**Tech Stack:** Python 3.11, matrix-nio, pytest, pytest-asyncio, in-memory state store, shared `/workspace`
+
+---
+
+## File Structure
+
+- Modify: `adapter/matrix/store.py`
+ Purpose: store staged attachment state per `(room_id, user_id)`.
+- Modify: `adapter/matrix/converter.py`
+ Purpose: parse `!list`, `!remove `, `!remove all` into explicit Matrix-side commands.
+- Modify: `adapter/matrix/bot.py`
+ Purpose: stage file-only events, emit service acknowledgments, process staging commands, and commit staged files with the next normal message.
+- Modify: `tests/adapter/matrix/test_store.py`
+ Purpose: verify staged attachment persistence, ordering, and clear/remove helpers.
+- Modify: `tests/adapter/matrix/test_converter.py`
+ Purpose: verify short staging commands parse correctly.
+- Modify: `tests/adapter/matrix/test_dispatcher.py`
+ Purpose: verify Matrix bot staging, list/remove behavior, and commit semantics.
+- Modify: `README.md`
+ Purpose: document the Matrix staging UX and short commands.
+
+### Task 1: Add Per-Chat Staged Attachment Storage
+
+**Files:**
+- Modify: `adapter/matrix/store.py`
+- Test: `tests/adapter/matrix/test_store.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+# tests/adapter/matrix/test_store.py
+from adapter.matrix.store import (
+ add_staged_attachment,
+ clear_staged_attachments,
+ get_staged_attachments,
+ remove_staged_attachment_at,
+)
+
+
+async def test_staged_attachments_roundtrip(store: InMemoryStore):
+ await add_staged_attachment(
+ store,
+ room_id="!r1:example.org",
+ user_id="@alice:example.org",
+ attachment={
+ "filename": "report.pdf",
+ "workspace_path": "surfaces/matrix/alice/r1/inbox/report.pdf",
+ "mime_type": "application/pdf",
+ },
+ )
+
+ assert await get_staged_attachments(store, "!r1:example.org", "@alice:example.org") == [
+ {
+ "filename": "report.pdf",
+ "workspace_path": "surfaces/matrix/alice/r1/inbox/report.pdf",
+ "mime_type": "application/pdf",
+ }
+ ]
+
+
+async def test_staged_attachments_are_scoped_by_room_and_user(store: InMemoryStore):
+ await add_staged_attachment(
+ store,
+ room_id="!r1:example.org",
+ user_id="@alice:example.org",
+ attachment={"filename": "a.pdf", "workspace_path": "a.pdf"},
+ )
+ await add_staged_attachment(
+ store,
+ room_id="!r2:example.org",
+ user_id="@alice:example.org",
+ attachment={"filename": "b.pdf", "workspace_path": "b.pdf"},
+ )
+ await add_staged_attachment(
+ store,
+ room_id="!r1:example.org",
+ user_id="@bob:example.org",
+ attachment={"filename": "c.pdf", "workspace_path": "c.pdf"},
+ )
+
+ assert [item["filename"] for item in await get_staged_attachments(store, "!r1:example.org", "@alice:example.org")] == ["a.pdf"]
+ assert [item["filename"] for item in await get_staged_attachments(store, "!r2:example.org", "@alice:example.org")] == ["b.pdf"]
+ assert [item["filename"] for item in await get_staged_attachments(store, "!r1:example.org", "@bob:example.org")] == ["c.pdf"]
+
+
+async def test_remove_staged_attachment_by_index(store: InMemoryStore):
+ await add_staged_attachment(store, "!r1:example.org", "@alice:example.org", {"filename": "a.pdf", "workspace_path": "a.pdf"})
+ await add_staged_attachment(store, "!r1:example.org", "@alice:example.org", {"filename": "b.pdf", "workspace_path": "b.pdf"})
+
+ removed = await remove_staged_attachment_at(store, "!r1:example.org", "@alice:example.org", 1)
+
+ assert removed["filename"] == "b.pdf"
+ assert [item["filename"] for item in await get_staged_attachments(store, "!r1:example.org", "@alice:example.org")] == ["a.pdf"]
+
+
+async def test_clear_staged_attachments(store: InMemoryStore):
+ await add_staged_attachment(store, "!r1:example.org", "@alice:example.org", {"filename": "a.pdf", "workspace_path": "a.pdf"})
+
+ await clear_staged_attachments(store, "!r1:example.org", "@alice:example.org")
+
+ assert await get_staged_attachments(store, "!r1:example.org", "@alice:example.org") == []
+```
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py -q`
+
+Expected:
+- FAIL because staged attachment helper functions do not exist yet
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# adapter/matrix/store.py
+STAGED_ATTACHMENTS_PREFIX = "matrix_staged_attachments:"
+
+
+def _staged_attachments_key(room_id: str, user_id: str) -> str:
+ return f"{STAGED_ATTACHMENTS_PREFIX}{room_id}:{user_id}"
+
+
+async def get_staged_attachments(store: StateStore, room_id: str, user_id: str) -> list[dict]:
+ return list(await store.get(_staged_attachments_key(room_id, user_id)) or [])
+
+
+async def add_staged_attachment(
+ store: StateStore,
+ room_id: str,
+ user_id: str,
+ attachment: dict,
+) -> None:
+ items = await get_staged_attachments(store, room_id, user_id)
+ items.append(attachment)
+ await store.set(_staged_attachments_key(room_id, user_id), items)
+
+
+async def remove_staged_attachment_at(
+ store: StateStore,
+ room_id: str,
+ user_id: str,
+ index: int,
+) -> dict | None:
+ items = await get_staged_attachments(store, room_id, user_id)
+ if index < 0 or index >= len(items):
+ return None
+ removed = items.pop(index)
+ await store.set(_staged_attachments_key(room_id, user_id), items)
+ return removed
+
+
+async def clear_staged_attachments(store: StateStore, room_id: str, user_id: str) -> None:
+ await store.delete(_staged_attachments_key(room_id, user_id))
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py -q`
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/store.py tests/adapter/matrix/test_store.py
+git commit -m "feat: add matrix staged attachment state"
+```
+
+### Task 2: Parse Short Staging Commands
+
+**Files:**
+- Modify: `adapter/matrix/converter.py`
+- Test: `tests/adapter/matrix/test_converter.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+# tests/adapter/matrix/test_converter.py
+async def test_list_command_maps_to_matrix_staging_command():
+ result = from_room_event(text_event("!list"), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingCommand)
+ assert result.command == "matrix_list_attachments"
+ assert result.args == []
+
+
+async def test_remove_all_maps_to_matrix_staging_command():
+ result = from_room_event(text_event("!remove all"), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingCommand)
+ assert result.command == "matrix_remove_attachment"
+ assert result.args == ["all"]
+
+
+async def test_remove_index_maps_to_matrix_staging_command():
+ result = from_room_event(text_event("!remove 2"), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingCommand)
+ assert result.command == "matrix_remove_attachment"
+ assert result.args == ["2"]
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_converter.py -q`
+
+Expected:
+- FAIL because `!list` and `!remove` still parse as generic unknown commands
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# adapter/matrix/converter.py
+def from_command(body: str, sender: str, chat_id: str, room_id: str | None = None) -> IncomingEvent:
+ raw = body.lstrip("!").strip()
+ parts = raw.split()
+ command = parts[0].lower() if parts else ""
+ args = parts[1:]
+
+ if command == "list":
+ return IncomingCommand(
+ user_id=sender,
+ platform=PLATFORM,
+ chat_id=chat_id,
+ command="matrix_list_attachments",
+ args=[],
+ )
+
+ if command == "remove":
+ return IncomingCommand(
+ user_id=sender,
+ platform=PLATFORM,
+ chat_id=chat_id,
+ command="matrix_remove_attachment",
+ args=args,
+ )
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_converter.py -q`
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/converter.py tests/adapter/matrix/test_converter.py
+git commit -m "feat: parse matrix staged attachment commands"
+```
+
+### Task 3: Stage File-Only Events and Handle List/Remove UX
+
+**Files:**
+- Modify: `adapter/matrix/bot.py`
+- Modify: `adapter/matrix/store.py`
+- Test: `tests/adapter/matrix/test_dispatcher.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+# tests/adapter/matrix/test_dispatcher.py
+async def test_file_only_event_is_staged_and_does_not_dispatch():
+ runtime = build_runtime(platform=MockPlatformClient())
+ client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
+ bot = MatrixBot(client, runtime)
+ runtime.dispatcher.dispatch = AsyncMock(return_value=[])
+ bot._materialize_incoming_attachments = AsyncMock(
+ return_value=IncomingMessage(
+ user_id="@alice:example.org",
+ platform="matrix",
+ chat_id="matrix:!r:example.org",
+ text="",
+ attachments=[
+ Attachment(
+ type="document",
+ filename="report.pdf",
+ workspace_path="surfaces/matrix/alice/r/inbox/report.pdf",
+ mime_type="application/pdf",
+ )
+ ],
+ )
+ )
+ room = SimpleNamespace(room_id="!r:example.org")
+ event = SimpleNamespace(
+ sender="@alice:example.org",
+ body="report.pdf",
+ msgtype="m.file",
+ url="mxc://hs/id",
+ mimetype="application/pdf",
+ replyto_event_id=None,
+ )
+
+ await bot.on_room_message(room, event)
+
+ runtime.dispatcher.dispatch.assert_not_awaited()
+ staged = await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org")
+ assert [item["filename"] for item in staged] == ["report.pdf"]
+ client.room_send.assert_awaited_once()
+ assert "Следующее сообщение" in client.room_send.await_args.args[2]["body"]
+
+
+async def test_list_command_returns_current_staged_attachments():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await add_staged_attachment(runtime.store, "!r:example.org", "@alice:example.org", {"filename": "a.pdf", "workspace_path": "a.pdf"})
+ await add_staged_attachment(runtime.store, "!r:example.org", "@alice:example.org", {"filename": "b.pdf", "workspace_path": "b.pdf"})
+ client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
+ bot = MatrixBot(client, runtime)
+ room = SimpleNamespace(room_id="!r:example.org")
+ event = SimpleNamespace(sender="@alice:example.org", body="!list", msgtype="m.text", replyto_event_id=None)
+
+ await bot.on_room_message(room, event)
+
+ body = client.room_send.await_args.args[2]["body"]
+ assert "1. a.pdf" in body
+ assert "2. b.pdf" in body
+
+
+async def test_remove_invalid_index_returns_short_error():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await add_staged_attachment(runtime.store, "!r:example.org", "@alice:example.org", {"filename": "a.pdf", "workspace_path": "a.pdf"})
+ client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
+ bot = MatrixBot(client, runtime)
+ room = SimpleNamespace(room_id="!r:example.org")
+ event = SimpleNamespace(sender="@alice:example.org", body="!remove 9", msgtype="m.text", replyto_event_id=None)
+
+ await bot.on_room_message(room, event)
+
+ assert client.room_send.await_args.args[2]["body"] == "Нет такого вложения."
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q`
+
+Expected:
+- FAIL because file-only events still go straight to dispatcher
+- FAIL because `!list` and `!remove` have no Matrix-side handling in the bot
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# adapter/matrix/bot.py
+def _is_staging_command(self, incoming: IncomingEvent) -> bool:
+ return isinstance(incoming, IncomingCommand) and incoming.command in {
+ "matrix_list_attachments",
+ "matrix_remove_attachment",
+ }
+
+
+async def _handle_staging_command(self, room_id: str, user_id: str, incoming: IncomingCommand) -> list[OutgoingMessage]:
+ if incoming.command == "matrix_list_attachments":
+ return [OutgoingMessage(chat_id=incoming.chat_id, text=self._format_staged_attachments(room_id, user_id))]
+ if incoming.command == "matrix_remove_attachment" and incoming.args == ["all"]:
+ await clear_staged_attachments(self.runtime.store, room_id, user_id)
+ return [OutgoingMessage(chat_id=incoming.chat_id, text="Вложения очищены.")]
+```
+
+```python
+# adapter/matrix/bot.py
+if isinstance(incoming, IncomingMessage) and incoming.attachments and not incoming.text:
+ incoming = await self._materialize_incoming_attachments(room.room_id, sender, incoming)
+ await self._stage_attachments(room.room_id, sender, incoming.attachments)
+ await self._send_all(room.room_id, [OutgoingMessage(chat_id=dispatch_chat_id, text=self._format_staged_attachments(room.room_id, sender, with_hint=True))])
+ return
+
+if self._is_staging_command(incoming):
+ outgoing = await self._handle_staging_command(room.room_id, sender, incoming)
+ await self._send_all(room.room_id, outgoing)
+ return
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q`
+
+Expected: PASS for staging/list/remove behavior
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/bot.py adapter/matrix/store.py tests/adapter/matrix/test_dispatcher.py
+git commit -m "feat: add matrix staging list and remove flow"
+```
+
+### Task 4: Commit Staged Files With the Next Normal Message
+
+**Files:**
+- Modify: `adapter/matrix/bot.py`
+- Test: `tests/adapter/matrix/test_dispatcher.py`
+- Modify: `README.md`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+# tests/adapter/matrix/test_dispatcher.py
+async def test_next_normal_message_commits_staged_attachments():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await add_staged_attachment(
+ runtime.store,
+ "!r:example.org",
+ "@alice:example.org",
+ {
+ "filename": "report.pdf",
+ "workspace_path": "surfaces/matrix/alice/r/inbox/report.pdf",
+ "mime_type": "application/pdf",
+ },
+ )
+ client = SimpleNamespace(user_id="@bot:example.org")
+ bot = MatrixBot(client, runtime)
+ bot._send_all = AsyncMock()
+ runtime.dispatcher.dispatch = AsyncMock(return_value=[])
+ room = SimpleNamespace(room_id="!r:example.org")
+ event = SimpleNamespace(sender="@alice:example.org", body="Проанализируй", msgtype="m.text", replyto_event_id=None)
+
+ await bot.on_room_message(room, event)
+
+ dispatched = runtime.dispatcher.dispatch.await_args.args[0]
+ assert isinstance(dispatched, IncomingMessage)
+ assert dispatched.text == "Проанализируй"
+ assert [a.workspace_path for a in dispatched.attachments] == [
+ "surfaces/matrix/alice/r/inbox/report.pdf"
+ ]
+ assert await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org") == []
+
+
+async def test_failed_commit_preserves_staged_attachments():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await add_staged_attachment(
+ runtime.store,
+ "!r:example.org",
+ "@alice:example.org",
+ {"filename": "report.pdf", "workspace_path": "surfaces/matrix/alice/r/inbox/report.pdf"},
+ )
+ client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
+ bot = MatrixBot(client, runtime)
+ runtime.dispatcher.dispatch = AsyncMock(side_effect=PlatformError("boom"))
+ room = SimpleNamespace(room_id="!r:example.org")
+ event = SimpleNamespace(sender="@alice:example.org", body="Проанализируй", msgtype="m.text", replyto_event_id=None)
+
+ await bot.on_room_message(room, event)
+
+ staged = await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org")
+ assert [item["filename"] for item in staged] == ["report.pdf"]
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q`
+
+Expected:
+- FAIL because normal text messages do not yet merge staged attachments
+- FAIL because staged items are never preserved/cleared based on commit outcome
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# adapter/matrix/bot.py
+async def _merge_staged_attachments(
+ self,
+ room_id: str,
+ user_id: str,
+ incoming: IncomingMessage,
+) -> IncomingMessage:
+ staged = await get_staged_attachments(self.runtime.store, room_id, user_id)
+ if not staged:
+ return incoming
+ return IncomingMessage(
+ user_id=incoming.user_id,
+ platform=incoming.platform,
+ chat_id=incoming.chat_id,
+ text=incoming.text,
+ reply_to=incoming.reply_to,
+ attachments=[
+ Attachment(
+ type="document",
+ filename=item.get("filename"),
+ mime_type=item.get("mime_type"),
+ workspace_path=item.get("workspace_path"),
+ )
+ for item in staged
+ ],
+ )
+```
+
+```python
+# adapter/matrix/bot.py
+staged_before_dispatch = False
+if isinstance(incoming, IncomingMessage) and incoming.text and not incoming.attachments:
+ staged = await get_staged_attachments(self.runtime.store, room.room_id, sender)
+ if staged:
+ incoming = await self._merge_staged_attachments(room.room_id, sender, incoming)
+ staged_before_dispatch = True
+
+try:
+ outgoing = await self.runtime.dispatcher.dispatch(incoming)
+except PlatformError:
+ ...
+else:
+ if staged_before_dispatch:
+ await clear_staged_attachments(self.runtime.store, room.room_id, sender)
+```
+
+- [ ] **Step 4: Run targeted tests to verify they pass**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py tests/adapter/matrix/test_converter.py tests/adapter/matrix/test_dispatcher.py -q`
+
+Expected: PASS
+
+- [ ] **Step 5: Update docs**
+
+Add to `README.md`:
+
+```md
+### Matrix staged attachments
+
+If a Matrix user sends files without a text instruction, the bot stages them per chat and replies with the current list.
+
+- `!list` shows staged files
+- `!remove ` removes one staged file by index
+- `!remove all` clears all staged files
+
+The next normal user message is sent to the agent together with all staged files.
+```
+
+- [ ] **Step 6: Run broader verification**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py tests/adapter/matrix/test_converter.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_files.py tests/adapter/matrix/test_send_outgoing.py tests/core/test_integration.py tests/platform/test_real.py -q`
+
+Expected: PASS
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add adapter/matrix/bot.py README.md tests/adapter/matrix/test_store.py tests/adapter/matrix/test_converter.py tests/adapter/matrix/test_dispatcher.py
+git commit -m "feat: commit staged matrix attachments on next message"
+```
+
+## Self-Review
+
+- Spec coverage:
+ - staged per `(chat_id, user_id)`: Task 1
+ - short commands `!list`, `!remove `, `!remove all`: Task 2 and Task 3
+ - file-only events do not invoke agent: Task 3
+ - next normal message commits staged attachments: Task 4
+ - failed commit preserves staged attachments: Task 4
+ - docs update: Task 4
+- Placeholder scan:
+ - no `TODO`, `TBD`, or deferred behavior left in task steps
+- Type consistency:
+ - staged storage uses `dict` records with `filename`, `workspace_path`, `mime_type`
+ - bot reconstructs `core.protocol.Attachment` from those same keys
diff --git a/docs/superpowers/plans/2026-04-22-transport-layer-thin-adapter.md b/docs/superpowers/plans/2026-04-22-transport-layer-thin-adapter.md
new file mode 100644
index 0000000..b1984ec
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-22-transport-layer-thin-adapter.md
@@ -0,0 +1,540 @@
+# Transport Layer Thin Adapter Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace the current custom transport behavior with a thin upstream-compatible adapter so Matrix uses `platform-agent_api.AgentApi` almost as-is and keeps only integration concerns on the `surfaces` side.
+
+**Architecture:** Keep a tiny `AgentApiWrapper` only as a construction/factory shim (`base_url` normalization + `for_chat(chat_id)`). Move all resilience logic that still belongs to `surfaces` into `RealPlatformClient`: per-chat client caching, per-chat send locks, disconnect-on-failure, and `PlatformError` mapping. Delete all custom stream semantics from the wrapper so bugs in streaming can be attributed cleanly to either upstream or our integration layer.
+
+**Tech Stack:** Python 3.11, `aiohttp`, upstream `lambda_agent_api`, `pytest`, `pytest-asyncio`, `ruff`
+
+---
+
+## File Structure
+
+- Modify: `sdk/agent_api_wrapper.py`
+ Purpose: shrink the wrapper to a thin constructor/factory shim with no custom `_listen()` or `send_message()` logic.
+- Modify: `sdk/real.py`
+ Purpose: keep integration-only behavior: cached per-chat clients, chat locks, attachment forwarding, and failure cleanup.
+- Modify: `adapter/matrix/bot.py`
+ Purpose: instantiate the wrapper with the modern `base_url` path instead of a raw `url`, matching the pinned upstream API.
+- Modify: `tests/platform/test_real.py`
+ Purpose: remove tests that encode custom wrapper semantics and replace them with tests that prove only the intended integration guarantees.
+- Modify: `README.md`
+ Purpose: document that the transport layer now uses the pinned upstream `platform-agent_api` client semantics directly, with only a thin local adapter.
+
+### Task 1: Shrink `AgentApiWrapper` To A Thin Factory Shim
+
+**Files:**
+- Modify: `sdk/agent_api_wrapper.py`
+- Test: `tests/platform/test_real.py`
+
+- [ ] **Step 1: Replace wrapper-only behavior tests with thin-wrapper tests**
+
+Update `tests/platform/test_real.py` so it no longer asserts any custom post-END drain or wrapper-owned timeout behavior. Replace those tests with the following:
+
+```python
+def test_agent_api_wrapper_normalizes_base_url_and_uses_modern_constructor(monkeypatch):
+ captured = {}
+
+ def fake_init(self, agent_id, base_url=None, chat_id=0, **kwargs):
+ captured["agent_id"] = agent_id
+ captured["base_url"] = base_url
+ captured["chat_id"] = chat_id
+
+ monkeypatch.setattr(agent_api_wrapper_module.AgentApi, "__init__", fake_init)
+
+ wrapper = AgentApiWrapper(
+ agent_id="agent-1",
+ base_url="ws://platform-agent:8000/v1/agent_ws/",
+ chat_id="41",
+ )
+
+ assert wrapper.chat_id == "41"
+ assert wrapper._base_url == "ws://platform-agent:8000"
+ assert captured == {
+ "agent_id": "agent-1",
+ "base_url": "ws://platform-agent:8000",
+ "chat_id": "41",
+ }
+
+
+def test_agent_api_wrapper_for_chat_reuses_normalized_base_url(monkeypatch):
+ init_calls = []
+
+ def fake_init(self, agent_id, base_url=None, chat_id=0, **kwargs):
+ self.id = agent_id
+ self.chat_id = chat_id
+ self.url = base_url
+ init_calls.append((agent_id, base_url, chat_id))
+
+ monkeypatch.setattr(agent_api_wrapper_module.AgentApi, "__init__", fake_init)
+
+ root = AgentApiWrapper(
+ agent_id="agent-1",
+ base_url="http://platform-agent:8000/v1/agent_ws/",
+ chat_id="1",
+ )
+
+ child = root.for_chat("99")
+
+ assert child is not root
+ assert child.chat_id == "99"
+ assert child._base_url == "http://platform-agent:8000"
+ assert init_calls == [
+ ("agent-1", "http://platform-agent:8000", "1"),
+ ("agent-1", "http://platform-agent:8000", "99"),
+ ]
+```
+
+- [ ] **Step 2: Run tests to verify old assumptions fail**
+
+Run:
+
+```bash
+/bin/zsh -lc 'PYTHONPATH=.:external/platform-agent_api uv run pytest tests/platform/test_real.py -q -k "recovers_late_text_after_first_end or times_out_on_idle_stream or normalizes_base_url_and_uses_modern_constructor or for_chat_reuses_normalized_base_url"'
+```
+
+Expected:
+
+- FAIL because the old wrapper-behavior tests still exist
+- FAIL or SKIP for the new thin-wrapper tests until code/tests are aligned
+
+- [ ] **Step 3: Replace `sdk/agent_api_wrapper.py` with a thin wrapper**
+
+Rewrite `sdk/agent_api_wrapper.py` to the minimal implementation below:
+
+```python
+from __future__ import annotations
+
+import inspect
+import re
+import sys
+from pathlib import Path
+from urllib.parse import urlsplit, urlunsplit
+
+_api_root = Path(__file__).resolve().parents[1] / "external" / "platform-agent_api"
+if str(_api_root) not in sys.path:
+ sys.path.insert(0, str(_api_root))
+
+from lambda_agent_api.agent_api import AgentApi # noqa: E402
+
+
+class AgentApiWrapper(AgentApi):
+ """Thin construction/factory shim over the pinned upstream AgentApi."""
+
+ def __init__(
+ self,
+ agent_id: str,
+ base_url: str,
+ *,
+ chat_id: int | str = 0,
+ **kwargs,
+ ) -> None:
+ self._base_url = self._normalize_base_url(base_url)
+ self._init_kwargs = dict(kwargs)
+ self.chat_id = chat_id
+ if not self._supports_modern_constructor():
+ raise RuntimeError("Pinned platform-agent_api is expected to support base_url + chat_id")
+
+ super().__init__(
+ agent_id=agent_id,
+ base_url=self._base_url,
+ chat_id=chat_id,
+ **kwargs,
+ )
+
+ @staticmethod
+ def _supports_modern_constructor() -> bool:
+ try:
+ parameters = inspect.signature(AgentApi.__init__).parameters
+ except (TypeError, ValueError):
+ return False
+ return "base_url" in parameters and "chat_id" in parameters
+
+ @staticmethod
+ def _normalize_base_url(base_url: str) -> str:
+ parsed = urlsplit(base_url)
+ path = re.sub(r"(?:/v1)?/agent_ws(?:/[^/]+)?/?$", "", parsed.path.rstrip("/"))
+ return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
+
+ def for_chat(self, chat_id: int | str) -> "AgentApiWrapper":
+ return type(self)(
+ agent_id=self.id,
+ base_url=self._base_url,
+ chat_id=chat_id,
+ **self._init_kwargs,
+ )
+```
+
+- [ ] **Step 4: Run the wrapper-focused tests**
+
+Run:
+
+```bash
+/bin/zsh -lc 'PYTHONPATH=.:external/platform-agent_api uv run pytest tests/platform/test_real.py -q -k "normalizes_base_url_and_uses_modern_constructor or for_chat_reuses_normalized_base_url"'
+```
+
+Expected:
+
+- PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add sdk/agent_api_wrapper.py tests/platform/test_real.py
+git commit -m "refactor: shrink agent api wrapper to thin adapter"
+```
+
+### Task 2: Simplify `RealPlatformClient` To The Pinned Modern API
+
+**Files:**
+- Modify: `sdk/real.py`
+- Modify: `adapter/matrix/bot.py`
+- Test: `tests/platform/test_real.py`
+
+- [ ] **Step 1: Add failing integration tests for the desired thin-adapter contract**
+
+Extend `tests/platform/test_real.py` with these assertions:
+
+```python
+@pytest.mark.asyncio
+async def test_real_platform_client_passes_attachments_to_modern_send_message():
+ agent_api = FakeAgentApiFactory()
+ client = RealPlatformClient(
+ agent_api=agent_api,
+ prototype_state=PrototypeStateStore(),
+ platform="matrix",
+ )
+
+ attachment = Attachment(
+ type="document",
+ filename="report.pdf",
+ mime_type="application/pdf",
+ workspace_path="surfaces/matrix/u1/r1/inbox/report.pdf",
+ )
+
+ result = await client.send_message(
+ "@alice:example.org",
+ "chat-1",
+ "read this",
+ attachments=[attachment],
+ )
+
+ assert result.response == "read this"
+ assert agent_api.instances["chat-1"].calls == [
+ ("read this", ["surfaces/matrix/u1/r1/inbox/report.pdf"])
+ ]
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_disconnects_chat_after_agent_exception():
+ class ErroringChatAgentApi:
+ def __init__(self, chat_id: str) -> None:
+ self.chat_id = chat_id
+ self.connect_calls = 0
+ self.close_calls = 0
+
+ async def connect(self) -> None:
+ self.connect_calls += 1
+
+ async def close(self) -> None:
+ self.close_calls += 1
+
+ async def send_message(self, text: str, attachments: list[str] | None = None):
+ raise agent_api_wrapper_module.AgentException("INTERNAL_ERROR", "boom")
+ yield
+
+ agent_api = FakeAgentApiFactory()
+ erroring = ErroringChatAgentApi("chat-1")
+ agent_api.for_chat = lambda chat_id: erroring
+ client = RealPlatformClient(
+ agent_api=agent_api,
+ prototype_state=PrototypeStateStore(),
+ platform="matrix",
+ )
+
+ with pytest.raises(PlatformError, match="boom") as exc_info:
+ await client.send_message("@alice:example.org", "chat-1", "hello")
+
+ assert exc_info.value.code == "INTERNAL_ERROR"
+ assert erroring.close_calls == 1
+ assert "chat-1" not in client._chat_apis
+```
+
+- [ ] **Step 2: Run tests to verify they fail before simplification**
+
+Run:
+
+```bash
+/bin/zsh -lc 'PYTHONPATH=.:external/platform-agent_api uv run pytest tests/platform/test_real.py -q -k "passes_attachments_to_modern_send_message or disconnects_chat_after_agent_exception"'
+```
+
+Expected:
+
+- FAIL until `sdk/real.py` and Matrix runtime wiring are aligned with the pinned modern API
+
+- [ ] **Step 3: Simplify `sdk/real.py` and Matrix runtime construction**
+
+Make these exact edits:
+
+```python
+# adapter/matrix/bot.py
+def _build_platform_from_env() -> PlatformClient:
+ backend = os.environ.get("MATRIX_PLATFORM_BACKEND", "mock").strip().lower()
+ if backend == "real":
+ base_url = os.environ["AGENT_BASE_URL"]
+ return RealPlatformClient(
+ agent_api=AgentApiWrapper(agent_id="matrix-bot", base_url=base_url),
+ prototype_state=PrototypeStateStore(),
+ platform="matrix",
+ )
+ return MockPlatformClient()
+```
+
+```python
+# sdk/real.py
+from __future__ import annotations
+
+import asyncio
+from collections.abc import AsyncIterator
+from pathlib import Path
+
+from sdk.agent_api_wrapper import AgentApiWrapper
+from sdk.interface import (
+ Attachment,
+ MessageChunk,
+ MessageResponse,
+ PlatformClient,
+ PlatformError,
+ User,
+ UserSettings,
+)
+from sdk.prototype_state import PrototypeStateStore
+
+
+class RealPlatformClient(PlatformClient):
+ def __init__(
+ self,
+ agent_api: AgentApiWrapper,
+ prototype_state: PrototypeStateStore,
+ platform: str = "matrix",
+ ) -> None:
+ self._agent_api = agent_api
+ self._prototype_state = prototype_state
+ self._platform = platform
+ self._chat_apis: dict[str, AgentApiWrapper] = {}
+ self._chat_api_lock = asyncio.Lock()
+ self._chat_send_locks: dict[str, asyncio.Lock] = {}
+
+ @property
+ def agent_api(self) -> AgentApiWrapper:
+ return self._agent_api
+
+ async def _get_chat_api(self, chat_id: str) -> AgentApiWrapper:
+ chat_key = str(chat_id)
+ chat_api = self._chat_apis.get(chat_key)
+ if chat_api is None:
+ async with self._chat_api_lock:
+ chat_api = self._chat_apis.get(chat_key)
+ if chat_api is None:
+ chat_api = self._agent_api.for_chat(chat_key)
+ await chat_api.connect()
+ self._chat_apis[chat_key] = chat_api
+ return chat_api
+
+ def _get_chat_send_lock(self, chat_id: str) -> asyncio.Lock:
+ chat_key = str(chat_id)
+ lock = self._chat_send_locks.get(chat_key)
+ if lock is None:
+ lock = asyncio.Lock()
+ self._chat_send_locks[chat_key] = lock
+ return lock
+
+ async def send_message(
+ self,
+ user_id: str,
+ chat_id: str,
+ text: str,
+ attachments: list[Attachment] | None = None,
+ ) -> MessageResponse:
+ response_parts: list[str] = []
+ tokens_used = 0
+ sent_attachments: list[Attachment] = []
+ message_id = user_id
+
+ lock = self._get_chat_send_lock(chat_id)
+ async with lock:
+ chat_api = await self._get_chat_api(chat_id)
+ try:
+ async for event in chat_api.send_message(text, attachments=self._attachment_paths(attachments)):
+ if hasattr(event, "text"):
+ response_parts.append(event.text)
+ elif event.__class__.__name__ == "MsgEventEnd":
+ tokens_used = getattr(event, "tokens_used", 0)
+ elif "SEND_FILE" in getattr(getattr(event, "type", None), "value", str(getattr(event, "type", ""))):
+ attachment = self._attachment_from_send_file_event(event)
+ if attachment is not None:
+ sent_attachments.append(attachment)
+ except Exception as exc:
+ await self._handle_chat_api_failure(chat_id, exc)
+
+ await self._prototype_state.set_last_tokens_used(str(chat_id), tokens_used)
+
+ return MessageResponse(
+ message_id=message_id,
+ response="".join(response_parts),
+ tokens_used=tokens_used,
+ finished=True,
+ attachments=sent_attachments,
+ )
+
+ async def stream_message(
+ self,
+ user_id: str,
+ chat_id: str,
+ text: str,
+ attachments: list[Attachment] | None = None,
+ ) -> AsyncIterator[MessageChunk]:
+ lock = self._get_chat_send_lock(chat_id)
+ async with lock:
+ chat_api = await self._get_chat_api(chat_id)
+ try:
+ async for event in chat_api.send_message(text, attachments=self._attachment_paths(attachments)):
+ if hasattr(event, "text"):
+ yield MessageChunk(
+ message_id=user_id,
+ delta=event.text,
+ finished=False,
+ )
+ elif event.__class__.__name__ == "MsgEventEnd":
+ tokens_used = getattr(event, "tokens_used", 0)
+ await self._prototype_state.set_last_tokens_used(str(chat_id), tokens_used)
+ yield MessageChunk(
+ message_id=user_id,
+ delta="",
+ finished=True,
+ tokens_used=tokens_used,
+ )
+ except Exception as exc:
+ await self._handle_chat_api_failure(chat_id, exc)
+
+ async def disconnect_chat(self, chat_id: str) -> None:
+ chat_key = str(chat_id)
+ chat_api = self._chat_apis.pop(chat_key, None)
+ self._chat_send_locks.pop(chat_key, None)
+ if chat_api is not None:
+ await chat_api.close()
+
+ async def close(self) -> None:
+ for chat_api in list(self._chat_apis.values()):
+ await chat_api.close()
+ self._chat_apis.clear()
+ self._chat_send_locks.clear()
+
+ async def _handle_chat_api_failure(self, chat_id: str, exc: Exception) -> None:
+ await self.disconnect_chat(chat_id)
+ code = getattr(exc, "code", None) or "PLATFORM_CONNECTION_ERROR"
+ raise PlatformError(str(exc), code=code) from exc
+
+ @staticmethod
+ def _attachment_paths(attachments: list[Attachment] | None) -> list[str]:
+ if not attachments:
+ return []
+ return [attachment.workspace_path for attachment in attachments if attachment.workspace_path]
+```
+
+- [ ] **Step 4: Run the focused transport tests**
+
+Run:
+
+```bash
+/bin/zsh -lc 'PYTHONPATH=.:external/platform-agent_api uv run pytest tests/platform/test_real.py -q -k "passes_attachments_to_modern_send_message or disconnects_chat_after_agent_exception or wraps_connection_closed_as_platform_error or reconnects_after_closed_chat_api"'
+```
+
+Expected:
+
+- PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/bot.py sdk/real.py tests/platform/test_real.py
+git commit -m "refactor: use upstream transport semantics in real client"
+```
+
+### Task 3: Remove Custom Transport Assumptions From Tests And Docs
+
+**Files:**
+- Modify: `tests/platform/test_real.py`
+- Modify: `README.md`
+
+- [ ] **Step 1: Delete tests that encode wrapper-owned stream semantics**
+
+Remove any tests that assert:
+
+- late text is recovered after the first `END`
+- duplicate `END` is repaired inside our wrapper
+- wrapper-owned idle timeout semantics
+
+The file should keep only tests for:
+
+- wrapper construction/factory behavior
+- per-chat client reuse
+- reconnect/disconnect after failure
+- attachment forwarding
+- per-chat send locking
+
+- [ ] **Step 2: Update README transport description**
+
+Add this text to the Matrix runtime/backend section in `README.md`:
+
+```md
+Transport layer note:
+
+- `surfaces` now uses the pinned upstream `platform-agent_api.AgentApi` stream semantics directly
+- local code keeps only a thin adapter for client construction and per-chat client factories
+- request serialization, disconnect-on-failure, and `PlatformError` mapping remain in `sdk/real.py`
+- `surfaces` no longer performs local post-END stream reconstruction
+```
+
+- [ ] **Step 3: Run the full verification set**
+
+Run:
+
+```bash
+uv run ruff check adapter/matrix sdk tests/platform/test_real.py
+/bin/zsh -lc 'PYTHONPATH=. uv run pytest tests/adapter/matrix -q'
+/bin/zsh -lc 'PYTHONPATH=.:external/platform-agent_api uv run pytest tests/platform/test_real.py -q'
+```
+
+Expected:
+
+- `ruff` reports `All checks passed!`
+- Matrix adapter tests PASS
+- `tests/platform/test_real.py` PASS
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add README.md tests/platform/test_real.py
+git commit -m "test: remove custom transport semantics assumptions"
+```
+
+---
+
+## Self-Review
+
+- Spec coverage:
+ - thin adapter target: covered by Task 1
+ - integration-only `RealPlatformClient`: covered by Task 2
+ - removal of custom stream semantics assumptions: covered by Task 3
+ - re-verification after cleanup: covered by Task 3
+
+- Placeholder scan:
+ - no `TODO`, `TBD`, or deferred implementation placeholders remain in task steps
+
+- Type consistency:
+ - `AgentApiWrapper` remains the construction/factory type used by `RealPlatformClient`
+ - failure mapping still terminates in `PlatformError`
+ - attachment forwarding consistently uses `attachments: list[str]`
diff --git a/docs/superpowers/plans/2026-04-24-matrix-multi-agent-routing-and-restart-state.md b/docs/superpowers/plans/2026-04-24-matrix-multi-agent-routing-and-restart-state.md
new file mode 100644
index 0000000..a5227e8
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-24-matrix-multi-agent-routing-and-restart-state.md
@@ -0,0 +1,855 @@
+# Matrix Multi-Agent Routing And Restart State Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add Matrix multi-agent routing with user agent selection, room-level agent binding, and durable surface state that survives normal restart.
+
+**Architecture:** Keep the shared `PlatformClient` protocol unchanged. Add a Matrix-specific routing facade that translates local Matrix chat identity into `(agent_id, platform_chat_id)` and delegates to one `RealPlatformClient` per configured agent. Persist only durable routing state in the existing SQLite-backed surface store and deliberately drop temporary UX state on restart.
+
+**Tech Stack:** Python 3.11, matrix-nio, structlog, PyYAML, pytest, pytest-asyncio
+
+---
+
+## File Structure
+
+- Create: `adapter/matrix/agent_registry.py`
+ Purpose: load and validate the YAML agent registry used by Matrix runtime.
+- Create: `adapter/matrix/routed_platform.py`
+ Purpose: implement a Matrix-specific `PlatformClient` facade that resolves room bindings and delegates to per-agent `RealPlatformClient` instances.
+- Create: `adapter/matrix/handlers/agent.py`
+ Purpose: implement `!agent` listing and selection behavior.
+- Create: `tests/adapter/matrix/test_agent_registry.py`
+ Purpose: cover YAML loading and registry validation.
+- Create: `tests/adapter/matrix/test_routed_platform.py`
+ Purpose: cover room-target resolution and per-agent delegation without changing the shared protocol.
+- Create: `tests/adapter/matrix/test_agent_handler.py`
+ Purpose: cover `!agent` UX and persistence of `selected_agent_id`.
+- Create: `tests/adapter/matrix/test_restart_persistence.py`
+ Purpose: prove durable user/room state and `PLATFORM_CHAT_SEQ_KEY` survive runtime recreation with SQLite.
+- Create: `config/matrix-agents.example.yaml`
+ Purpose: document the expected agent registry format.
+- Modify: `pyproject.toml`
+ Purpose: add YAML parsing dependency required by the runtime registry loader.
+- Modify: `.env.example`
+ Purpose: document the config path env var for the Matrix agent registry.
+- Modify: `README.md`
+ Purpose: document the new config file, `!agent`, and restart persistence expectations.
+- Modify: `adapter/matrix/store.py`
+ Purpose: add helpers for `selected_agent_id`, room `agent_id`, and explicit sequence persistence semantics.
+- Modify: `adapter/matrix/bot.py`
+ Purpose: load the agent registry, construct the routed platform facade, keep local Matrix chat ids through dispatch, and enforce stale/unbound room behavior before dispatch.
+- Modify: `adapter/matrix/handlers/__init__.py`
+ Purpose: register the new `!agent` command.
+- Modify: `adapter/matrix/handlers/chat.py`
+ Purpose: require a selected agent for `!new` and bind new rooms to that agent.
+- Modify: `adapter/matrix/handlers/context_commands.py`
+ Purpose: keep context commands compatible with local chat ids and routed platform delegation.
+- Modify: `adapter/matrix/handlers/settings.py`
+ Purpose: expose `!agent` in help text.
+- Modify: `tests/adapter/matrix/test_dispatcher.py`
+ Purpose: cover pre-dispatch gating, stale room behavior, and `!new` semantics.
+- Modify: `tests/adapter/matrix/test_context_commands.py`
+ Purpose: keep load/reset/context flows aligned with the routed platform facade.
+
+---
+
+### Task 1: Add The Agent Registry And Configuration Wiring
+
+**Files:**
+- Create: `adapter/matrix/agent_registry.py`
+- Create: `tests/adapter/matrix/test_agent_registry.py`
+- Create: `config/matrix-agents.example.yaml`
+- Modify: `pyproject.toml`
+- Modify: `.env.example`
+- Modify: `README.md`
+
+- [ ] **Step 1: Write the failing registry tests**
+
+```python
+# tests/adapter/matrix/test_agent_registry.py
+from pathlib import Path
+
+import pytest
+
+from adapter.matrix.agent_registry import AgentRegistryError, load_agent_registry
+
+
+def test_load_agent_registry_reads_yaml_entries(tmp_path: Path):
+ path = tmp_path / "agents.yaml"
+ path.write_text(
+ "agents:\n"
+ " - id: agent-1\n"
+ " label: Analyst\n"
+ " - id: agent-2\n"
+ " label: Research\n",
+ encoding="utf-8",
+ )
+
+ registry = load_agent_registry(path)
+
+ assert [agent.agent_id for agent in registry.agents] == ["agent-1", "agent-2"]
+ assert registry.get("agent-1").label == "Analyst"
+
+
+def test_load_agent_registry_rejects_duplicate_ids(tmp_path: Path):
+ path = tmp_path / "agents.yaml"
+ path.write_text(
+ "agents:\n"
+ " - id: agent-1\n"
+ " label: Analyst\n"
+ " - id: agent-1\n"
+ " label: Duplicate\n",
+ encoding="utf-8",
+ )
+
+ with pytest.raises(AgentRegistryError, match="duplicate agent id"):
+ load_agent_registry(path)
+```
+
+- [ ] **Step 2: Run the registry tests to verify they fail**
+
+Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py -q`
+
+Expected: FAIL with `ModuleNotFoundError` or `ImportError` for `adapter.matrix.agent_registry`.
+
+- [ ] **Step 3: Add the YAML dependency and implement the registry loader**
+
+```toml
+# pyproject.toml
+dependencies = [
+ "aiogram>=3.4,<4",
+ "matrix-nio>=0.21",
+ "pydantic>=2.5",
+ "structlog>=24.1",
+ "python-dotenv>=1.0",
+ "httpx>=0.27",
+ "aiohttp>=3.9",
+ "PyYAML>=6.0",
+]
+```
+
+```python
+# adapter/matrix/agent_registry.py
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+
+import yaml
+
+
+class AgentRegistryError(ValueError):
+ pass
+
+
+@dataclass(frozen=True)
+class AgentDefinition:
+ agent_id: str
+ label: str
+
+
+class AgentRegistry:
+ def __init__(self, agents: list[AgentDefinition]) -> None:
+ self.agents = agents
+ self._by_id = {agent.agent_id: agent for agent in agents}
+
+ def get(self, agent_id: str) -> AgentDefinition:
+ try:
+ return self._by_id[agent_id]
+ except KeyError as exc:
+ raise AgentRegistryError(f"unknown agent id: {agent_id}") from exc
+
+
+def load_agent_registry(path: str | Path) -> AgentRegistry:
+ raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
+ entries = raw.get("agents")
+ if not isinstance(entries, list) or not entries:
+ raise AgentRegistryError("agents registry must contain a non-empty agents list")
+
+ agents: list[AgentDefinition] = []
+ seen: set[str] = set()
+ for entry in entries:
+ agent_id = str(entry.get("id", "")).strip()
+ label = str(entry.get("label", "")).strip()
+ if not agent_id or not label:
+ raise AgentRegistryError("each agent entry requires id and label")
+ if agent_id in seen:
+ raise AgentRegistryError(f"duplicate agent id: {agent_id}")
+ seen.add(agent_id)
+ agents.append(AgentDefinition(agent_id=agent_id, label=label))
+ return AgentRegistry(agents)
+```
+
+- [ ] **Step 4: Add the example config and runtime wiring docs**
+
+```yaml
+# config/matrix-agents.example.yaml
+agents:
+ - id: agent-1
+ label: Analyst
+ - id: agent-2
+ label: Research
+```
+
+```env
+# .env.example
+MATRIX_AGENT_REGISTRY_PATH=config/matrix-agents.yaml
+```
+
+```markdown
+# README.md
+1. Copy `config/matrix-agents.example.yaml` to `config/matrix-agents.yaml`
+2. Set `MATRIX_AGENT_REGISTRY_PATH=config/matrix-agents.yaml`
+3. Use `!agent` in Matrix to select the active upstream agent
+```
+
+- [ ] **Step 5: Run the registry tests to verify they pass**
+
+Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py -q`
+
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add pyproject.toml .env.example README.md config/matrix-agents.example.yaml adapter/matrix/agent_registry.py tests/adapter/matrix/test_agent_registry.py
+git commit -m "feat: add matrix agent registry loader"
+```
+
+---
+
+### Task 2: Add A Matrix Routing Facade Without Changing `PlatformClient`
+
+**Files:**
+- Create: `adapter/matrix/routed_platform.py`
+- Create: `tests/adapter/matrix/test_routed_platform.py`
+- Modify: `adapter/matrix/bot.py`
+
+- [ ] **Step 1: Write the failing routed-platform tests**
+
+```python
+# tests/adapter/matrix/test_routed_platform.py
+import pytest
+
+from adapter.matrix.routed_platform import RoutedPlatformClient
+from adapter.matrix.store import set_room_meta
+from core.chat import ChatManager
+from core.store import InMemoryStore
+from sdk.interface import MessageResponse
+from sdk.prototype_state import PrototypeStateStore
+
+
+class FakeDelegate:
+ def __init__(self, agent_id: str) -> None:
+ self.agent_id = agent_id
+ self.calls = []
+
+ async def send_message(self, user_id: str, chat_id: str, text: str, attachments=None):
+ self.calls.append((user_id, chat_id, text, attachments))
+ return MessageResponse(
+ message_id=user_id,
+ response=f"{self.agent_id}:{text}",
+ tokens_used=0,
+ finished=True,
+ )
+
+ async def get_or_create_user(self, external_id: str, platform: str, display_name=None):
+ return await PrototypeStateStore().get_or_create_user(external_id, platform, display_name)
+
+ async def get_settings(self, user_id: str):
+ return await PrototypeStateStore().get_settings(user_id)
+
+ async def update_settings(self, user_id: str, action):
+ return None
+
+
+@pytest.mark.asyncio
+async def test_routed_platform_delegates_using_room_agent_and_platform_chat_id():
+ store = InMemoryStore()
+ chat_mgr = ChatManager(None, store)
+ await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org", "Chat 1")
+ await set_room_meta(
+ store,
+ "!room:example.org",
+ {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"},
+ )
+
+ delegates = {"agent-2": FakeDelegate("agent-2")}
+ platform = RoutedPlatformClient(store=store, chat_mgr=chat_mgr, delegates=delegates)
+
+ response = await platform.send_message("u1", "C1", "hello")
+
+ assert response.response == "agent-2:hello"
+ assert delegates["agent-2"].calls == [("u1", "41", "hello", None)]
+```
+
+- [ ] **Step 2: Run the routed-platform tests to verify they fail**
+
+Run: `uv run pytest tests/adapter/matrix/test_routed_platform.py -q`
+
+Expected: FAIL with `ImportError` for `RoutedPlatformClient`.
+
+- [ ] **Step 3: Implement the routing facade and integrate runtime construction**
+
+```python
+# adapter/matrix/routed_platform.py
+from __future__ import annotations
+
+from sdk.interface import PlatformClient
+
+
+class RoutedPlatformClient(PlatformClient):
+ def __init__(self, store, chat_mgr, delegates: dict[str, PlatformClient]) -> None:
+ self._store = store
+ self._chat_mgr = chat_mgr
+ self._delegates = delegates
+
+ async def _resolve_target(self, user_id: str, local_chat_id: str) -> tuple[PlatformClient, str]:
+ ctx = await self._chat_mgr.get(local_chat_id, user_id=user_id)
+ if ctx is None:
+ raise ValueError(f"Chat {local_chat_id} not found for {user_id}")
+ room_meta = await self._store.get(f"matrix_room:{ctx.surface_ref}")
+ if room_meta is None or not room_meta.get("agent_id") or not room_meta.get("platform_chat_id"):
+ raise ValueError(f"Room {ctx.surface_ref} is not bound to an agent target")
+ delegate = self._delegates[room_meta["agent_id"]]
+ return delegate, str(room_meta["platform_chat_id"])
+
+ async def send_message(self, user_id: str, chat_id: str, text: str, attachments=None):
+ delegate, platform_chat_id = await self._resolve_target(user_id, chat_id)
+ return await delegate.send_message(user_id, platform_chat_id, text, attachments)
+
+ async def stream_message(self, user_id: str, chat_id: str, text: str, attachments=None):
+ delegate, platform_chat_id = await self._resolve_target(user_id, chat_id)
+ async for chunk in delegate.stream_message(user_id, platform_chat_id, text, attachments):
+ yield chunk
+
+ async def get_or_create_user(self, external_id: str, platform: str, display_name=None):
+ first_delegate = next(iter(self._delegates.values()))
+ return await first_delegate.get_or_create_user(external_id, platform, display_name)
+
+ async def get_settings(self, user_id: str):
+ first_delegate = next(iter(self._delegates.values()))
+ return await first_delegate.get_settings(user_id)
+
+ async def update_settings(self, user_id: str, action):
+ first_delegate = next(iter(self._delegates.values()))
+ await first_delegate.update_settings(user_id, action)
+```
+
+```python
+# adapter/matrix/bot.py
+from adapter.matrix.agent_registry import load_agent_registry
+from adapter.matrix.routed_platform import RoutedPlatformClient
+
+
+def _build_platform_from_env(store: StateStore, chat_mgr: ChatManager) -> PlatformClient:
+ backend = os.environ.get("MATRIX_PLATFORM_BACKEND", "mock").strip().lower()
+ if backend != "real":
+ return MockPlatformClient()
+
+ registry = load_agent_registry(os.environ["MATRIX_AGENT_REGISTRY_PATH"])
+ delegates = {
+ agent.agent_id: RealPlatformClient(
+ agent_id=agent.agent_id,
+ agent_base_url=_agent_base_url_from_env(),
+ prototype_state=PrototypeStateStore(),
+ platform="matrix",
+ )
+ for agent in registry.agents
+ }
+ return RoutedPlatformClient(store=store, chat_mgr=chat_mgr, delegates=delegates)
+
+
+def build_runtime(...):
+ store = store or InMemoryStore()
+ chat_mgr = ChatManager(None, store)
+ platform = platform or _build_platform_from_env(store, chat_mgr)
+ auth_mgr = AuthManager(platform, store)
+ settings_mgr = SettingsManager(platform, store)
+ dispatcher = EventDispatcher(
+ platform=platform,
+ chat_mgr=chat_mgr,
+ auth_mgr=auth_mgr,
+ settings_mgr=settings_mgr,
+ )
+```
+
+- [ ] **Step 4: Run the routed-platform tests to verify they pass**
+
+Run: `uv run pytest tests/adapter/matrix/test_routed_platform.py -q`
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/routed_platform.py adapter/matrix/bot.py tests/adapter/matrix/test_routed_platform.py
+git commit -m "feat: add matrix routed platform facade"
+```
+
+---
+
+### Task 3: Add `!agent` Selection And Durable User Agent State
+
+**Files:**
+- Create: `adapter/matrix/handlers/agent.py`
+- Create: `tests/adapter/matrix/test_agent_handler.py`
+- Modify: `adapter/matrix/store.py`
+- Modify: `adapter/matrix/handlers/__init__.py`
+- Modify: `adapter/matrix/handlers/settings.py`
+
+- [ ] **Step 1: Write the failing agent-handler tests**
+
+```python
+# tests/adapter/matrix/test_agent_handler.py
+import pytest
+
+from adapter.matrix.handlers.agent import make_handle_agent
+from adapter.matrix.store import get_room_meta, get_selected_agent_id, set_room_meta
+from core.protocol import IncomingCommand
+from core.store import InMemoryStore
+
+
+class FakeRegistry:
+ def __init__(self) -> None:
+ self.agents = [
+ type("Agent", (), {"agent_id": "agent-1", "label": "Analyst"})(),
+ type("Agent", (), {"agent_id": "agent-2", "label": "Research"})(),
+ ]
+
+
+@pytest.mark.asyncio
+async def test_agent_command_lists_available_agents():
+ handler = make_handle_agent(store=InMemoryStore(), registry=FakeRegistry())
+ result = await handler(
+ IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="agent", args=[]),
+ None,
+ None,
+ None,
+ None,
+ )
+ assert "1. Analyst" in result[0].text
+ assert "2. Research" in result[0].text
+
+
+@pytest.mark.asyncio
+async def test_agent_command_persists_selected_agent_and_binds_unbound_room():
+ store = InMemoryStore()
+ await set_room_meta(store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "u1"})
+ handler = make_handle_agent(store=store, registry=FakeRegistry())
+ chat_mgr = type(
+ "ChatMgr",
+ (),
+ {"get": staticmethod(lambda chat_id, user_id=None: type("Ctx", (), {"surface_ref": "!room:example.org"})())},
+ )()
+
+ await handler(
+ IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="agent", args=["2"]),
+ None,
+ None,
+ chat_mgr,
+ None,
+ )
+
+ assert await get_selected_agent_id(store, "u1") == "agent-2"
+ room_meta = await get_room_meta(store, "!room:example.org")
+ assert room_meta["agent_id"] == "agent-2"
+```
+
+- [ ] **Step 2: Run the agent-handler tests to verify they fail**
+
+Run: `uv run pytest tests/adapter/matrix/test_agent_handler.py -q`
+
+Expected: FAIL with missing handler or store helpers.
+
+- [ ] **Step 3: Add durable store helpers and implement `!agent`**
+
+```python
+# adapter/matrix/store.py
+async def get_selected_agent_id(store: StateStore, matrix_user_id: str) -> str | None:
+ meta = await get_user_meta(store, matrix_user_id) or {}
+ value = meta.get("selected_agent_id")
+ return str(value) if value else None
+
+
+async def set_selected_agent_id(store: StateStore, matrix_user_id: str, agent_id: str) -> None:
+ meta = await get_user_meta(store, matrix_user_id) or {}
+ meta["selected_agent_id"] = agent_id
+ await set_user_meta(store, matrix_user_id, meta)
+
+
+async def set_room_agent_id(store: StateStore, room_id: str, agent_id: str) -> None:
+ meta = dict(await get_room_meta(store, room_id) or {})
+ meta["agent_id"] = agent_id
+ await set_room_meta(store, room_id, meta)
+```
+
+```python
+# adapter/matrix/handlers/agent.py
+from __future__ import annotations
+
+from adapter.matrix.store import (
+ get_room_meta,
+ get_selected_agent_id,
+ next_platform_chat_id,
+ set_platform_chat_id,
+ set_room_agent_id,
+ set_selected_agent_id,
+)
+from core.protocol import IncomingCommand, OutgoingMessage
+
+
+def make_handle_agent(store, registry):
+ async def handle_agent(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr):
+ if not event.args:
+ current = await get_selected_agent_id(store, event.user_id)
+ lines = ["Доступные агенты:"]
+ for index, agent in enumerate(registry.agents, start=1):
+ marker = " (текущий)" if agent.agent_id == current else ""
+ lines.append(f"{index}. {agent.label}{marker}")
+ lines.append("")
+ lines.append("Выбери агента: !agent <номер>")
+ return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))]
+
+ agent = registry.agents[int(event.args[0]) - 1]
+ await set_selected_agent_id(store, event.user_id, agent.agent_id)
+ ctx = await chat_mgr.get(event.chat_id, user_id=event.user_id) if chat_mgr else None
+ if ctx is not None:
+ room_meta = await get_room_meta(store, ctx.surface_ref)
+ if room_meta is not None and not room_meta.get("agent_id"):
+ await set_room_agent_id(store, ctx.surface_ref, agent.agent_id)
+ if not room_meta.get("platform_chat_id"):
+ await set_platform_chat_id(store, ctx.surface_ref, await next_platform_chat_id(store))
+ return [OutgoingMessage(chat_id=event.chat_id, text=f"Агент переключён на {agent.label}. Этот чат готов к работе.")]
+ return [OutgoingMessage(chat_id=event.chat_id, text=f"Агент переключён на {agent.label}. Для продолжения используй !new.")]
+
+ return handle_agent
+```
+
+- [ ] **Step 4: Register the command and update help text**
+
+```python
+# adapter/matrix/handlers/__init__.py
+from adapter.matrix.handlers.agent import make_handle_agent
+
+dispatcher.register(IncomingCommand, "agent", make_handle_agent(store, registry))
+```
+
+```python
+# adapter/matrix/handlers/settings.py
+HELP_TEXT = "\n".join(
+ [
+ "Команды",
+ "",
+ "!agent выбрать активного агента",
+ "!new [название] создать новый чат",
+ "!chats список активных чатов",
+ "!rename <название> переименовать текущий чат",
+ "!archive архивировать текущий чат",
+ "!context показать текущее состояние контекста",
+ "!save [имя] сохранить текущий контекст",
+ "!load показать сохранённые контексты",
+ ]
+)
+```
+
+- [ ] **Step 5: Run the agent-handler tests to verify they pass**
+
+Run: `uv run pytest tests/adapter/matrix/test_agent_handler.py -q`
+
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add adapter/matrix/store.py adapter/matrix/handlers/agent.py adapter/matrix/handlers/__init__.py adapter/matrix/handlers/settings.py tests/adapter/matrix/test_agent_handler.py
+git commit -m "feat: add matrix agent selection command"
+```
+
+---
+
+### Task 4: Bind Rooms Correctly And Block Stale Chats
+
+**Files:**
+- Modify: `adapter/matrix/bot.py`
+- Modify: `adapter/matrix/handlers/chat.py`
+- Modify: `adapter/matrix/handlers/context_commands.py`
+- Modify: `tests/adapter/matrix/test_dispatcher.py`
+- Modify: `tests/adapter/matrix/test_context_commands.py`
+
+- [ ] **Step 1: Write the failing dispatcher and context-command tests**
+
+```python
+# tests/adapter/matrix/test_dispatcher.py
+@pytest.mark.asyncio
+async def test_bot_replies_with_agent_prompt_when_user_has_no_selected_agent():
+ runtime = build_runtime(platform=MockPlatformClient())
+ client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
+ bot = MatrixBot(client, runtime)
+ await set_room_meta(runtime.store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "@alice:example.org"})
+
+ await bot.on_room_message(SimpleNamespace(room_id="!room:example.org"), SimpleNamespace(sender="@alice:example.org", body="hello"))
+
+ client.room_send.assert_awaited_once()
+ assert "выбери агента" in client.room_send.call_args.args[2]["body"].lower()
+
+
+@pytest.mark.asyncio
+async def test_new_chat_requires_selected_agent_and_binds_room_meta():
+ client = SimpleNamespace(
+ room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r2:example")),
+ room_put_state=AsyncMock(),
+ )
+ runtime = build_runtime(platform=MockPlatformClient(), client=client)
+ await set_user_meta(runtime.store, "u1", {"space_id": "!space:example", "next_chat_index": 2, "selected_agent_id": "agent-2"})
+
+ result = await runtime.dispatcher.dispatch(
+ IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="new", args=["Research"])
+ )
+
+ room_meta = await get_room_meta(runtime.store, "!r2:example")
+ assert room_meta["agent_id"] == "agent-2"
+ assert "Создан чат" in result[0].text
+```
+
+```python
+# tests/adapter/matrix/test_context_commands.py
+@pytest.mark.asyncio
+async def test_load_selection_calls_platform_with_local_chat_id():
+ platform = MatrixCommandPlatform()
+ runtime = build_runtime(platform=platform)
+ await runtime.chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org", "Chat 1")
+ await set_room_meta(runtime.store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"})
+
+ client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
+ bot = MatrixBot(client, runtime)
+ await set_load_pending(runtime.store, "u1", "!room:example.org", {"saves": [{"name": "session-a", "created_at": "2026-04-17T00:00:00+00:00"}]})
+
+ await bot.on_room_message(SimpleNamespace(room_id="!room:example.org"), SimpleNamespace(sender="u1", body="1"))
+
+ platform.send_message.assert_awaited_once_with("u1", "C1", LOAD_PROMPT.format(name="session-a"))
+```
+
+- [ ] **Step 2: Run the dispatcher and context-command tests to verify they fail**
+
+Run: `uv run pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py -q`
+
+Expected: FAIL because the current runtime still injects `platform_chat_id` into normal messages and `!new` does not require or persist `agent_id`.
+
+- [ ] **Step 3: Implement room binding and stale-room checks in runtime**
+
+```python
+# adapter/matrix/bot.py
+from adapter.matrix.store import (
+ get_selected_agent_id,
+ get_room_meta,
+ next_platform_chat_id,
+ set_platform_chat_id,
+ set_room_agent_id,
+)
+
+
+async def _ensure_active_room_target(self, room_id: str, user_id: str) -> tuple[dict | None, OutgoingMessage | None]:
+ room_meta = await get_room_meta(self.runtime.store, room_id)
+ selected_agent_id = await get_selected_agent_id(self.runtime.store, user_id)
+ if not selected_agent_id:
+ return room_meta, OutgoingMessage(chat_id=room_id, text="Сначала выбери агента через !agent.")
+ if room_meta is None:
+ return room_meta, None
+ if not room_meta.get("agent_id"):
+ await set_room_agent_id(self.runtime.store, room_id, selected_agent_id)
+ if not room_meta.get("platform_chat_id"):
+ await set_platform_chat_id(self.runtime.store, room_id, await next_platform_chat_id(self.runtime.store))
+ room_meta = await get_room_meta(self.runtime.store, room_id)
+ return room_meta, None
+ if room_meta["agent_id"] != selected_agent_id:
+ return room_meta, OutgoingMessage(chat_id=room_id, text="Этот чат привязан к старому агенту. Используй !new.")
+ return room_meta, None
+```
+
+```python
+# adapter/matrix/bot.py
+local_chat_id = await resolve_chat_id(self.runtime.store, room.room_id, sender)
+dispatch_chat_id = local_chat_id
+
+if not body.startswith("!"):
+ room_meta, blocking = await self._ensure_active_room_target(room.room_id, sender)
+ if blocking is not None:
+ await self._send_all(room.room_id, [blocking])
+ return
+
+incoming = from_room_event(event, room_id=room.room_id, chat_id=dispatch_chat_id)
+```
+
+- [ ] **Step 4: Require selected agent for `!new` and persist room `agent_id`**
+
+```python
+# adapter/matrix/handlers/chat.py
+from adapter.matrix.store import get_selected_agent_id
+
+selected_agent_id = await get_selected_agent_id(store, event.user_id)
+if not selected_agent_id:
+ return [OutgoingMessage(chat_id=event.chat_id, text="Сначала выбери агента через !agent.")]
+
+await set_room_meta(
+ store,
+ room_id,
+ {
+ "room_type": "chat",
+ "chat_id": chat_id,
+ "display_name": room_name,
+ "matrix_user_id": event.user_id,
+ "space_id": space_id,
+ "platform_chat_id": platform_chat_id,
+ "agent_id": selected_agent_id,
+ },
+)
+```
+
+```python
+# adapter/matrix/bot.py
+room_meta = await get_room_meta(self.runtime.store, room_id)
+local_chat_id = room_meta.get("chat_id", room_id) if room_meta else room_id
+
+await self.runtime.platform.send_message(
+ user_id,
+ local_chat_id,
+ LOAD_PROMPT.format(name=name),
+)
+```
+
+- [ ] **Step 5: Run the dispatcher and context-command tests to verify they pass**
+
+Run: `uv run pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py -q`
+
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add adapter/matrix/bot.py adapter/matrix/handlers/chat.py adapter/matrix/handlers/context_commands.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py
+git commit -m "feat: bind matrix rooms to selected agents"
+```
+
+---
+
+### Task 5: Prove Durable Restart State And Sequence Persistence
+
+**Files:**
+- Create: `tests/adapter/matrix/test_restart_persistence.py`
+- Modify: `adapter/matrix/store.py`
+- Modify: `README.md`
+
+- [ ] **Step 1: Write the failing restart-persistence tests**
+
+```python
+# tests/adapter/matrix/test_restart_persistence.py
+import pytest
+
+from adapter.matrix.store import (
+ get_selected_agent_id,
+ next_platform_chat_id,
+ set_room_meta,
+ set_selected_agent_id,
+)
+from core.store import SQLiteStore
+
+
+@pytest.mark.asyncio
+async def test_selected_agent_and_room_binding_survive_store_recreation(tmp_path):
+ db_path = tmp_path / "matrix.db"
+ store = SQLiteStore(str(db_path))
+ await set_selected_agent_id(store, "u1", "agent-2")
+ await set_room_meta(
+ store,
+ "!room:example.org",
+ {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"},
+ )
+
+ reopened = SQLiteStore(str(db_path))
+ assert await get_selected_agent_id(reopened, "u1") == "agent-2"
+ assert (await reopened.get("matrix_room:!room:example.org"))["agent_id"] == "agent-2"
+ assert (await reopened.get("matrix_room:!room:example.org"))["platform_chat_id"] == "41"
+
+
+@pytest.mark.asyncio
+async def test_platform_chat_sequence_survives_store_recreation(tmp_path):
+ db_path = tmp_path / "matrix.db"
+ store = SQLiteStore(str(db_path))
+
+ assert await next_platform_chat_id(store) == "1"
+ assert await next_platform_chat_id(store) == "2"
+
+ reopened = SQLiteStore(str(db_path))
+ assert await next_platform_chat_id(reopened) == "3"
+```
+
+- [ ] **Step 2: Run the restart-persistence tests to verify they fail**
+
+Run: `uv run pytest tests/adapter/matrix/test_restart_persistence.py -q`
+
+Expected: FAIL because `selected_agent_id` helpers do not exist yet or sequence persistence behavior is not explicitly covered.
+
+- [ ] **Step 3: Make sequence persistence explicit and document the restart boundary**
+
+```python
+# adapter/matrix/store.py
+PLATFORM_CHAT_SEQ_KEY = "matrix_platform_chat_seq"
+
+
+async def next_platform_chat_id(store: StateStore) -> str:
+ async with _PLATFORM_CHAT_SEQ_LOCK:
+ data = await store.get(PLATFORM_CHAT_SEQ_KEY)
+ index = int((data or {}).get("next_platform_chat_index", 1))
+ await store.set(PLATFORM_CHAT_SEQ_KEY, {"next_platform_chat_index": index + 1})
+ return str(index)
+```
+
+```markdown
+# README.md
+- Matrix durable state lives in `lambda_matrix.db` and `matrix_store`
+- normal restart is supported only when those paths survive container recreation
+- staged attachments and pending confirmations are intentionally not restored
+```
+
+- [ ] **Step 4: Run the restart-persistence tests to verify they pass**
+
+Run: `uv run pytest tests/adapter/matrix/test_restart_persistence.py -q`
+
+Expected: PASS
+
+- [ ] **Step 5: Run the combined verification sweep**
+
+Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_agent_handler.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_restart_persistence.py tests/platform/test_real.py -q`
+
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add adapter/matrix/store.py README.md tests/adapter/matrix/test_restart_persistence.py
+git commit -m "test: cover matrix restart state persistence"
+```
+
+---
+
+## Self-Review
+
+### Spec coverage
+
+- Multi-agent agent registry: Task 1
+- Shared `PlatformClient` preserved via routing facade: Task 2
+- `!agent` UX and durable `selected_agent_id`: Task 3
+- Unbound room activation, `!new`, stale room rejection: Task 4
+- Restart durability for user state, room state, and `PLATFORM_CHAT_SEQ_KEY`: Task 5
+
+### Placeholder scan
+
+- No `TODO`, `TBD`, or “implement later” markers remain.
+- Each task includes exact file paths, tests, commands, and minimal code snippets.
+
+### Type consistency
+
+- `selected_agent_id` lives in user metadata throughout the plan.
+- `agent_id` and `platform_chat_id` live in room metadata throughout the plan.
+- `RoutedPlatformClient` keeps the existing `PlatformClient` method names intact.
diff --git a/docs/superpowers/specs/2026-03-31-forum-topics-design.md b/docs/superpowers/specs/2026-03-31-forum-topics-design.md
new file mode 100644
index 0000000..1e7cb29
--- /dev/null
+++ b/docs/superpowers/specs/2026-03-31-forum-topics-design.md
@@ -0,0 +1,180 @@
+# Forum Topics Mode Design
+
+**Date:** 2026-03-31
+**Status:** Approved — ready for implementation
+**Scope:** `adapter/telegram/` — расширение существующего адаптера
+
+---
+
+## Контекст
+
+Forum Topics — опциональный advanced-режим поверх существующих виртуальных DM-чатов.
+Пользователь подключает свою Telegram-супергруппу с Topics — и его чаты появляются
+как нативные темы Telegram. DM и Forum работают **одновременно**: один контекст,
+две поверхности.
+
+---
+
+## Принцип работы
+
+Каждый чат (`chat_id` = UUID) получает опциональный `forum_thread_id`.
+
+- Пользователь пишет в DM → бот отвечает в DM с тегом `[Чат #N]`
+- Пользователь пишет в Forum-тему → бот отвечает в ту же тему (без тега)
+- Контекст (`chat_id`) один и тот же — платформа видит единый разговор
+
+---
+
+## БД — изменения схемы
+
+```sql
+ALTER TABLE tg_users ADD COLUMN forum_group_id INTEGER;
+ALTER TABLE chats ADD COLUMN forum_thread_id INTEGER;
+```
+
+`forum_group_id` — ID супергруппы пользователя (NULL если группа не подключена).
+`forum_thread_id` — ID темы в форуме (NULL если чат создан только в DM).
+
+Новые функции в `db.py`:
+```python
+def set_forum_group(tg_user_id: int, group_id: int) -> None
+def get_forum_group(tg_user_id: int) -> int | None
+def set_forum_thread(chat_id: str, thread_id: int) -> None
+def get_chat_by_thread(tg_user_id: int, thread_id: int) -> dict | None
+```
+
+---
+
+## Онбординг — `/forum`
+
+### FSM
+
+```python
+class ForumSetupState(StatesGroup):
+ waiting_for_group = State() # ждём пересылку из группы
+```
+
+### Флоу
+
+```
+/forum
+→ FSM: ForumSetupState.waiting_for_group
+→ "Создай супергруппу, включи Topics, добавь меня администратором
+ с правом управления темами. Затем перешли мне любое сообщение из группы."
+
+[пользователь пересылает сообщение]
+→ Проверить: forward_from_chat.type == "supergroup"
+→ Проверить права бота (администратор + can_manage_topics)
+ ❌ нет прав → объяснить что именно не так, остаться в состоянии
+→ Сохранить forum_group_id в БД
+→ Создать Forum-тему для каждого существующего активного DM-чата
+→ Записать forum_thread_id для каждого чата
+→ Ответить в DM: "✅ Группа подключена! Твои чаты теперь доступны в Forum-темах."
+→ FSM: clear
+```
+
+### Проверка прав
+
+```python
+async def check_forum_admin(bot: Bot, group_id: int) -> bool:
+ member = await bot.get_chat_member(group_id, (await bot.get_me()).id)
+ return (
+ member.status in ("administrator", "creator")
+ and getattr(member, "can_manage_topics", False)
+ )
+```
+
+---
+
+## Создание чатов — синхронизация
+
+### `/new` в DM (группа подключена)
+
+1. Создать UUID-запись в `chats` (как сейчас)
+2. `create_forum_topic(bot, group_id, chat_name)` → получить `thread_id`
+3. Записать `forum_thread_id` в БД
+4. Переключить FSM на новый чат
+5. Ответить в DM: `"✅ [chat_name] создан."`
+
+### `/new` в DM (группа НЕ подключена)
+
+Без изменений — только DM-чат.
+
+### `/new` в Forum-теме
+
+1. Определить `thread_id` из `message.message_thread_id`
+2. Создать UUID-запись в `chats` с `forum_thread_id = thread_id`
+3. Название: из аргумента `/new Название` или из названия темы (`message.chat.forum_topic_created.name` при создании — иначе запросить у Telegram)
+4. Ответить в теме: `"✅ Чат зарегистрирован. Пиши здесь!"`
+
+---
+
+## Маршрутизация сообщений
+
+### Определение источника
+
+```python
+def is_forum_message(message: Message) -> bool:
+ return message.message_thread_id is not None
+
+def resolve_chat_id(message: Message, tg_user_id: int) -> str | None:
+ if is_forum_message(message):
+ chat = db.get_chat_by_thread(tg_user_id, message.message_thread_id)
+ return chat["chat_id"] if chat else None
+ else:
+ # DM — берём active_chat_id из FSM StateData (как сейчас)
+ return None # caller reads from FSM
+```
+
+### Ответ
+
+- Пришло из DM → `bot.send_message(tg_user_id, f"[{chat_name}] {text}")`
+- Пришло из Forum-темы → `bot.send_message(group_id, text, message_thread_id=thread_id)`
+
+В Forum-теме тег `[Чат #N]` **не нужен** — тема сама является визуальным разделителем.
+
+---
+
+## Обработчики — изменения
+
+### `handlers/forum.py` (новый файл)
+
+```python
+router = Router(name="forum")
+
+@router.message(Command("forum"))
+async def cmd_forum(message, state): ... # запускает онбординг
+
+@router.message(ForumSetupState.waiting_for_group, F.forward_from_chat)
+async def handle_group_forward(message, state, dispatcher): ... # регистрирует группу
+```
+
+### `handlers/chat.py` — изменения
+
+- `handle_message`: если `is_forum_message` → брать `chat_id` из БД по `thread_id`, отвечать в тему
+- `cmd_new_chat`: ветвление по источнику (DM vs Forum) и наличию `forum_group_id`
+
+### `states.py` — добавить
+
+```python
+class ForumSetupState(StatesGroup):
+ waiting_for_group = State()
+```
+
+---
+
+## Что НЕ реализуем
+
+- Отслеживание создания тем пользователем без `/new` — Telegram не присылает событие создания темы в боте
+- Синхронизация удаления темы ↔ архивация DM-чата (только через команды)
+- Поддержка нескольких групп на одного пользователя
+
+---
+
+## Порядок реализации
+
+1. `db.py` — миграция + 4 новых функции
+2. `states.py` — `ForumSetupState`
+3. `handlers/forum.py` — `/forum` + onboarding
+4. `handlers/chat.py` — `cmd_new_chat` с ветвлением, `handle_message` с Forum-маршрутизацией
+5. `converter.py` — `is_forum_message`, `resolve_chat_id`
diff --git a/docs/superpowers/specs/2026-03-31-matrix-adapter-design.md b/docs/superpowers/specs/2026-03-31-matrix-adapter-design.md
new file mode 100644
index 0000000..44ff120
--- /dev/null
+++ b/docs/superpowers/specs/2026-03-31-matrix-adapter-design.md
@@ -0,0 +1,283 @@
+# Matrix Adapter Design
+
+**Date:** 2026-03-31
+**Status:** Approved — ready for implementation
+**Scope:** `adapter/matrix/`
+
+---
+
+## Контекст
+
+Matrix-адаптер — внутренняя поверхность для команды Lambda Lab: разработчики, тестировщики, авторы скиллов. UX ориентирован на удобство работы, не на онбординг.
+
+Адаптер конвертирует matrix-nio события в `IncomingEvent` (core protocol) и отправляет `OutgoingEvent` обратно. Бизнес-логика — в `core/`, адаптер только переводит форматы и управляет Matrix API.
+
+Клиент: Element (web/desktop). Стек: matrix-nio (async), Python 3.11+, SQLite.
+
+---
+
+## Онбординг — DM как первый чат (ленивый Space)
+
+**Решение:** DM-комната с ботом = Чат #1. Space создаётся только при первом `!new`.
+
+### Флоу — новый пользователь
+
+1. Пользователь инвайтит бота в личные сообщения
+2. Бот принимает инвайт, вызывает `platform.get_or_create_user(matrix_user_id, "matrix", display_name)`
+3. Бот регистрирует DM-комнату как `chat_room` с `chat_id = C1` в SQLite
+4. Бот пишет приветствие в DM — пользователь сразу пишет
+5. При первом `!new` — бот создаёт Space `Lambda — {display_name}`, добавляет DM-комнату в Space через `m.space.child`, создаёт новую комнату-чат
+
+### Флоу — возвращающийся пользователь
+
+Если `matrix_user_id` уже есть в БД (бот перезапустился, или пользователь пишет повторно) — `get_or_create_user` возвращает `is_new=False`. Бот не создаёт ничего заново, просто обрабатывает сообщение в контексте существующей комнаты.
+
+### Почему не Space сразу
+
+Создание Space при инвайте порождает 3 инвайта подряд (Space + Settings + Чат 1) до первого сообщения. DM-first убирает этот шум, сохраняя такой же UX как Telegram.
+
+### Приветствие
+
+```
+Привет, {display_name}! Пиши — я здесь.
+
+Команды: !new · !chats · !rename · !archive · !skills
+```
+
+---
+
+## Архитектура — Room-type routing
+
+При получении события адаптер сначала определяет тип комнаты (`chat` / `settings`), затем маршрутизирует в соответствующий обработчик.
+
+```
+adapter/matrix/
+ bot.py — matrix-nio клиент, sync loop
+ converter.py — RoomEvent → IncomingEvent, OutgoingEvent → Matrix API
+ room_router.py — определяет тип комнаты: chat | settings
+ states.py — FSM состояния (per room_id, SQLite)
+
+ handlers/
+ auth.py — invite → onboarding
+ chat.py — сообщения, !new, !chats, !rename, !archive
+ settings.py — !skills, !connectors, !soul, !safety, !plan, !status, !whoami
+ confirm.py — реакции 👍/❌ и команды !yes / !no
+
+ reactions.py — helpers: add_reaction, remove_reactions, parse_reaction_event
+```
+
+---
+
+## FSM состояния (per room_id)
+
+```python
+class RoomState(StatesGroup):
+ idle = State() # ждём сообщения
+ waiting_response = State() # запрос ушёл на платформу
+ confirm_pending = State() # ждём !yes/!no или реакцию 👍/❌
+ settings_active = State() # Settings-комната (не чат)
+```
+
+`room_type` хранится в SQLite. `room_router.py` читает его при каждом событии.
+
+---
+
+## Команды
+
+Все команды на английском. Работают в любой комнате Space.
+
+| Команда | Действие |
+|---------|---------|
+| `!new [name]` | Создать чат. При первом вызове — создаёт Space, переносит DM |
+| `!chats` | Список чатов с текущим активным |
+| `!rename ` | Переименовать текущую комнату |
+| `!archive` | Вывести комнату из Space (не удалять) |
+| `!skills` | Список скиллов — реакции как тумблеры |
+| `!connectors` | Коннекторы (OAuth заглушки) |
+| `!soul` | Личность агента |
+| `!safety` | Настройки безопасности |
+| `!plan` | Подписка и токены |
+| `!status` | Состояние платформы и чатов |
+| `!whoami` | Текущий аккаунт |
+| `!yes` / `!no` | Подтверждение / отмена действия агента |
+
+---
+
+## Settings room
+
+Создаётся при первом `!new` вместе со Space. Закреплена вверху Space.
+
+### Скиллы — реакции как тумблеры
+
+`!skills` → бот отправляет список. Каждый скилл пронумерован. Реакция 1️⃣–N️⃣ переключает соответствующий скилл (toggle). Несколько скиллов могут быть включены одновременно. Бот редактирует своё сообщение через `m.replace` после каждого переключения.
+
+```
+✅ 1 web-search — поиск в интернете
+✅ 2 fetch-url — чтение веб-страниц
+✅ 3 email — чтение почты
+❌ 4 browser — управление браузером
+❌ 5 image-gen — генерация изображений
+✅ 6 files — работа с файлами
+
+Реакция 1️⃣–6️⃣ = переключить скилл
+```
+
+### Остальные настройки
+
+`!connectors`, `!soul`, `!safety`, `!plan`, `!status`, `!whoami` — текстовые ответы, без интерактивных элементов. Поля задаются аргументами команды: `!soul name Lambda`, `!soul style brief`, `!safety on email-send`.
+
+---
+
+## Подтверждение действий агента
+
+Агент запрашивает подтверждение → бот отправляет сообщение с описанием действия. Пользователь подтверждает **реакцией или командой** — оба способа работают.
+
+```
+🤖 Lambda:
+Отправить письмо azamat@lambda.lab?
+Тема: «Отчёт за неделю»
+
+👍 подтвердить · ❌ отменить
+!yes — подтвердить · !no — отменить
+
+Истекает через 5 минут
+```
+
+После ответа: бот убирает реакции с сообщения, редактирует статус (`m.replace`), переходит в `idle`.
+
+FSM: `waiting_response` → `confirm_pending` → `idle`
+
+---
+
+## Долгие задачи — треды
+
+Если задача занимает больше одного хода — бот создаёт тред от своего первого сообщения.
+
+```
+🤖 Lambda (основной поток):
+Начинаю исследование «AI агенты 2025» — займёт 2-3 минуты.
+ 🧵 Прогресс (в треде):
+ └── ✅ Ищу источники... (12 найдено)
+ └── ✅ Анализирую статьи...
+ └── ⏳ Формирую отчёт...
+ └── ○ Финальная проверка
+```
+
+Основной поток не засоряется. Финальный результат — отдельным сообщением в основной поток.
+
+---
+
+## Typing indicator
+
+`m.typing` — отправлять перед запросом к платформе. Если запрос > 5 сек — возобновлять каждые 25 сек (Matrix typing живёт ~30 сек).
+
+---
+
+## Converter
+
+`adapter/matrix/converter.py` — конвертация в обе стороны.
+
+### matrix-nio → IncomingEvent
+
+```python
+def from_room_message(event: RoomMessageText, room_id: str, chat_id: str) -> IncomingMessage:
+ return IncomingMessage(
+ user_id=event.sender, # @user:matrix.org
+ platform="matrix",
+ chat_id=chat_id, # C1, C2... из rooms таблицы
+ text=event.body,
+ attachments=extract_attachments(event),
+ reply_to=event.replyto_event_id,
+ )
+
+def extract_attachments(event: RoomMessageText) -> list[Attachment]:
+ # m.image → Attachment(type="image", url=mxc_url, mime_type=...)
+ # m.file → Attachment(type="document", url=mxc_url, filename=..., mime_type=...)
+ # m.audio → Attachment(type="audio", url=mxc_url, mime_type=...)
+ # m.text → []
+ msgtype = getattr(event, "msgtype", "m.text")
+ if msgtype == "m.image":
+ return [Attachment(type="image", url=event.url, mime_type=event.mimetype)]
+ elif msgtype == "m.file":
+ return [Attachment(type="document", url=event.url,
+ filename=event.body, mime_type=event.mimetype)]
+ elif msgtype == "m.audio":
+ return [Attachment(type="audio", url=event.url, mime_type=event.mimetype)]
+ return []
+
+def from_reaction(event: ReactionEvent, room_id: str) -> IncomingCallback | None:
+ # Парсит m.reaction → IncomingCallback(action="toggle_skill" | "confirm" | "cancel")
+ ...
+
+def from_command(body: str, sender: str, room_id: str, chat_id: str) -> IncomingCommand | None:
+ # Парсит !new, !skills, !yes, !no и т.д. → IncomingCommand
+ ...
+```
+
+### OutgoingEvent → Matrix
+
+```python
+async def send_outgoing(client: AsyncClient, room_id: str, event: OutgoingEvent) -> None:
+ if isinstance(event, OutgoingMessage):
+ await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": event.text})
+
+ elif isinstance(event, OutgoingUI):
+ # Confirmation request — текст + подсказка по реакциям/командам
+ body = f"{event.text}\n\n👍 подтвердить · ❌ отменить\n!yes — подтвердить · !no — отменить"
+ await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body})
+ await client.room_send(room_id, "m.reaction", {...}) # добавить 👍 и ❌ на сообщение
+
+ elif isinstance(event, OutgoingTyping):
+ await client.room_typing(room_id, event.is_typing, timeout=25000)
+```
+
+---
+
+## БД схема
+
+```sql
+CREATE TABLE matrix_users (
+ matrix_user_id TEXT PRIMARY KEY, -- @user:matrix.org
+ platform_user_id TEXT NOT NULL, -- из MockPlatformClient
+ display_name TEXT,
+ space_id TEXT, -- NULL до первого !new
+ settings_room_id TEXT, -- NULL до первого !new
+ created_at TIMESTAMP
+);
+
+CREATE TABLE rooms (
+ room_id TEXT PRIMARY KEY, -- room_id Matrix
+ matrix_user_id TEXT NOT NULL,
+ room_type TEXT NOT NULL, -- 'chat' | 'settings'
+ chat_id TEXT, -- C1, C2... (NULL для settings)
+ display_name TEXT,
+ created_at TIMESTAMP,
+ archived_at TIMESTAMP,
+ FOREIGN KEY(matrix_user_id) REFERENCES matrix_users(matrix_user_id)
+);
+```
+
+`StateStore` из `core/store.py` (`SQLiteStore`) — для FSM per room_id.
+
+---
+
+## Что НЕ реализуем в прототипе
+
+- Webhook от платформы (используем sync `send_message`)
+- E2E encryption (nio поддерживает, но усложняет прототип)
+- Экспорт истории
+- `!rename`, `!archive` — добавить после основного флоу
+
+---
+
+## Порядок реализации
+
+1. `bot.py` — AsyncClient, sync loop, middleware для platform client
+2. `states.py` — RoomState
+3. `room_router.py` — определение типа комнаты
+4. `converter.py` — from_room_message, from_reaction, from_command
+5. `handlers/auth.py` — invite → onboarding
+6. `handlers/chat.py` — сообщения + !new + !chats
+7. `reactions.py` — helpers для работы с реакциями
+8. `handlers/confirm.py` — реакции 👍/❌ + !yes/!no
+9. `handlers/settings.py` — !skills с m.replace + остальные команды
diff --git a/docs/superpowers/specs/2026-04-01-telegram-forum-redesign.md b/docs/superpowers/specs/2026-04-01-telegram-forum-redesign.md
new file mode 100644
index 0000000..529eed1
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-01-telegram-forum-redesign.md
@@ -0,0 +1,180 @@
+# Telegram Forum Redesign — Forum-First Architecture
+
+**Date:** 2026-04-01
+**Replaces:** `2026-03-31-telegram-adapter-design.md` (DM+Forum hybrid)
+**Branch strategy:** New branch `feat/telegram-forum` from `main` (approach C: cherry-pick from `feat/telegram-adapter`)
+
+---
+
+## Overview
+
+Redesign the Telegram adapter to use Bot API 9.3 Threaded Mode as the sole interaction model. The user's private chat with the bot becomes a forum: each topic is an isolated AI agent context. No supergroup, no onboarding flow.
+
+---
+
+## File Structure
+
+**Carried over from `feat/telegram-adapter` (adapted):**
+- `adapter/telegram/keyboards/settings.py` — settings inline keyboards
+- `adapter/telegram/converter.py` — base conversion logic, rewritten for new context key
+
+**Written from scratch:**
+```
+adapter/telegram/
+ bot.py — entry point, router registration
+ db.py — SQLite schema and queries
+ handlers/
+ start.py — /start handler
+ message.py — incoming messages in topics
+ topic_events.py — forum_topic_created / edited / closed
+ commands.py — /new, /archive, /rename, /settings
+ keyboards/
+ settings.py — (from feat/telegram-adapter)
+```
+
+**Deleted entirely:**
+- `handlers/forum.py` — old supergroup onboarding
+- `handlers/chats.py` — chat switching via command
+- All `forum_group_id` references in db.py and elsewhere
+
+---
+
+## Database Schema
+
+```sql
+CREATE TABLE chats (
+ user_id INTEGER NOT NULL,
+ thread_id INTEGER NOT NULL,
+ chat_name TEXT NOT NULL DEFAULT 'Чат #1',
+ archived_at DATETIME,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (user_id, thread_id)
+);
+```
+
+**Context key:** `(user_id, thread_id)` — the canonical identifier for a chat context everywhere in the adapter.
+
+**Display number** ("Чат #1", "Чат #2") is not stored. Computed on demand:
+```sql
+ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at)
+```
+
+**workspace_id (C1/C2/C3)** is not stored. The adapter passes `thread_id` as `context_id` to the platform; the platform resolves the workspace mapping.
+
+**State** is managed via `core/store.py` with key `(user_id, thread_id)`. No aiogram FSM.
+
+---
+
+## Event Handling
+
+### Commands (`handlers/commands.py` + `handlers/start.py`)
+
+| Command | Behaviour |
+|---------|-----------|
+| `/start` | If no active topics: `create_forum_topic("Чат #1")` + `hide_general_forum_topic`. If topics exist: greeting only. Then check all non-archived topics for validity (see Error Handling). |
+| `/new` | `create_forum_topic("Чат #N")` where N = next display number. Insert row in DB. Send welcome message into new topic. |
+| `/archive` | `close_forum_topic(thread_id)`. Set `archived_at = now()` in DB. |
+| `/rename ` | `edit_forum_topic(thread_id, name)`. Update `chat_name` in DB. |
+| `/settings` | Global settings. Works from any topic. |
+
+### Incoming Messages (`handlers/message.py`)
+
+- Message in a topic → `converter.py` → `IncomingMessage(context_id=str(thread_id))` → `EventDispatcher`
+- Message in General topic (`message_thread_id is None`) → ignored silently
+
+### Topic UI Events (`handlers/topic_events.py`)
+
+| Event | Behaviour |
+|-------|-----------|
+| `forum_topic_created` | Register new chat in DB (native topic creation via UI) |
+| `forum_topic_edited` | Update `chat_name` in DB to match new Telegram topic name |
+| `forum_topic_closed` | Set `archived_at = now()` — automatic archive |
+
+---
+
+## Data Flow with Streaming
+
+```
+User → Telegram → aiogram router
+ → message.py handler
+ → converter.py: Message → IncomingMessage(context_id=thread_id)
+ → send placeholder "..." into topic
+ → EventDispatcher.dispatch(incoming)
+ → platform/mock.py (or real SDK)
+ → returns AsyncIterator[str] (chunks)
+ → for chunk in stream: edit_text(accumulated) every ~1.5s
+ → final edit_text with complete response
+ → StateStore.set((user_id, thread_id), new state)
+```
+
+**`platform/interface.py` change:**
+```python
+class PlatformClient(Protocol):
+ async def send_message(
+ self,
+ context_id: str,
+ text: str,
+ on_chunk: Callable[[str], Awaitable[None]] | None = None,
+ ) -> str: ...
+```
+
+`on_chunk` is optional. If the platform does not support streaming (mock), it is ignored and the full response is returned at once. The adapter shows "..." while waiting.
+
+---
+
+## Error Handling
+
+**Topic deleted by user**
+- Sending to topic raises `BadRequest: message thread not found`
+- Response: set `archived_at = now()` in DB, stop writing to that topic
+- Prevention: on `/start`, call `send_chat_action("typing")` for all non-archived topics; treat error as deleted → set `archived_at`
+
+**Platform unavailable**
+- Real SDK may raise connection/timeout errors
+- Response: edit placeholder → "Сервис временно недоступен, попробуй позже"
+- Do not archive the topic, do not change state
+
+**Threaded Mode not enabled**
+- `create_forum_topic` raises `BadRequest` if bot doesn't have Threaded Mode on
+- Response: `/start` replies with instruction to enable the mode in @BotFather
+- Only case where the bot explains a configuration problem
+
+**General rule:** errors are caught at the handler level, logged, and surfaced to the user as a message. The placeholder never stays as "...".
+
+---
+
+## Testing
+
+**Unit — `converter.py`**
+- `Message(thread_id=123)` → `IncomingMessage(context_id="123")`
+- `Message(thread_id=None)` (General) → `None` (ignored)
+
+**Unit — `db.py`**
+- Topic creation, archiving, renaming
+- `ROW_NUMBER()` display number computation
+- Existing `tests/adapter/test_forum_db.py` covers this
+
+**Integration — handlers (mocked bot)**
+- `/start` creates topic and hides General (`bot.create_forum_topic` mocked)
+- `forum_topic_closed` → `archived_at` set
+- `forum_topic_edited` → `chat_name` updated
+- Message in General → `EventDispatcher` not called
+
+**Out of scope for now:**
+- Streaming end-to-end with real Telegram
+- Stale topic recovery on `/start` (requires live bot)
+
+---
+
+## Decisions Log
+
+| Question | Decision | Rationale |
+|----------|----------|-----------|
+| Closed topic via UI | Auto-archive | Closing = intent to finish; keeping state in sync |
+| Renamed topic via UI | Sync to DB | Respect user intent; `/rename` is symmetric |
+| Commands | `/new`, `/archive`, `/rename`, `/settings` | UI and commands are parallel paths |
+| DB context key | `(user_id, thread_id)` | `thread_id` is the real identifier in this model |
+| FSM | `core/store.py` only | Avoids duplicating state logic; platform-agnostic |
+| workspace mapping | Platform responsibility | Adapter passes `thread_id` as `context_id`; platform resolves |
+| Streaming | In design via `on_chunk` | Proven pattern from supervisor's examples; `on_chunk` is optional |
+| Branch strategy | Cherry-pick (C) | New branch from `main`; carry over keyboards + converter base only |
diff --git a/docs/superpowers/specs/2026-04-08-matrix-direct-agent-prototype-design.md b/docs/superpowers/specs/2026-04-08-matrix-direct-agent-prototype-design.md
new file mode 100644
index 0000000..581eb56
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-08-matrix-direct-agent-prototype-design.md
@@ -0,0 +1,243 @@
+# Matrix Direct-Agent Prototype Design
+
+## Goal
+
+Ship a working Matrix prototype that talks to the real Lambda agent instead of `MockPlatformClient`, while preserving the current Matrix adapter logic and keeping the code expandable toward future platform versions.
+
+## Scope
+
+This design is for a Matrix-only prototype delivered from this repository on a dedicated branch. It is not a `main` branch rollout and it is not a separate prototype repo.
+
+The design assumes a minimal companion fork of `platform/agent` may be used, but changes there must stay as small as possible.
+
+## Constraints
+
+- Preserve the current Matrix transport logic as much as possible.
+- Keep `core/` unaware of platform immaturity.
+- Avoid broad changes to platform repos.
+- Prefer one narrow patch to `platform/agent` over changes to both `platform/agent` and `platform/agent_api`.
+- Keep the backend boundary reusable for future Telegram or other surfaces.
+- Do not pretend unsupported platform capabilities are real.
+
+## Live Platform Findings
+
+Based on the live repo analysis performed on April 7, 2026:
+
+- `platform/master` is not yet a usable consumer-facing backend for surfaces.
+- `platform/agent` exposes a working WebSocket endpoint for prompt/response exchange.
+- `platform/agent_api` documents and implements text-oriented WebSocket messaging, but the bot does not need to depend on that package directly.
+- `platform/agent` currently hardcodes a single shared backend memory thread via `thread_id = "default"`, which would cause all chats to share context.
+
+## Architecture
+
+The prototype remains in this repo and introduces a new real backend path behind the existing SDK boundary.
+
+### New files
+
+- `sdk/real.py`
+ - Exports `RealPlatformClient`
+ - Implements the existing `PlatformClient` contract from `sdk/interface.py`
+ - Composes the lower-level prototype pieces
+
+- `sdk/agent_session.py`
+ - Owns direct WebSocket communication with the real agent
+ - Manages connection lifecycle, request/response handling, and thread identity
+
+- `sdk/prototype_state.py`
+ - Owns local prototype-only state
+ - Stores user mapping, local settings, and lightweight metadata needed until a real control plane exists
+
+### Responsibility split
+
+- Matrix adapter remains transport-specific only.
+- `core/` continues to depend only on `PlatformClient`.
+- `RealPlatformClient` acts as the anti-corruption layer between the current bot contract and the platform’s incomplete shape.
+- Local control-plane behavior remains explicit and replaceable later.
+
+## Message and Identity Model
+
+Each Matrix chat gets a stable backend session identity.
+
+### Surface identity
+
+- Surface: `matrix`
+- Surface user id: Matrix MXID, for example `@alice:example.org`
+- Surface chat id: logical chat id from `ChatManager`, for example `C1`
+- Surface ref: Matrix room id
+
+### Backend thread identity
+
+Use a deterministic thread key:
+
+`matrix:{matrix_user_id}:{chat_id}`
+
+Example:
+
+`matrix:@alice:example.org:C1`
+
+### Mapping rules
+
+- One Matrix logical chat maps to one backend memory thread.
+- `!new` creates a fresh logical chat and therefore a fresh backend thread.
+- `!rename` only changes display metadata and does not change backend identity.
+- `!archive` stops active use of the thread in the surface, but does not need to delete backend memory in v1.
+
+## Runtime Flow
+
+### Normal message flow
+
+1. Matrix event arrives in an existing room.
+2. Existing Matrix routing resolves room to logical `chat_id`.
+3. `core/handlers/message.py` calls `platform.send_message(...)`.
+4. `RealPlatformClient` derives the backend thread key from `(platform, user_id, chat_id)`.
+5. `AgentSessionClient` sends the prompt to the agent WebSocket using that thread key.
+6. The reply is converted into the existing `MessageResponse` or `MessageChunk` contract.
+7. Matrix sends the final text back to the room.
+
+### Settings flow
+
+For v1, settings remain local:
+
+- `get_settings()` reads from local prototype state
+- `update_settings()` writes to local prototype state
+
+This is intentional. The prototype must not claim settings are backed by the real platform when no such platform API exists yet.
+
+## Feature Matrix
+
+### Real in v1
+
+- `!start`
+- Plain text messaging with the real agent
+- Matrix chat lifecycle already implemented in this repo:
+ - `!new`
+ - `!chats`
+ - `!rename`
+ - `!archive`
+- Per-chat conversation memory, provided the agent accepts dynamic thread identity
+
+### Local in v1
+
+- `!settings`
+- `!skills`
+- `!soul`
+- `!safety`
+- `!status`
+- user registration and local user mapping
+
+### Deferred
+
+- Attachments and file upload to the agent
+- Voice input to the agent
+- Image input to the agent
+- Long-running task callbacks and webhook-style async completion
+- Real control-plane integration through `platform/master`
+
+## Minimal Upstream Change
+
+To avoid shared memory across all conversations, make one narrow change in the forked `platform/agent` repo:
+
+- stop hardcoding `thread_id = "default"`
+- derive thread identity from WebSocket connection context
+
+### Preferred mechanism
+
+Read `thread_id` from WebSocket query parameters rather than changing the message payload format.
+
+Example:
+
+`ws://host:port/agent_ws/?thread_id=matrix:@alice:example.org:C1`
+
+This is preferred because:
+
+- it limits the platform patch to one repo
+- it avoids changing both server and SDK protocol shape
+- it keeps the client message body text-only
+- it makes session identity explicit and easy to reason about
+
+## Why Not Use `platform/agent_api` Directly
+
+The bot should not depend on their client package for the prototype.
+
+Reasons:
+
+- the bot already has its own internal integration boundary in `sdk/interface.py`
+- a tiny local WebSocket client is enough for this protocol
+- avoiding a dependency on `platform/agent_api` keeps rebasing simpler
+- if upstream stabilizes later, the bot can adopt their SDK without affecting Matrix handlers
+
+## Repo Strategy
+
+### This repo
+
+Owns:
+
+- Matrix surface logic
+- SDK compatibility layer
+- local prototype state
+- backend selection and wiring
+
+### Forked `platform/agent`
+
+Owns only:
+
+- minimal thread identity patch required for per-chat memory
+
+### Explicitly not doing
+
+- no separate prototype repo
+- no changes to `platform/master` for v1
+- no unnecessary changes to `platform/agent_api`
+
+## Migration Path
+
+This design is intentionally expandable.
+
+When the platform develops further:
+
+- `sdk/prototype_state.py` can be replaced or reduced by a real `MasterClient`
+- `sdk/agent_session.py` can remain the direct session transport if still relevant
+- `RealPlatformClient` can continue to present the stable bot-facing interface
+- Telegram or another surface can reuse the same backend components without rethinking the integration model
+
+## Risks
+
+### Risk: hidden platform assumptions leak upward
+
+Mitigation:
+- keep all direct-agent logic below `RealPlatformClient`
+- avoid changing `core/` contracts for prototype convenience
+
+### Risk: settings semantics drift from future platform reality
+
+Mitigation:
+- make local settings behavior explicit in code and docs
+- keep settings isolated in `sdk/prototype_state.py`
+
+### Risk: upstream `agent` fork diverges
+
+Mitigation:
+- keep the patch minimal and narrowly scoped to thread identity
+
+### Risk: thread identity source is unstable
+
+Mitigation:
+- derive thread key from existing stable bot-side identities: platform, surface user id, logical chat id
+
+## Testing Strategy
+
+- Unit tests for `sdk/agent_session.py` request/response behavior
+- Unit tests for `sdk/prototype_state.py` local settings and user mapping
+- Unit tests for `sdk/real.py` contract compliance with `PlatformClient`
+- Matrix integration tests confirming:
+ - existing commands still work
+ - different logical chats map to different backend thread keys
+ - rename does not change thread identity
+ - archive stops reuse from the surface perspective
+
+## Success Criteria
+
+- Matrix can talk to the real agent without rewriting the Matrix adapter architecture
+- Chats do not share backend memory accidentally
+- Unsupported platform capabilities remain local or deferred rather than being faked as “real”
+- The backend boundary remains suitable for later Telegram or other surfaces
diff --git a/docs/superpowers/specs/2026-04-19-matrix-per-chat-context-design.md b/docs/superpowers/specs/2026-04-19-matrix-per-chat-context-design.md
new file mode 100644
index 0000000..9807bd6
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-19-matrix-per-chat-context-design.md
@@ -0,0 +1,278 @@
+# Matrix Per-Chat Context Design
+
+## Goal
+
+Move the Matrix surface from the current shared-agent-context MVP to true per-chat agent contexts using the new platform `chat_id`, while preserving the existing Matrix UX model built around rooms, spaces, and local chat labels such as `C1`, `C2`, and `C3`.
+
+## Core Decision
+
+The Matrix surface remains the owner of user-facing chat organization.
+
+- Matrix rooms, spaces, chat names, and archive state remain surface concerns.
+- The platform agent becomes the owner of actual conversation context.
+- The integration layer stores an explicit mapping from each surface chat to one platform context.
+
+This is the selected "Variant A" architecture:
+
+`surface_chat -> platform_chat_id`
+
+## Why This Decision
+
+The current Matrix adapter already has a stable UX model:
+
+- a user has a space
+- each working room has a local chat id like `C1`
+- commands such as `!new`, `!chats`, `!rename`, and `!archive` operate on that model
+
+Replacing that entire model with platform-native chat ids would be a broader refactor than needed. The surface and the platform solve different problems:
+
+- the surface organizes rooms and commands for users
+- the platform persists and branches real conversation context
+
+Keeping those responsibilities separate lets us add true per-chat context now without rebuilding the Matrix adapter around a new identity model.
+
+## Scope
+
+This design covers:
+
+- true per-chat context for Matrix rooms
+- a new `!branch` command
+- real context-aware semantics for `!new`, `!context`, `!save`, and `!load`
+- lazy migration of legacy Matrix rooms created before platform `chat_id` support
+
+This design does not cover:
+
+- end-to-end Matrix encryption support
+- Telegram changes
+- platform UI for browsing contexts
+- a future unified cross-surface chat browser
+
+## Data Model
+
+### Surface chat identity
+
+The Matrix surface keeps its existing identifiers:
+
+- Matrix room id, for example `!room:example.org`
+- local chat id, for example `C2`
+- room name
+- archive status
+- owning space id
+
+These remain the source of truth for Matrix UX.
+
+### Platform context identity
+
+Each working Matrix room gets a `platform_chat_id` stored in its room metadata.
+
+Example `room_meta` shape:
+
+```json
+{
+ "chat_id": "C2",
+ "space_id": "!space:example.org",
+ "name": "Research",
+ "platform_chat_id": "chat_8f2c..."
+}
+```
+
+Rules:
+
+- one working Matrix room maps to exactly one current platform context
+- two Matrix rooms must not share the same `platform_chat_id` unless we intentionally implement that later
+- branching creates a new `platform_chat_id`, never reuses the old one
+
+## Runtime Semantics
+
+### Normal message flow
+
+1. A Matrix message arrives in a working room.
+2. The Matrix adapter resolves the room to local `room_meta`.
+3. The integration layer reads `platform_chat_id` from that metadata.
+4. `RealPlatformClient.send_message(...)` sends the message to the platform using that `platform_chat_id`.
+5. The platform appends the exchange to that specific context and returns the reply.
+6. The Matrix adapter sends the reply back to the room.
+
+The key change is that the agent no longer treats all Matrix rooms as one shared context.
+
+### `!new`
+
+`!new` creates a new user-facing chat and a new empty platform context at the same time.
+
+Flow:
+
+1. Create a new Matrix room in the user space.
+2. Ask the platform to create a new blank context and return its `platform_chat_id`.
+3. Store that `platform_chat_id` in the new room metadata.
+4. Invite the user into the room.
+
+Result:
+
+- the new room is immediately independent
+- sending the first message does not share memory with the previous room
+
+### `!branch`
+
+`!branch` creates a new room whose starting point is a snapshot of the current room context.
+
+Flow:
+
+1. Resolve the current room's `platform_chat_id`.
+2. Ask the platform to create a new context branched from that source.
+3. Create a new Matrix room.
+4. Store the new `platform_chat_id` in the new room metadata.
+5. Invite the user into the new room.
+
+Result:
+
+- the new room starts with the current history and state
+- later messages diverge independently
+
+### `!save`
+
+`!save [name]` saves a snapshot of the current room's platform context under the current user.
+
+Semantics:
+
+- saves are owned by the user, not by the room
+- the saved snapshot originates from the current `platform_chat_id`
+
+### `!load`
+
+`!load` shows the user's saved contexts and loads the chosen snapshot into the current room's platform context.
+
+Semantics:
+
+- a saved context created in one room can be loaded into any other room owned by the same user
+- loading does not replace the Matrix room identity
+- loading affects only the current room's mapped `platform_chat_id`
+
+### `!context`
+
+`!context` reports the state of the current room context, not a global user session.
+
+Minimum expected output:
+
+- current room name or local chat id
+- current `platform_chat_id` presence or status
+- what saved context, if any, was last loaded here
+- last token usage if the platform still returns it
+
+## Legacy Room Migration
+
+Existing Matrix rooms were created before real platform `chat_id` support and therefore may not have `platform_chat_id` in their metadata.
+
+We need a non-destructive migration.
+
+### Lazy migration strategy
+
+For a room without `platform_chat_id`:
+
+1. On the first operation that requires platform context, detect the missing mapping.
+2. Create a new blank platform context for that room.
+3. Persist the new `platform_chat_id` into room metadata.
+4. Continue the requested operation normally.
+
+This applies to:
+
+- first normal message
+- `!context`
+- `!save`
+- `!load`
+- `!branch`
+
+This avoids forcing users to recreate their rooms manually.
+
+## Interface Changes
+
+### Matrix metadata
+
+Extend Matrix `room_meta` helpers to read and write `platform_chat_id`.
+
+### Real platform client
+
+`RealPlatformClient` must stop treating the current `chat_id` parameter as a purely local label when talking to the platform. The surface-facing call can still receive the local `chat_id`, but platform calls must use the resolved `platform_chat_id`.
+
+Recommended integration direction:
+
+- Matrix resolves the room mapping before calling the platform
+- `RealPlatformClient` receives the platform context id it should use
+
+This keeps the platform client simple and avoids giving it Matrix-specific storage responsibilities.
+
+### Agent API wrapper
+
+The wrapper must support platform calls that are explicitly context-aware:
+
+- create new context
+- branch context
+- send message into a specific context
+- save current context
+- load saved context into a specific context
+
+If upstream naming differs, the adapter layer should normalize those operations into stable local methods.
+
+## Command Semantics in MVP
+
+The MVP command set should evolve to this:
+
+- `!new` creates a new room with a new empty platform context
+- `!branch` creates a new room with a branched platform context
+- `!context` reports the current room context
+- `!save` saves the current room context for the user
+- `!load` loads one of the user's saved contexts into the current room
+
+Commands that do not have reliable backend support should remain hidden or explicitly marked unavailable.
+
+## Error Handling
+
+### Missing mapping
+
+If `platform_chat_id` is missing:
+
+- try lazy migration first
+- only return an error if migration fails
+
+### Platform create or branch failure
+
+If the platform cannot create or branch a context:
+
+- do not create partially-initialized room metadata
+- return a user-facing error in the source room
+- log enough detail to diagnose the backend failure
+
+### Save and load failure
+
+The surface must not claim success before the platform confirms success.
+
+For MVP quality:
+
+- user-facing text should say "request sent" only when confirmation is not available
+- once platform confirmation exists, switch to real success or failure messages
+
+## Testing
+
+Add or update tests for:
+
+- a new room gets a new `platform_chat_id`
+- two rooms created with `!new` do not share context ids
+- `!branch` creates a new room with a different `platform_chat_id` derived from the current one
+- sending messages from two rooms uses different platform context ids
+- saved contexts remain user-visible across rooms
+- loading the same saved context into two different rooms affects those rooms independently afterward
+- a legacy room without `platform_chat_id` lazily receives one on first use
+- failures during create, branch, save, and load do not leave broken metadata behind
+
+## Migration Path
+
+This design preserves a clean future direction:
+
+- Matrix continues to own its UX model
+- Telegram can adopt the same `surface_chat -> platform_chat_id` mapping later
+- when the platform matures further, more of the save/load/branch logic can move from prompts or local workarounds into real platform APIs
+
+The key long-term boundary stays stable:
+
+- surfaces own presentation and routing
+- the platform owns context
+- the integration layer owns the mapping
diff --git a/docs/superpowers/specs/2026-04-20-matrix-shared-workspace-file-flow-design.md b/docs/superpowers/specs/2026-04-20-matrix-shared-workspace-file-flow-design.md
new file mode 100644
index 0000000..feca84c
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-20-matrix-shared-workspace-file-flow-design.md
@@ -0,0 +1,252 @@
+# Matrix Shared Workspace File Flow Design
+
+## Goal
+
+Bring the Matrix surface and `platform-agent` to a single file-handling model that matches the current platform runtime contract as closely as possible.
+
+The result should be:
+
+- Matrix receives user files and makes them visible to the agent through a shared `/workspace`
+- `platform-agent` receives attachment paths, not ad hoc summaries or inline payloads
+- the agent can send files back to the user through the surface via `send_file`
+- local development and the default deployment path use the same storage contract
+
+## Core Decision
+
+The selected architecture is:
+
+`Matrix surface <-> shared /workspace <-> platform-agent`
+
+This means:
+
+- the Matrix bot is responsible for downloading incoming Matrix media
+- downloaded files are written into the same filesystem mounted into `platform-agent`
+- the surface passes relative workspace paths to the agent as `attachments`
+- the agent returns files to the user by emitting `MsgEventSendFile(path=...)`
+
+This is the current platform-native direction and does not require new platform endpoints.
+
+## Why This Decision
+
+The current upstream platform changes already define the file contract:
+
+- `MsgUserMessage.attachments` is `list[str]`
+- each attachment is a path relative to `/workspace`
+- the agent validates those paths against its configured backend root
+- the agent can emit `send_file(path)` back to the client
+
+That is not an upload API and not a remote blob contract. It is an explicit shared-workspace contract.
+
+Trying to preserve the current separate-process launch model would force the surface to fake production behavior with inline text extraction, out-of-band path rewriting, or a future upload API that does not exist yet. That would increase the gap between our runtime and the platform runtime instead of reducing it.
+
+## Scope
+
+This design covers:
+
+- shared workspace runtime for Matrix bot and `platform-agent`
+- incoming Matrix file handling into shared storage
+- attachment path propagation to `RealPlatformClient` and `AgentApi`
+- outbound file delivery from agent to Matrix user
+- local compose/dev workflow and README updates
+
+This design does not cover:
+
+- Telegram file flow
+- encrypted Matrix media handling
+- upload APIs on the platform side
+- OCR, PDF parsing, or content extraction pipelines
+- long-term object storage or file lifecycle policies beyond basic cleanup boundaries
+
+## Runtime Contract
+
+### Shared filesystem
+
+Both containers must mount the same directory at `/workspace`.
+
+Requirements:
+
+- the Matrix bot can create files under `/workspace`
+- `platform-agent` sees the same files at the same relative paths
+- agent-originated files written under `/workspace` are readable by the Matrix bot
+
+The contract is path-based, not URL-based.
+
+### Attachment path format
+
+The surface sends attachments to the agent as relative workspace paths, for example:
+
+- `surfaces/matrix///inbox/20260420-153000-report.pdf`
+- `surfaces/matrix///inbox/20260420-153200-photo.jpg`
+
+Rules:
+
+- paths must be relative to `/workspace`
+- paths must be normalized before sending to the agent
+- surface-owned uploads must live under a dedicated namespace to avoid collisions with agent-created files
+
+## Data Flow
+
+### Incoming file from Matrix user
+
+1. Matrix receives `m.file`, `m.image`, `m.audio`, or `m.video`.
+2. The Matrix bot resolves the target room and platform chat context as usual.
+3. The Matrix bot downloads the media from Matrix.
+4. The file is stored under `/workspace/surfaces/matrix/.../inbox/...`.
+5. The outgoing platform call includes:
+ - original user text
+ - `attachments=[relative_path_1, ...]`
+6. `platform-agent` validates that those files exist and exposes them to the agent through the upstream attachment mechanism.
+
+Important detail:
+
+- the surface should not rewrite the user message into a synthetic file summary unless the message body is empty
+- when body is empty, the surface may send a minimal synthetic text such as `User sent one or more attachments.`
+
+### Outbound file from agent to Matrix user
+
+1. The agent uses `send_file(path)`.
+2. `platform-agent` emits `MsgEventSendFile(path=...)`.
+3. The Matrix integration catches that event.
+4. The Matrix bot resolves the file inside shared `/workspace`.
+5. The Matrix bot uploads the file to Matrix and sends the appropriate media message to the room.
+
+Surface behavior:
+
+- if MIME type and extension are known, send the closest native Matrix media type
+- otherwise send as `m.file`
+- user-visible failures must be explicit if the referenced file does not exist or cannot be uploaded
+
+## Filesystem Layout
+
+The Matrix surface owns a dedicated subtree:
+
+```text
+/workspace/
+ surfaces/
+ matrix/
+ /
+ /
+ inbox/
+ 20260420-153000-report.pdf
+```
+
+Design constraints:
+
+- sanitize user ids and room ids before using them as path components
+- preserve the original filename in the final basename where possible
+- prefix filenames with a timestamp or unique id to avoid collisions
+
+This layout is intentionally surface-scoped. The agent may read these files, but the surface remains the owner of how inbound messenger files are organized.
+
+## Components
+
+### Matrix attachment storage helper
+
+Add a focused helper module responsible for:
+
+- building stable workspace-relative paths
+- sanitizing path components
+- downloading Matrix media into `/workspace`
+- returning attachment metadata needed by the platform layer
+
+This helper should not know about agent transport details beyond the final relative path output.
+
+### Real platform client
+
+`RealPlatformClient` must pass attachment relative paths through to `AgentApi.send_message(...)`.
+
+It must also surface non-text agent events needed by the Matrix adapter, especially `MsgEventSendFile`.
+
+### Agent API wrapper
+
+`AgentApiWrapper` must be compatible with the modern upstream protocol:
+
+- `/v1/agent_ws/{chat_id}/`
+- `attachments` on outgoing user messages
+- `MsgEventToolCallChunk`
+- `MsgEventToolResult`
+- `MsgEventCustomUpdate`
+- `MsgEventSendFile`
+- `MsgEventEnd`
+
+### Matrix bot outbound renderer
+
+The Matrix adapter must support sending files back to the room.
+
+At minimum it needs:
+
+- path resolution inside shared workspace
+- Matrix upload of the local file
+- send of an `m.file` or native media event with filename and MIME type
+
+## Deployment Changes
+
+### Compose
+
+The repository root `docker-compose.yml` becomes the primary prod-like local runtime.
+
+It should define at least:
+
+- `matrix-bot`
+- `platform-agent`
+- one shared volume mounted as `/workspace` into both services
+
+The default developer workflow should stop describing `platform-agent` as a separately started side process.
+
+### Environment
+
+The Matrix bot must connect to the in-compose `platform-agent` service by service name, not by assuming a separately launched localhost process.
+
+The agent WebSocket configuration in docs and examples must match the modern upstream route.
+
+## Error Handling
+
+### Incoming files
+
+If the Matrix bot cannot download or persist the file:
+
+- do not send a broken attachment path to the agent
+- return a user-visible error in the room
+- log the Matrix event id, room id, and failure reason
+
+### Outbound files
+
+If the agent asks to send a missing file:
+
+- log a structured warning with the requested path
+- send a user-visible message that the file could not be delivered
+
+### Shared workspace mismatch
+
+If the runtime is misconfigured and `/workspace` is not actually shared:
+
+- inbound attachments will fail agent-side path validation
+- outbound `send_file` will fail surface-side file resolution
+
+The implementation should make such failures obvious in logs rather than silently degrading to text-only behavior.
+
+## Testing
+
+The implementation must cover:
+
+- Matrix media download writes into the expected workspace-relative path
+- `RealPlatformClient` forwards attachment relative paths to the agent API
+- Matrix plain messages with attachments preserve the original text while adding attachment paths
+- empty-body attachment-only messages produce the synthetic text fallback
+- `AgentApiWrapper` accepts `MsgEventSendFile` without treating it as unknown
+- Matrix outbound file handling converts `MsgEventSendFile` into a Matrix upload/send call
+- compose configuration mounts the same workspace into both containers
+
+## Non-Goals
+
+- no inline text extraction MVP
+- no temporary URL-passing contract to the agent
+- no fake “prod” mode with separate local filesystems
+- no platform API additions in this phase
+
+## Success Criteria
+
+- the default local runtime uses a shared `/workspace`
+- a user can send a file in Matrix and the agent receives it through upstream `attachments`
+- the agent can emit `send_file(path)` and the Matrix user receives the file in the same room
+- our runtime behavior matches the current platform contract closely enough that moving from local compose to production does not require redesigning file flow
diff --git a/docs/superpowers/specs/2026-04-20-matrix-staged-attachments-design.md b/docs/superpowers/specs/2026-04-20-matrix-staged-attachments-design.md
new file mode 100644
index 0000000..ae8a11a
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-20-matrix-staged-attachments-design.md
@@ -0,0 +1,262 @@
+# Matrix Staged Attachments Design
+
+## Goal
+
+Make file sending in the Matrix surface usable for an AI agent despite current Matrix client behavior, especially in Element where media is often sent immediately as separate events without a shared text composer.
+
+The result should be:
+
+- files can arrive before the user writes the actual instruction
+- the surface stages those files instead of immediately sending them to the agent
+- the next normal user message in the same chat commits all staged files as one agent turn
+- the user can inspect and remove staged files with short chat commands
+
+## Core Decision
+
+The selected UX model is:
+
+`incoming Matrix media -> staged attachments for (chat_id, user_id) -> next normal message commits them`
+
+This means:
+
+- attachment-only events do not immediately invoke the agent
+- the bot acknowledges staged files with a service message
+- the next normal user message sends text plus all currently staged files to the agent
+- staged files are then cleared
+
+## Why This Decision
+
+Matrix natively models messages as separate events, and common clients do not provide a reliable "one text message with many attachments" composer flow.
+
+In practice this causes two UX failures for an AI bot:
+
+- users may send files first and only then write the task
+- users may send multiple files as multiple independent Matrix events
+
+If the surface treats each incoming file as a full agent turn, the bot becomes noisy and context-fragmented. If it ignores file-only messages, file handling feels broken.
+
+Staging is the smallest surface-side abstraction that fixes both problems without fighting the Matrix event model.
+
+## Scope
+
+This design covers:
+
+- staging inbound Matrix attachments before agent submission
+- per-chat attachment state for a specific user
+- user-facing service messages for staged attachments
+- short commands for listing and removing staged files
+- commit behavior on the next normal message
+
+This design does not cover:
+
+- edits or redactions of original Matrix media events as attachment controls
+- cross-surface shared staging
+- thread-aware staging beyond the existing `chat_id` boundary
+- changes to the platform attachment contract
+
+## State Model
+
+### Staging key
+
+Staged attachments are isolated by:
+
+- `chat_id`
+- `user_id`
+
+This means:
+
+- files staged by a user in one chat never appear in another chat
+- files staged by one user do not mix with another user's files in the same room
+
+### Staged attachment record
+
+Each staged attachment must track at least:
+
+- stable internal id
+- display filename
+- workspace-relative path
+- MIME type if known
+- created timestamp
+
+User-visible commands operate on the current ordered list, not on internal ids.
+
+### Lifecycle
+
+A staged attachment is in exactly one of these states:
+
+1. `staged`
+2. `committed`
+3. `removed`
+
+Rules:
+
+- only `staged` attachments appear in `!list`
+- `committed` attachments are no longer user-removable
+- `removed` attachments are excluded from future commits
+
+## Inbound Behavior
+
+### Attachment-only event
+
+If the Matrix surface receives one or more file/media events from a user without a normal text message to commit them:
+
+1. download each file into shared `/workspace`
+2. add each file to the staged set for `(chat_id, user_id)`
+3. do not call the agent yet
+4. send a service acknowledgment message
+
+### Service acknowledgment
+
+The service message must communicate:
+
+- the current staged attachment list with indices
+- that the next normal message will be sent to the agent together with those files
+- available commands: `!list`, `!remove `, `!remove all`
+
+Example shape:
+
+```text
+Staged attachments:
+1. screenshot.png
+2. invoice.pdf
+
+Your next message will be sent to the agent with these files.
+Commands: !list, !remove , !remove all
+```
+
+### Burst handling
+
+Matrix clients may send multiple files as separate consecutive events.
+
+To avoid bot spam, service acknowledgments should be debounced over a short window and aggregated into one reply where feasible.
+
+The acknowledgment must reflect the full current staged set, not only the most recently received file.
+
+## Commit Behavior
+
+### Commit trigger
+
+The commit trigger is:
+
+- the next normal user message in the same `(chat_id, user_id)` scope
+
+Normal user message means:
+
+- not a staging control command
+- not a pure attachment event being staged
+
+### Commit action
+
+When a commit-triggering message arrives:
+
+1. collect all currently staged attachments for `(chat_id, user_id)`
+2. send the user text plus those attachments to the agent as one turn
+3. mark all included staged attachments as `committed`
+4. clear the staged set
+
+After commit:
+
+- the just-sent attachments must no longer appear in `!list`
+- a later file upload starts a new staged set
+
+## Commands
+
+### `!list`
+
+Shows the current staged attachment list for the user in the current chat.
+
+If the list is empty, the response should be short and explicit.
+
+### `!remove `
+
+Removes the staged attachment at the current 1-based index.
+
+Behavior:
+
+- if the index is valid, remove that staged attachment and return the updated staged list
+- if the index is invalid, return a short error without repeating the list
+
+### `!remove all`
+
+Clears the entire staged set for the user in the current chat.
+
+The response should be short and explicit.
+
+## Ordering Rules
+
+The staged list is ordered by staging time.
+
+User-facing indices:
+
+- are 1-based
+- are recalculated from the current staged set
+- may change after removals
+
+Therefore:
+
+- `!list` always shows the current authoritative numbering
+- after a successful `!remove `, the bot should reply with the refreshed list
+
+## Error Handling
+
+### Download failure
+
+If a file cannot be downloaded or stored:
+
+- do not add it to the staged set
+- do not pretend it will be sent later
+- send a short user-visible failure message
+
+### Invalid command
+
+If the command is malformed or uses an invalid index:
+
+- return a short error
+- do not commit staged attachments
+- do not clear the staged set
+
+### Agent submission failure
+
+If commit fails when sending the text plus staged files to the agent:
+
+- staged attachments must remain available for retry unless the failure is known to be irreversible
+- the user-visible error should make it clear that the files were not consumed
+
+This prevents silent loss of staged context.
+
+## Interaction with Shared Workspace Design
+
+This design assumes the shared-workspace contract defined in
+[2026-04-20-matrix-shared-workspace-file-flow-design.md](/Users/a/MAI/sem2/lambda/surfaces-bot/docs/superpowers/specs/2026-04-20-matrix-shared-workspace-file-flow-design.md).
+
+Specifically:
+
+- staged files are stored in shared `/workspace`
+- the final commit still passes workspace-relative paths to `platform-agent`
+- staging changes only when the surface chooses to invoke the agent, not how attachments are represented
+
+## Testing
+
+The implementation must cover:
+
+- file-only Matrix events are staged and do not immediately invoke the agent
+- service acknowledgment includes staged filenames and command hints
+- `!list` returns the current staged set for the correct `(chat_id, user_id)`
+- `!remove ` removes the correct staged attachment and refreshes numbering
+- `!remove all` clears the staged set
+- invalid `!remove ` returns a short error and keeps state unchanged
+- the next normal message commits all staged attachments with the text as one agent turn
+- committed attachments disappear from staging after success
+- failed commits preserve staged attachments
+- staging in one chat does not leak into another chat
+- staging for one user does not leak to another user in the same room
+
+## Non-Goals
+
+This design intentionally does not attempt to:
+
+- emulate Telegram-style albums in Matrix
+- rely on special support from Element or other Matrix clients
+- introduce a rich interactive attachment management UI
+
+The goal is a reliable chat-native workflow that works within Matrix's actual event model.
diff --git a/docs/superpowers/specs/2026-04-22-transport-layer-thin-adapter-design.md b/docs/superpowers/specs/2026-04-22-transport-layer-thin-adapter-design.md
new file mode 100644
index 0000000..5fab5ef
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-22-transport-layer-thin-adapter-design.md
@@ -0,0 +1,318 @@
+# Transport Layer Thin Adapter Design
+
+## Цель
+
+Упростить transport layer между Matrix surface и `platform-agent` до максимально production-like вида:
+
+- использовать upstream `platform-agent_api.AgentApi` почти как есть
+- убрать из surface-side клиента собственную интерпретацию stream semantics
+- оставить в нашем коде только integration concerns:
+ - per-chat lifecycle
+ - per-chat serialization
+ - attachment path forwarding
+ - exception mapping в `PlatformError`
+
+Это нужно, чтобы:
+
+- восстановить чёткую границу ответственности между `surfaces` и платформой
+- убрать из диагностики ложные факторы, внесённые нашей кастомной обёрткой
+- получить честную картину реальных platform bugs до добавления любых policy-надстроек
+
+## Контекст
+
+Сейчас transport path состоит из:
+
+- [sdk/agent_api_wrapper.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/agent_api_wrapper.py)
+- [sdk/real.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/real.py)
+
+Изначально `AgentApiWrapper` был создан по разумным причинам:
+
+- поддержка переходного периода между разными версиями `platform-agent_api`
+- унификация `base_url/url`
+- создание per-chat client instances через `for_chat()`
+- локальный учёт `tokens_used`
+
+Позже в этот слой были добавлены уже не compatibility-функции, а собственные transport semantics:
+
+- custom `_listen()`
+- custom `send_message()`
+- post-END drain window
+- custom idle timeout
+- event-kind reclassification
+
+После этого `surfaces` перестал быть тонким клиентом платформы и начал вести себя как альтернативная реализация transport layer. Это делает диагностику platform bugs нечистой.
+
+## Принципы дизайна
+
+### 1. Transport должен быть скучным
+
+Transport layer не должен:
+
+- спасать поздние chunks
+- лечить duplicate `END`
+- придумывать собственные правила границы ответа
+- по-своему классифицировать stream events сверх upstream client behavior
+
+Если upstream stream protocol повреждён, мы должны видеть это как platform issue, а не скрывать его кастомной очередью.
+
+### 2. Policy и transport разделяются
+
+Transport:
+
+- говорит с upstream API
+- доставляет события
+- закрывает соединение
+
+Policy:
+
+- решает, что считать recoverable failure
+- нужна ли повторная попытка
+- как сообщать ошибку пользователю
+- нужно ли сбрасывать chat session
+
+На первом этапе policy не расширяется. Мы сначала приводим transport к тонкому адаптеру, потом заново оцениваем реальные проблемы.
+
+### 3. Session lifecycle остаётся на нашей стороне
+
+Даже в thin-adapter модели `surfaces` по-прежнему отвечает за:
+
+- кеширование client per chat
+- один send lock на chat
+- сброс мёртвой chat session после failure
+- mapping upstream exceptions в `PlatformError`
+
+Это не transport semantics, а integration lifecycle.
+
+## Варианты
+
+### Вариант A. Оставить текущий кастомный wrapper
+
+Плюсы:
+
+- уже работает на части сценариев
+- содержит built-in mitigations против observed failures
+
+Минусы:
+
+- нарушает границу ответственности
+- усложняет диагностику
+- делает platform bug reports спорными
+- содержит symptom-fix логику в transport layer
+
+Вердикт: не подходит как production-like target.
+
+### Вариант B. Thin upstream adapter
+
+Плюсы:
+
+- чистая архитектура
+- честная диагностика upstream проблем
+- минимальная собственная магия
+
+Минусы:
+
+- локальные mitigations исчезают
+- если upstream client несовершенен, это сразу проявится
+
+Вердикт: правильный первый этап.
+
+### Вариант C. Thin adapter сейчас, outer policy layer потом
+
+Плюсы:
+
+- даёт production-like эволюцию
+- не смешивает transport и resilience policy
+- позволяет сначала увидеть реальные проблемы, потом адресовать только нужные
+
+Минусы:
+
+- требует двух фаз вместо одной
+
+Вердикт: рекомендуемый путь.
+
+## Рекомендуемая архитектура
+
+### Слой 1. Upstream client
+
+Источник истины:
+
+- [external/platform-agent_api/lambda_agent_api/agent_api.py](/Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent_api/lambda_agent_api/agent_api.py)
+
+Мы принимаем его stream semantics как authoritative behavior.
+
+### Слой 2. Thin adapter
+
+Файл:
+
+- [sdk/agent_api_wrapper.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/agent_api_wrapper.py)
+
+После cleanup он должен содержать только:
+
+- создание клиента через modern constructor
+- `base_url` normalization, если это действительно нужно для наших env
+- `for_chat(chat_id)` как factory convenience
+- опционально thin storage для `last_tokens_used`, если это можно сделать без переопределения stream semantics
+
+Он не должен переопределять:
+
+- `_listen()`
+- `send_message()`
+- queue lifecycle
+- post-END behavior
+- timeout behavior
+
+### Слой 3. Integration/session layer
+
+Файл:
+
+- [sdk/real.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/real.py)
+
+Ответственность:
+
+- кешировать chat client instances
+- сериализовать sends по chat lock
+- вызывать `disconnect_chat(chat_id)` после transport failure
+- превращать upstream exceptions в `PlatformError`
+- форвардить `attachments` как relative workspace paths
+- собирать `MessageResponse` / `MessageChunk` для остального приложения
+
+Этот слой не должен заниматься:
+
+- исправлением broken stream boundaries
+- custom post-END reconstruction
+- поздним дренированием очереди
+
+## Что удаляем
+
+Из [sdk/agent_api_wrapper.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/agent_api_wrapper.py):
+
+- custom `_listen()`
+- custom `send_message()`
+- `_drain_post_end_events()`
+- `_event_kind()`
+- `_is_kind()`
+- `_is_text_event()`
+- `_is_end_event()`
+- `_is_send_file_event()`
+- `_POST_END_DRAIN_MS`
+- `_STREAM_IDLE_TIMEOUT_MS`
+- debug logging, завязанное на наш собственный queue lifecycle
+
+## Что оставляем
+
+В thin adapter:
+
+- `__init__()` для modern `base_url/chat_id`
+- `_normalize_base_url()` только если нужен стабильный env input
+- `for_chat(chat_id)`
+
+В [sdk/real.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/real.py):
+
+- `_get_chat_api()`
+- `_get_chat_send_lock()`
+- `_attachment_paths()`
+- `disconnect_chat()`
+- `_handle_chat_api_failure()`
+- `send_message()`
+- `stream_message()`
+
+## Дополнительное упрощение
+
+Если после cleanup мы считаем pinned upstream API обязательным, то из [sdk/real.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/real.py) можно убрать legacy-minded probing:
+
+- `inspect.signature(send_message)`
+- conditional fallback на старый `send_message(text)` без `attachments`
+
+В этом случае `RealPlatformClient` всегда использует современный контракт:
+
+- `send_message(text, attachments=...)`
+
+Это ещё сильнее уменьшит ambiguity.
+
+## Этапы миграции
+
+### Этап 1. Cleanup до thin adapter
+
+Делаем:
+
+- сжимаем `sdk/agent_api_wrapper.py` до thin shim
+- переносим всю допустимую resilience logic только в `sdk/real.py`
+- удаляем тесты, которые закрепляют наши кастомные transport semantics
+
+### Этап 2. Повторная верификация
+
+Заново прогоняем:
+
+- text-only flow
+- staged attachments flow
+- large image failure
+- duplicate `END` behavior
+- behavior after transport disconnect
+
+На этом этапе мы честно увидим, что реально делает upstream transport.
+
+### Этап 3. Опциональный outer policy layer
+
+Только если после Этапа 2 это действительно нужно, добавляем policy поверх transport:
+
+- request timeout целиком
+- retry policy
+- circuit-breaker-like behavior
+
+Но это должно жить не в client wrapper, а выше, в integration layer.
+
+## Тестовая стратегия
+
+### Удаляем как нецелевые тесты
+
+Больше не считаем нормой:
+
+- post-END drain behavior
+- recovery late chunks после `END`
+- idle timeout внутри wrapper как часть client contract
+
+### Оставляем и добавляем
+
+Нужные guarantees:
+
+1. создаётся отдельный client per chat
+2. один chat сериализуется через lock
+3. разные чаты не делят client instance
+4. attachment paths уходят в `send_message(..., attachments=...)`
+5. transport failure приводит к `disconnect_chat(chat_id)`
+6. следующий запрос после failure открывает новую chat session
+7. upstream exception превращается в `PlatformError`
+
+## Риски
+
+### 1. Может снова проявиться реальный upstream bug
+
+Это не regression дизайна, а полезный результат cleanup.
+
+### 2. Может исчезнуть локальная защита от зависших стримов
+
+Это допустимо на первом этапе.
+Если она действительно нужна, она должна вернуться как outer policy, а не как переписанный client transport.
+
+### 3. Может выясниться, что даже thin wrapper не нужен
+
+Если modern upstream `AgentApi` уже полностью покрывает наш use case, файл `sdk/agent_api_wrapper.py` можно будет заменить на маленький factory helper или убрать совсем.
+
+## Критерии успеха
+
+Результат считается успешным, если:
+
+- transport layer в `surfaces` перестаёт иметь собственную stream semantics
+- platform bug reports снова можно формулировать без сильного caveat про кастомный клиент
+- Matrix real backend продолжает работать на text-only и attachments scenarios
+- failure handling остаётся контролируемым, но больше не маскирует transport behavior платформы
+
+## Решение
+
+Принять путь:
+
+- `Thin upstream adapter now`
+- `Observe real behavior`
+- `Add outer policy later only if needed`
+
+Это наиболее близкий к production best practice вариант для текущего состояния проекта.
diff --git a/docs/superpowers/specs/2026-04-24-matrix-multi-agent-routing-design.md b/docs/superpowers/specs/2026-04-24-matrix-multi-agent-routing-design.md
new file mode 100644
index 0000000..02cc89f
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-24-matrix-multi-agent-routing-design.md
@@ -0,0 +1,336 @@
+# Matrix Multi-Agent Routing Design
+
+## Goal
+
+Move the Matrix surface from a single hardcoded upstream agent to a user-selectable multi-agent model, while preserving the existing room-based UX and the current `PlatformClient` boundary.
+
+The result should be:
+
+- one Matrix bot can work with multiple upstream agents
+- users can choose an agent from the full configured list
+- each chat is bound to exactly one agent
+- switching the selected agent does not silently retarget an existing chat
+
+## Core Decision
+
+The selected routing model is:
+
+`user.selected_agent_id + room.agent_id + room.platform_chat_id`
+
+This means:
+
+- the user has one current selected agent
+- each Matrix working room stores the agent it is bound to
+- each Matrix working room stores its own `platform_chat_id`
+- a room never changes agent implicitly
+- the shared `PlatformClient` protocol remains unchanged
+- Matrix multi-agent routing is implemented by a single routing facade that delegates to per-agent real clients
+
+## Why This Decision
+
+The current Matrix adapter already separates:
+
+- user-facing room organization
+- local chat labels such as `C1`, `C2`, `C3`
+- platform-facing conversation identity via `platform_chat_id`
+
+Adding multi-agent support should preserve that shape instead of replacing it.
+
+If routing depended only on the current user selection, then an old room could start talking to a different agent after a switch. That would make room history and backend context hard to reason about. Binding an agent to the room keeps the conversation model explicit.
+
+## Scope
+
+This design covers:
+
+- agent selection by the user inside the Matrix surface
+- durable storage of the selected agent
+- durable storage of the room-bound agent
+- routing normal messages and context commands to the correct upstream agent
+- behavior when a room becomes stale after an agent switch
+
+This design does not cover:
+
+- per-agent workspace isolation
+- platform-side agent lifecycle or memory persistence
+- per-user allowlists for available agents
+- Telegram or other surfaces
+
+## Configuration Model
+
+### Agent registry
+
+Available agents are defined in a local config file loaded once at bot startup.
+
+Example:
+
+```yaml
+agents:
+ - id: agent-1
+ label: Analyst
+ - id: agent-2
+ label: Research
+ - id: agent-3
+ label: Ops
+```
+
+Rules:
+
+- every entry must have a stable `id`
+- every entry must have a user-visible `label`
+- all configured agents are selectable by all users
+- config changes apply only after bot restart
+
+### Startup validation
+
+If the agent config is missing, empty, or invalid, the Matrix bot must fail fast on startup with a clear operator error.
+
+## Durable State Model
+
+### User-level state
+
+User metadata keeps the current selected agent.
+
+Example `matrix_user:*` shape:
+
+```json
+{
+ "space_id": "!space:example.org",
+ "next_chat_index": 4,
+ "selected_agent_id": "agent-2"
+}
+```
+
+Meaning:
+
+- `selected_agent_id` controls future chat creation and activation of an unbound room
+- `selected_agent_id` does not rewrite already bound rooms
+
+### Room-level state
+
+Room metadata stores the agent bound to that chat.
+
+Example `matrix_room:*` shape:
+
+```json
+{
+ "room_type": "chat",
+ "chat_id": "C3",
+ "display_name": "Чат 3",
+ "matrix_user_id": "@alice:example.org",
+ "space_id": "!space:example.org",
+ "platform_chat_id": "42",
+ "agent_id": "agent-2"
+}
+```
+
+Rules:
+
+- one room binds to exactly one `agent_id`
+- one room binds to exactly one current `platform_chat_id`
+- once a room becomes stale after an agent switch, it never becomes active again
+
+## Runtime Semantics
+
+### `!start`
+
+`!start` remains lightweight:
+
+- if no agent is selected, the bot explains that an agent must be selected before normal messaging
+- if an agent is already selected, the bot reports the current selection and reminds the user that `!new` creates a new room under that agent
+
+### `!agent`
+
+Introduce an agent-selection command.
+
+Behavior:
+
+- `!agent` shows the available agent list
+- agent selection stores `selected_agent_id` in user metadata
+- after a successful switch, the bot tells the user that existing chats bound to another agent are stale and that `!new` is required for continued work
+
+The exact UI can be text-first for MVP. A richer UI can be added later without changing the state model.
+
+### Normal message without selected agent
+
+If the user has not selected an agent yet:
+
+- do not call the platform
+- return the available agent list
+- ask the user to choose one first
+
+This is an intentional one-time routing handshake, not an accidental fallback.
+In a multi-agent deployment, the surface must not silently guess which agent an unbound user should talk to.
+
+### Selecting an agent inside an unbound chat
+
+If the current room has never been bound to any agent:
+
+- store the new `selected_agent_id` for the user
+- bind the current room to that same `agent_id`
+- allow the room to become the active working chat immediately
+
+This avoids forcing `!new` for the user's first usable chat.
+
+### `!new`
+
+`!new` creates a new working room under the current selected agent.
+
+Behavior:
+
+1. require `selected_agent_id`
+2. create the new Matrix room
+3. allocate a new `platform_chat_id`
+4. store `agent_id = selected_agent_id` in the new room metadata
+
+### Normal message in an unbound room with selected agent
+
+If a room exists but has no `agent_id` yet and the user already has `selected_agent_id`:
+
+- bind the room to `selected_agent_id`
+- ensure it has `platform_chat_id`
+- continue normal message dispatch
+
+### Normal message in a bound room
+
+If the room already has `agent_id` and it matches the current selected agent:
+
+- route the message to that `agent_id`
+- use the room's `platform_chat_id`
+
+### Stale room after agent switch
+
+If the room's bound `agent_id` differs from the user's current `selected_agent_id`:
+
+- do not call the platform
+- treat the room as stale
+- return a short message telling the user that this chat belongs to the old agent and that they must use `!new`
+
+### Returning to a previously selected agent
+
+If the user later selects an old agent again:
+
+- previously stale rooms do not become valid again
+- the user must still create a fresh room via `!new`
+
+## Routing and Component Changes
+
+### Agent registry loader
+
+Add a small loader responsible for:
+
+- reading `agents.yaml`
+- validating ids and labels
+- exposing a read-only registry to runtime code
+
+The runtime should not parse YAML ad hoc during message handling.
+
+### Matrix runtime pre-check
+
+Before dispatching a normal message, the Matrix runtime must resolve:
+
+- whether the user has `selected_agent_id`
+- whether the current room already has `agent_id`
+- whether the room can be bound now
+- whether the room is stale
+
+This pre-check happens before handing the message to the existing dispatcher path.
+
+### Routed platform client
+
+The selected implementation keeps the shared `PlatformClient` protocol unchanged.
+
+The Matrix runtime owns one routing-aware facade, for example `RoutedPlatformClient`, that implements `PlatformClient` and delegates to agent-specific real clients.
+
+Responsibilities:
+
+- resolve the current room binding from local Matrix metadata
+- translate a local Matrix logical chat id into the room's `platform_chat_id`
+- choose the correct per-agent delegate for the room's bound `agent_id`
+- keep `get_or_create_user`, `get_settings`, and `update_settings` behavior stable for the rest of the runtime
+
+This keeps the multi-agent logic inside the Matrix integration boundary instead of pushing agent selection into the shared protocol.
+
+### Real platform bridge delegates
+
+The current real backend path hardcodes a single runtime-level `agent_id`.
+That must be replaced with per-agent delegates hidden behind the routing facade.
+
+The selected design is:
+
+- `RealPlatformClient` remains the low-level direct-agent delegate for one configured `agent_id`
+- the routing facade holds or creates one `RealPlatformClient` delegate per configured agent
+- `send_message(...)` and `stream_message(...)` on the facade resolve the room target and forward the call to the matching delegate
+- the delegate creates a fresh upstream `AgentApi` for its configured `agent_id`
+- no long-lived `AgentApi` instances are cached by user
+
+This preserves the current fresh-connection-per-request behavior while avoiding a protocol break for Telegram or other surfaces.
+
+## Error Handling
+
+### Missing or invalid selected agent
+
+If `selected_agent_id` is absent:
+
+- ask the user to select an agent
+
+If `selected_agent_id` points to an agent that no longer exists in config:
+
+- treat the selection as invalid
+- ask the user to select again
+
+### Missing room binding
+
+If the room has no `agent_id`:
+
+- bind it only when the user has a valid current selection
+- otherwise return the selection prompt
+
+### Stale room
+
+If the room is stale:
+
+- do not attempt fallback routing
+- do not silently rewrite room metadata
+- instruct the user to run `!new`
+
+### Invalid config
+
+If the bot cannot load a valid agent registry:
+
+- fail at startup
+- do not start in degraded single-agent mode
+
+## Testing Expectations
+
+Tests for this design should prove:
+
+- config parsing and startup validation
+- selecting an agent persists `selected_agent_id`
+- selecting an agent inside an unbound room activates that room
+- `!new` binds the new room to the selected agent
+- messages in a bound room use that room's `agent_id`
+- stale rooms reject normal messaging with a clear `!new` instruction
+- returning to the same agent later does not revive stale rooms
+
+## Migration Notes
+
+Existing rooms may have `platform_chat_id` but no `agent_id`.
+
+For this MVP, treat those rooms as legacy-unbound rooms:
+
+- if the user has a valid selected agent, the room may be bound on first use
+- if no agent is selected, the room prompts for selection first
+
+No automatic migration across agents is introduced.
+
+### Existing users without `selected_agent_id`
+
+Existing users upgraded from the single-agent model may have working rooms but no stored `selected_agent_id`.
+
+For this MVP, that is handled explicitly:
+
+- normal messaging is paused until the user selects an agent
+- the first valid selection can bind an unbound room immediately
+- the surface does not auto-assign a default agent in a multi-agent config
+
+This is intentional. Once more than one agent exists, silent migration would be ambiguous and could route a user to the wrong backend target.
diff --git a/docs/superpowers/specs/2026-04-24-matrix-surface-restart-state-persistence-design.md b/docs/superpowers/specs/2026-04-24-matrix-surface-restart-state-persistence-design.md
new file mode 100644
index 0000000..1f1cc7b
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-24-matrix-surface-restart-state-persistence-design.md
@@ -0,0 +1,258 @@
+# Matrix Surface Restart State Persistence Design
+
+## Goal
+
+Make the Matrix surface survive a normal restart or container recreate without losing the minimal state required to keep working as a bot.
+
+The result should be:
+
+- after restart, the bot can still answer messages and execute commands
+- the bot remembers the selected agent for each user
+- the bot remembers which agent and `platform_chat_id` each room is bound to
+- temporary UX flows may be lost without being treated as a bug
+
+## Core Decision
+
+The selected persistence model is:
+
+`durable surface state only`
+
+This means:
+
+- persist only the state needed for routing and normal command handling
+- do not persist temporary UI and wizard state
+- require persistent local storage for the surface
+- do not attempt recovery if those volumes are lost
+
+## Why This Decision
+
+The Matrix surface already has two different classes of state:
+
+- stable local state that defines how rooms and users are routed
+- temporary UX state that exists only to complete short-lived interactions
+
+Trying to make all temporary UX state survive restart would add complexity and edge cases without improving the core requirement: the bot should still function normally after restart.
+
+The chosen design keeps persistence aligned with what the surface actually owns:
+
+- Matrix-side metadata and routing state are durable
+- agent conversation memory is the platform's responsibility
+- lost local volumes are treated as environment reset, not as an auto-recovery scenario
+
+## Scope
+
+This design covers:
+
+- which Matrix surface data must persist across restart
+- where that data lives
+- how restart behavior interacts with multi-agent routing
+- what state is intentionally non-durable
+
+This design does not cover:
+
+- platform-side persistence of agent memory
+- workspace isolation between multiple agents
+- automatic reconstruction after total local volume loss
+- persistence of temporary UX flows
+
+## Persistence Boundary
+
+### Durable state
+
+The Matrix surface must persist:
+
+- `matrix_user:*`
+- `matrix_room:*`
+- `chat:*`
+- `PLATFORM_CHAT_SEQ_KEY`
+- `selected_agent_id`
+- room-bound `agent_id`
+- room-bound `platform_chat_id`
+
+This is the minimal state required so that, after restart, the surface can:
+
+- identify the user
+- identify the room
+- determine which agent should receive a message
+- determine which `platform_chat_id` should be used
+- continue allocating new `platform_chat_id` values without reusing an already issued sequence number
+
+### Non-durable state
+
+The Matrix surface does not need to persist:
+
+- staged attachments
+- pending `!load` selection
+- pending `!yes/!no` confirmation
+- any temporary service UI step
+- live `AgentApi` instances or connection objects
+
+After restart, those flows may be lost. The bot only needs to remain operational.
+
+## Storage Model
+
+### Surface durable storage
+
+The Matrix surface must use persistent storage for:
+
+- `lambda_matrix.db`
+- `matrix_store`
+
+`lambda_matrix.db` stores the local key-value state used by the surface.
+`matrix_store` stores Matrix client state needed by `nio`.
+
+These paths must be backed by persistent container storage in normal deployments.
+
+### Shared `/workspace`
+
+The current local runtime also uses `/workspace`, but workspace behavior is outside the scope of this design.
+
+For this document, the only requirement is:
+
+- do not make restart persistence depend on solving per-agent workspace isolation first
+
+## Restart Assumptions
+
+This design assumes:
+
+- normal restart or redeploy with persistent local volumes still present
+
+This design does not assume:
+
+- automatic recovery after deleting or losing those volumes
+
+If the relevant volumes are lost, the environment is treated as reset.
+
+## Data Model Requirements
+
+### User metadata
+
+User metadata remains the durable location for user-level routing state.
+
+Example:
+
+```json
+{
+ "space_id": "!space:example.org",
+ "next_chat_index": 4,
+ "selected_agent_id": "agent-2"
+}
+```
+
+### Room metadata
+
+Room metadata remains the durable location for room-level routing state.
+
+Example:
+
+```json
+{
+ "room_type": "chat",
+ "chat_id": "C3",
+ "display_name": "Чат 3",
+ "matrix_user_id": "@alice:example.org",
+ "space_id": "!space:example.org",
+ "platform_chat_id": "42",
+ "agent_id": "agent-2"
+}
+```
+
+### Platform chat sequence
+
+The global `PLATFORM_CHAT_SEQ_KEY` remains part of durable surface state.
+
+Its purpose is:
+
+- allocate monotonically increasing `platform_chat_id` values
+- avoid reusing a previously issued platform chat identifier during normal restart or redeploy
+
+This sequence must be stored in the same durable surface store as the room and user metadata.
+
+## Runtime Semantics After Restart
+
+After restart, the Matrix surface must:
+
+1. load the durable Matrix store
+2. load the durable surface key-value state
+3. load the agent registry config
+4. resume normal room routing using persisted `selected_agent_id`, `agent_id`, and `platform_chat_id`
+
+Expected behavior:
+
+- a user with a valid previously selected agent does not need to reselect it
+- a room previously bound to an agent remains bound to that agent
+- normal messages and commands continue to work
+
+### Lost temporary UX state
+
+If the bot restarts during a transient UX flow:
+
+- staged attachments may disappear
+- pending `!load` selections may disappear
+- pending confirmations may disappear
+
+This is acceptable and should not block normal operation after restart.
+
+## Interaction With Multi-Agent Routing
+
+The multi-agent design introduces new durable state that must survive restart:
+
+- `selected_agent_id` on the user
+- `agent_id` on the room
+- `PLATFORM_CHAT_SEQ_KEY` in the surface store
+
+Restart persistence and multi-agent routing therefore belong together.
+
+Without durable storage for those fields, a restart would make room routing ambiguous.
+
+## Failure Handling
+
+### Missing durable surface store
+
+If the durable store paths are missing because the environment was reset:
+
+- do not attempt to reconstruct a full working state from scratch in this design
+- treat startup as a clean environment
+- allow normal onboarding flows to begin again
+
+### Invalid durable references
+
+If persisted `selected_agent_id` or room `agent_id` references an agent no longer present in config:
+
+- do not crash
+- treat the selection or room binding as invalid
+- ask the user to select a valid agent again
+
+### Platform conversation memory
+
+If the upstream platform loses agent memory across restart:
+
+- that is outside the surface persistence boundary
+- the surface must still route correctly
+- platform memory persistence remains a platform responsibility
+
+## Testing Expectations
+
+Tests for this design should prove:
+
+- `selected_agent_id` survives restart through durable local storage
+- room `agent_id` and `platform_chat_id` survive restart through durable local storage
+- the bot can route messages correctly after restart without user reconfiguration
+- missing temporary UX state does not break normal messaging and command handling
+- invalid persisted agent references degrade into reselection prompts rather than crashes
+
+## Operational Notes
+
+For the Matrix surface to survive restart in the intended way, deployment must persist:
+
+- `lambda_matrix.db`
+- `matrix_store`
+
+This is a deployment requirement, not an optional optimization.
+
+The design intentionally stops there. It does not require:
+
+- hot reload of agent config
+- recovery after total local state loss
+- persistence of temporary UX flows
+- a solved multi-agent workspace story
diff --git a/docs/surface-protocol.md b/docs/surface-protocol.md
index ca66000..f2bd7b1 100644
--- a/docs/surface-protocol.md
+++ b/docs/surface-protocol.md
@@ -38,9 +38,10 @@ surfaces-bot/
converter.py — matrix-nio Event → IncomingEvent, OutgoingEvent → Matrix API
bot.py — точка входа, клиент
- platform/
- interface.py — Protocol: PlatformClient
- mock.py — MockPlatformClient
+ sdk/
+ interface.py — Protocol: PlatformClient (контракт к SDK)
+ real.py — RealPlatformClient (через AgentApi)
+ mock.py — MockPlatformClient (для локальных тестов)
```
---
@@ -140,7 +141,7 @@ class UIButton:
```
Telegram рендерит это как InlineKeyboard.
-Matrix рендерит как текст с описанием реакций или HTML-кнопки.
+Matrix рендерит как текст (в MVP).
### OutgoingNotification
Асинхронное уведомление — агент закончил долгую задачу.
@@ -209,7 +210,7 @@ class ConfirmationRequest:
```
Telegram показывает как Inline-кнопки.
-Matrix показывает как реакции 👍 / ❌.
+Matrix показывает как запрос для `!yes` / `!no`.
Ядро не знает как именно — только получает `IncomingCallback` с `action: "confirm"`.
---
@@ -304,9 +305,9 @@ class PlatformClient(Protocol):
async def update_settings(self, user_id: str, action: Any) -> None: ...
```
-Бот **не управляет lifecycle контейнеров** — это делает Master (платформа).
-Бот передаёт `user_id` + `chat_id` + текст; Master сам решает нужно ли поднять контейнер, смонтировать `C1/`/`C2/`, запустить агента.
+Бот **не управляет lifecycle контейнеров** агентов. Запуск/перезапуск агентов — ответственность платформы.
+Бот передаёт `user_id` + `chat_id` + текст.
-`MockPlatformClient` реализует этот протокол сейчас.
-Реальный SDK — тоже реализует этот протокол, заменяя один файл.
-Адаптеры поверхностей и ядро не меняются вообще.
+`MockPlatformClient` реализует этот протокол для локальных тестов.
+Реальный SDK используется через `RealPlatformClient` (`sdk/real.py`), который подключается к `AgentApi` по WebSocket.
+Адаптеры поверхностей и ядро не меняются вообще, привязка идёт через `config/matrix-agents.yaml`.
diff --git a/docs/telegram-prototype.md b/docs/telegram-prototype.md
index c58a1e5..17f93cf 100644
--- a/docs/telegram-prototype.md
+++ b/docs/telegram-prototype.md
@@ -1,5 +1,8 @@
# Telegram — описание прототипа
+> **ВНИМАНИЕ: Telegram-адаптер не является частью текущего MVP-деплоя.**
+> Код Telegram-поверхности находится в отдельной ветке `feat/telegram-adapter`. Данный документ описывает возможности этого адаптера, но многие концепции (например, AuthFlow и MockPlatformClient) устарели по отношению к актуальной архитектуре `main`.
+
## Концепция
Один бот, несколько чатов через Topics в Forum-группе.
diff --git a/docs/user-flow.md b/docs/user-flow.md
deleted file mode 100644
index efe22f1..0000000
--- a/docs/user-flow.md
+++ /dev/null
@@ -1,65 +0,0 @@
-# User Flow — Lambda Bot
-
-> **Статус:** ШАБЛОН — заполняет @architect после исследований
-> **Зависит от:** docs/research/telegram-flows.md, docs/research/competitor-ux.md
-
----
-
-## Основной сценарий (happy path)
-
-```mermaid
-sequenceDiagram
- actor User
- participant Bot as Telegram/Matrix Bot
- participant Platform as Lambda Platform (Master)
-
- User->>Bot: /start
- Bot->>Platform: GET /users/{tg_id}?platform=telegram
- Platform-->>Bot: {user_id, is_new}
-
- alt Новый пользователь
- Bot->>User: Приветствие + инструкция
- else Существующий пользователь
- Bot->>User: Добро пожаловать обратно
- end
-
- loop Диалог (бот не управляет сессиями — Master делает это автоматически)
- User->>Bot: Сообщение в чат C1/C2/...
- Bot->>Platform: POST /users/{user_id}/chats/{chat_id}/messages
- Note over Platform: Master поднимает контейнер,
монтирует нужный чат, запускает агента
- Platform-->>Bot: {message_id, response, tokens_used}
- Bot->>User: Ответ агента
- end
-```
-
----
-
-## Состояния FSM (Telegram)
-
-```mermaid
-stateDiagram-v2
- [*] --> Unauthenticated: первый контакт
-
- Unauthenticated --> Idle: /start (auth confirmed)
-
- Idle --> WaitingResponse: сообщение пользователя
- WaitingResponse --> Idle: ответ получен
- WaitingResponse --> Error: ошибка платформы
-
- Idle --> Idle: /new (создан новый чат)
- Idle --> ConfirmAction: агент запрашивает подтверждение
- ConfirmAction --> Idle: подтверждено / отменено
-
- Error --> Idle: /start
-```
-
----
-
-## Открытые вопросы
-
-> Заполняет @researcher и @architect после исследований
-
-- [ ] Как выглядит онбординг новых пользователей у конкурентов?
-- [ ] Нужна ли кнопка "Новая сессия" или сессия стартует автоматически?
-- [ ] Что показываем пока агент думает (typing indicator)?
-- [ ] Как обрабатываем timeout ответа от платформы?
diff --git a/forum_topics_research.md b/forum_topics_research.md
new file mode 100644
index 0000000..b09c695
--- /dev/null
+++ b/forum_topics_research.md
@@ -0,0 +1,363 @@
+# Telegram-бот как форум для AI-агента: полный технический разбор
+
+С выходом **Bot API 9.3 (31 декабря 2025) и 9.4 (9 февраля 2026)** Telegram действительно позволяет боту «стать форумом» без отдельной supergroup — через режим **Threaded Mode**, включаемый в @BotFather. Личный чат пользователя с ботом получает полноценные forum topics, каждый из которых выступает изолированным контекстом разговора. Параллельно сохраняется классическая архитектура «бот-админ в supergroup с включёнными Topics», обкатанная с Bot API 6.3 (ноябрь 2022). Оба подхода дают `message_thread_id` для маршрутизации сообщений к нужному контексту AI-агента, но отличаются по сценариям применения, ограничениям и настройке.
+
+---
+
+## Threaded Mode — бот сам становится форумом
+
+Начиная с Bot API 9.3, в @BotFather появилась настройка **Threaded Mode** (Bot Settings → Threaded Mode). После её включения личный чат пользователя с ботом превращается в форум: сообщения несут `message_thread_id` и `is_topic_message`, точно как в supergroup-форумах.
+
+Ключевые поля и возможности нового режима:
+
+- **`User.has_topics_enabled`** (bool) — показывает, включён ли Threaded Mode у бота для данного пользователя.
+- **`User.allows_users_to_create_topics`** (bool, API 9.4) — может ли пользователь сам создавать топики, или это право только у бота. Управляется через настройку @BotFather Mini App.
+- Бот вызывает **`createForumTopic(chat_id=user_id, name="...")`** прямо в личном чате — без supergroup, без админ-прав (API 9.4).
+- Работают **`editForumTopic`**, **`deleteForumTopic`**, **`unpinAllForumTopicMessages`** — подтверждено для private chats с API 9.3.
+- Все методы отправки (`sendMessage`, `sendPhoto`, `sendDocument` и т.д.) принимают `message_thread_id` в личных чатах.
+
+Это и есть ответ на вопрос «бот становится форумом» — **никакой отдельной группы не нужно**. Пользователь открывает чат с ботом и видит структуру топиков. Каждый топик — отдельный «разговор» с AI-агентом.
+
+Классическая архитектура «supergroup + бот-админ» по-прежнему актуальна для многопользовательских сценариев, где несколько людей работают с агентом в одном пространстве. Но для **персонального AI-ассистента** Threaded Mode — технически чистое решение.
+
+---
+
+## Полный справочник Forum Topics API
+
+### Основные методы
+
+| Метод | Параметры | Возврат | Права |
+|-------|-----------|---------|-------|
+| `createForumTopic` | `chat_id`, `name` (1–128 символов), `icon_color`?, `icon_custom_emoji_id`? | `ForumTopic` | `can_manage_topics` (supergroup) / не нужны (private) |
+| `editForumTopic` | `chat_id`, `message_thread_id`, `name`?, `icon_custom_emoji_id`? | `True` | `can_manage_topics` или создатель топика |
+| `closeForumTopic` | `chat_id`, `message_thread_id` | `True` | `can_manage_topics` или создатель |
+| `reopenForumTopic` | `chat_id`, `message_thread_id` | `True` | `can_manage_topics` или создатель |
+| `deleteForumTopic` | `chat_id`, `message_thread_id` | `True` | **`can_delete_messages`** (не `can_manage_topics`!) |
+| `unpinAllForumTopicMessages` | `chat_id`, `message_thread_id` | `True` | `can_pin_messages` |
+| `getForumTopicIconStickers` | — | `Array of Sticker` | не нужны |
+
+### Методы General-топика (только supergroup)
+
+| Метод | Описание |
+|-------|----------|
+| `editGeneralForumTopic(chat_id, name)` | Переименовать General-топик |
+| `closeGeneralForumTopic(chat_id)` | Закрыть General |
+| `reopenGeneralForumTopic(chat_id)` | Открыть General |
+| `hideGeneralForumTopic(chat_id)` | Скрыть General (автоматически закрывает) |
+| `unhideGeneralForumTopic(chat_id)` | Показать General |
+| `unpinAllGeneralForumTopicMessages(chat_id)` | Открепить все сообщения в General |
+
+Все требуют `can_manage_topics`, кроме `unpinAll...` — там нужен `can_pin_messages`.
+
+### Объект ForumTopic
+
+```python
+class ForumTopic:
+ message_thread_id: int # уникальный ID топика
+ name: str # название (1–128 символов)
+ icon_color: int # RGB-цвет иконки
+ icon_custom_emoji_id: str # кастомный эмодзи (опционально)
+ is_name_implicit: bool # имя назначено автоматически (API 9.3+)
+```
+
+**Допустимые значения `icon_color`**: `0x6FB9F0` (голубой), `0xFFD67E` (жёлтый), `0xCB86DB` (фиолетовый), `0x8EEE98` (зелёный), `0xFF93B2` (розовый), `0xFB6F5F` (красный) — ровно 6 цветов, других API не принимает.
+
+### Как работает message_thread_id
+
+При отправке через `sendMessage` (и все остальные send-методы) параметр `message_thread_id` направляет сообщение в конкретный топик. Входящие сообщения из топиков содержат два поля: **`message_thread_id`** (int) и **`is_topic_message`** (bool = True). Для General-топика `is_topic_message` **не устанавливается** — это ключевое отличие.
+
+---
+
+## General-топик: коварная деталь
+
+General-топик имеет фиксированный **`id = 1`** на уровне MTProto API. Однако в Bot API его поведение отличается от кастомных топиков: сообщения в General **не несут `is_topic_message = true`**, а `message_thread_id` может быть `None` или отсутствовать. При этом отправка с `message_thread_id=1` часто возвращает **`400 Bad Request: message thread not found`**. Корректный подход — **просто опустить `message_thread_id`** при отправке в General.
+
+Логика маршрутизации для AI-агента должна учитывать это:
+
+```python
+if message.is_topic_message and message.message_thread_id:
+ # Кастомный топик → изолированный контекст
+ context_key = (chat_id, message.message_thread_id)
+elif getattr(message.chat, 'is_forum', False):
+ # Форум, но не is_topic_message → General-топик
+ context_key = (chat_id, "general")
+else:
+ # Обычный чат / личное сообщение
+ context_key = (chat_id, None)
+```
+
+General-топик **нельзя удалить**, но можно скрыть через `hideGeneralForumTopic`. Для AI-бота рекомендуется скрыть General и направлять все взаимодействия через кастомные топики — это устраняет edge case с маршрутизацией.
+
+---
+
+## Рабочий бот на aiogram 3.x с полной изоляцией контекстов
+
+Ниже — **полный минимальный бот**, который создаёт топики по команде `/new`, ведёт изолированную историю для каждого топика и интегрируется с LLM. Код проверен по документации aiogram 3.26.
+
+```python
+"""
+AI-агент с forum topics — aiogram 3.x
+pip install aiogram>=3.20 openai aiosqlite
+"""
+
+import asyncio
+import logging
+import os
+from collections import defaultdict
+
+from aiogram import Bot, Dispatcher, F, Router
+from aiogram.filters import Command, CommandStart
+from aiogram.types import Message, ForumTopic
+from aiogram.client.default import DefaultBotProperties
+from aiogram.enums import ParseMode
+from aiogram.fsm.storage.memory import MemoryStorage
+from aiogram.fsm.strategy import FSMStrategy
+
+# ── Конфигурация ──────────────────────────────────────────────
+TOKEN = os.getenv("BOT_TOKEN")
+GROUP_ID = int(os.getenv("GROUP_ID", "0")) # ID supergroup-форума
+
+router = Router()
+
+# ── Хранилище контекстов: {(chat_id, topic_id): [messages]} ──
+contexts: dict[tuple[int, int | None], list[dict]] = defaultdict(list)
+
+
+# ── /start — приветствие в любом топике ───────────────────────
+@router.message(CommandStart())
+async def cmd_start(message: Message):
+ topic = message.message_thread_id
+ await message.answer(
+ f"👋 AI-агент активен.\n"
+ f"Топик: {topic or 'General'}\n\n"
+ f"/new <имя> — новый разговор\n"
+ f"/clear — очистить контекст\n"
+ f"/close — закрыть топик"
+ )
+
+
+# ── /new <имя> — создание нового топика-контекста ─────────────
+@router.message(Command("new"))
+async def cmd_new(message: Message, bot: Bot):
+ args = message.text.split(maxsplit=1)
+ name = args[1] if len(args) > 1 else f"Чат #{message.message_id}"
+
+ try:
+ topic: ForumTopic = await bot.create_forum_topic(
+ chat_id=message.chat.id,
+ name=name,
+ icon_color=0x6FB9F0,
+ )
+ # Приветственное сообщение внутри нового топика
+ await bot.send_message(
+ chat_id=message.chat.id,
+ text=f"✅ Контекст «{name}» создан. Пишите сюда — "
+ f"я помню только этот разговор.",
+ message_thread_id=topic.message_thread_id,
+ )
+ except Exception as e:
+ await message.answer(f"❌ Ошибка: {e}")
+
+
+# ── /clear — сброс контекста текущего топика ──────────────────
+@router.message(Command("clear"))
+async def cmd_clear(message: Message):
+ key = (message.chat.id, message.message_thread_id)
+ contexts[key].clear()
+ await message.answer("🗑 Контекст очищен.")
+
+
+# ── /close — закрытие текущего топика ─────────────────────────
+@router.message(Command("close"), F.message_thread_id)
+async def cmd_close(message: Message, bot: Bot):
+ try:
+ await bot.close_forum_topic(
+ chat_id=message.chat.id,
+ message_thread_id=message.message_thread_id,
+ )
+ # Чистим контекст закрытого топика
+ key = (message.chat.id, message.message_thread_id)
+ contexts.pop(key, None)
+ except Exception as e:
+ await message.answer(f"❌ {e}")
+
+
+# ── Обработка текстовых сообщений — маршрутизация по топику ───
+@router.message(F.text, ~F.text.startswith("/"))
+async def handle_user_message(message: Message):
+ key = (message.chat.id, message.message_thread_id)
+ history = contexts[key]
+
+ # Сохраняем сообщение пользователя
+ history.append({"role": "user", "content": message.text})
+
+ # ── Вызов LLM (заглушка — заменить на реальный вызов) ──
+ reply = await call_llm(history)
+
+ # Сохраняем ответ ассистента
+ history.append({"role": "assistant", "content": reply})
+
+ # Ограничиваем историю (скользящее окно)
+ if len(history) > 100:
+ contexts[key] = history[-100:]
+
+ # message.answer() автоматически сохраняет message_thread_id
+ await message.answer(reply)
+
+
+# ── Заглушка LLM (заменить на OpenAI / Anthropic / etc.) ─────
+async def call_llm(history: list[dict]) -> str:
+ """
+ Реальная интеграция:
+
+ from openai import AsyncOpenAI
+ client = AsyncOpenAI()
+
+ messages = [{"role": "system", "content": "Ты полезный ассистент."}]
+ messages += [{"role": m["role"], "content": m["content"]}
+ for m in history[-20:]]
+
+ resp = await client.chat.completions.create(
+ model="gpt-4o", messages=messages
+ )
+ return resp.choices[0].message.content
+ """
+ return f"[Echo] {history[-1]['content']} (сообщений в контексте: {len(history)})"
+
+
+# ── Точка входа ───────────────────────────────────────────────
+async def main():
+ logging.basicConfig(level=logging.INFO)
+ bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
+
+ dp = Dispatcher(
+ storage=MemoryStorage(),
+ fsm_strategy=FSMStrategy.CHAT_TOPIC, # изоляция FSM по топикам
+ )
+ dp.include_router(router)
+ await dp.start_polling(bot)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+```
+
+### Критически важная деталь: FSMStrategy.CHAT_TOPIC
+
+Встроенная в aiogram стратегия `FSMStrategy.CHAT_TOPIC` хранит состояния FSM с ключом `(chat_id, chat_id, thread_id)` — каждый топик получает **собственное** изолированное состояние. Это появилось в aiogram 3.4.0 и специально предназначено для форумных ботов. Без этой стратегии FSM-состояния будут общими для всех топиков в одном чате.
+
+---
+
+## Хранение контекстов: от прототипа к продакшену
+
+### In-memory dict — для разработки
+
+Простой `defaultdict(list)` из примера выше теряет данные при перезапуске, но позволяет мгновенно начать работу. Ключ — кортеж `(chat_id, topic_id)`.
+
+### Redis — для продакшена
+
+Redis даёт **нативный TTL** (автоочистка неактивных контекстов), **атомарные операции** (безопасность при конкурентных сообщениях) и **персистентность**. Паттерн хранения:
+
+```python
+import json
+import redis.asyncio as redis
+
+r = redis.from_url("redis://localhost:6379")
+
+async def get_history(chat_id: int, topic_id: int | None) -> list[dict]:
+ key = f"ctx:{chat_id}:{topic_id or 'general'}"
+ raw = await r.get(key)
+ return json.loads(raw) if raw else []
+
+async def append_and_trim(chat_id: int, topic_id: int | None, msg: dict):
+ key = f"ctx:{chat_id}:{topic_id or 'general'}"
+ history = await get_history(chat_id, topic_id)
+ history.append(msg)
+ history = history[-50:] # скользящее окно
+ await r.set(key, json.dumps(history), ex=86400 * 7) # TTL 7 дней
+```
+
+### SQLite — компромисс
+
+Для однопроцессных развёртываний без инфраструктуры Redis:
+
+```python
+import aiosqlite
+
+async def init_db():
+ async with aiosqlite.connect("contexts.db") as db:
+ await db.execute("""
+ CREATE TABLE IF NOT EXISTS messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ chat_id INTEGER NOT NULL,
+ topic_id INTEGER,
+ role TEXT NOT NULL,
+ content TEXT NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+ await db.execute(
+ "CREATE INDEX IF NOT EXISTS idx_ctx ON messages(chat_id, topic_id)"
+ )
+ await db.commit()
+```
+
+---
+
+## Настройка supergroup с forum mode
+
+Включить режим форума **через Bot API невозможно** — нет соответствующего метода. Два способа активации:
+
+Для **Threaded Mode в личных чатах**: @BotFather → выбрать бота → Bot Settings → Threaded Mode → включить. Всё. Никаких supergroup не нужно.
+
+Для **supergroup-форума** — шаги через Telegram-клиент:
+
+1. Создать группу (или использовать существующую).
+2. Открыть настройки группы → Edit → включить **Topics**. Telegram автоматически конвертирует группу в supergroup (ID чата изменится).
+3. Добавить бота в группу.
+4. Назначить бота администратором с правами: **`can_manage_topics`** (создание/редактирование/закрытие топиков), **`can_delete_messages`** (удаление топиков), **`can_pin_messages`** (работа с закреплёнными сообщениями).
+
+Минимально необходимое право — `can_manage_topics`. Без него бот не сможет вызвать `createForumTopic`.
+
+MTProto API имеет `channels.toggleForum(enabled=true)`, но это доступно только пользовательским аккаунтам с правами владельца, а не ботам.
+
+---
+
+## Лимиты, edge cases и важные ограничения
+
+**До 1 000 000 топиков** в одной supergroup — практически неограниченный потолок. **5 закреплённых топиков** максимум. Общие rate limits Bot API (~30 запросов/сек) распространяются и на создание топиков.
+
+**При удалении топика** все сообщения внутри него **удаляются безвозвратно**, `message_thread_id` становится невалидным. Критическая проблема: **Bot API не доставляет webhook-событие об удалении топика**. Нет поля `forum_topic_deleted` в объекте Message. Для очистки контекстов в хранилище используйте одну из стратегий: TTL-based expiry в Redis, ошибку при попытке отправки в несуществующий thread (error-based cleanup), или ручную очистку, если удаление инициирует сам бот.
+
+**Bot API не предоставляет метод для получения списка существующих топиков.** Нет `getForumTopics`. Бот должен запоминать ID топиков при создании через `createForumTopic` или через service messages `ForumTopicCreated`.
+
+### python-telegram-bot v21 — для сравнения
+
+Эквивалентный вызов создания топика:
+
+```python
+from telegram import Update, ForumTopic
+from telegram.ext import Application, CommandHandler
+
+async def new_topic(update: Update, context):
+ topic: ForumTopic = await context.bot.create_forum_topic(
+ chat_id=update.effective_chat.id,
+ name="Новый разговор",
+ icon_color=0x6FB9F0,
+ )
+ await context.bot.send_message(
+ chat_id=update.effective_chat.id,
+ text="Топик создан!",
+ message_thread_id=topic.message_thread_id,
+ )
+```
+
+Ключевое отличие: python-telegram-bot **не имеет встроенных FSM-стратегий** для топиков. Изоляцию состояний по `message_thread_id` нужно реализовывать вручную. Фильтры service-сообщений: `filters.StatusUpdate.FORUM_TOPIC_CREATED`, `.FORUM_TOPIC_CLOSED`, `.FORUM_TOPIC_REOPENED`.
+
+---
+
+## Заключение
+
+**Threaded Mode — прорывная возможность** для AI-ботов, появившаяся буквально в конце 2025-го. До этого «бот как форум» требовал обязательной supergroup-обёртки. Теперь личный чат с ботом является полноценным форумом, где каждый топик — изолированный контекст разговора с агентом.
+
+Архитектурная формула проста: `context_key = (chat_id, message_thread_id)` + `FSMStrategy.CHAT_TOPIC` в aiogram дают полную изоляцию из коробки. Для продакшена — Redis с TTL, для прототипа — `defaultdict(list)`. Три граблей, которые нужно знать заранее: General-топик не принимает `message_thread_id=1` при отправке, Bot API не уведомляет об удалении топиков, и получить список существующих топиков нельзя — только запоминать при создании.
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 8f4978b..73dfbd7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,12 +15,15 @@ dependencies = [
"structlog>=24.1",
"python-dotenv>=1.0",
"httpx>=0.27",
+ "aiohttp>=3.9",
+ "pyyaml>=6.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
+ "pytest-aiohttp>=1.0",
"pytest-cov>=4.1",
"ruff>=0.3",
"mypy>=1.8",
diff --git a/sdk/__init__.py b/sdk/__init__.py
new file mode 100644
index 0000000..f7939f7
--- /dev/null
+++ b/sdk/__init__.py
@@ -0,0 +1,9 @@
+__all__ = ["RealPlatformClient"]
+
+
+def __getattr__(name: str):
+ if name == "RealPlatformClient":
+ from sdk.real import RealPlatformClient
+
+ return RealPlatformClient
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
diff --git a/sdk/agent_session.py b/sdk/agent_session.py
new file mode 100644
index 0000000..187b88a
--- /dev/null
+++ b/sdk/agent_session.py
@@ -0,0 +1 @@
+"""Compatibility stub: AgentSessionClient was replaced by direct AgentApi usage in Phase 4."""
diff --git a/platform/interface.py b/sdk/interface.py
similarity index 90%
rename from platform/interface.py
rename to sdk/interface.py
index e1ff12e..7b43b1b 100644
--- a/platform/interface.py
+++ b/sdk/interface.py
@@ -1,10 +1,11 @@
# platform/interface.py
from __future__ import annotations
+from collections.abc import AsyncIterator
from datetime import datetime
-from typing import Any, AsyncIterator, Literal, Protocol
+from typing import Any, Literal, Protocol
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
class User(BaseModel):
@@ -17,10 +18,11 @@ class User(BaseModel):
class Attachment(BaseModel):
- url: str
- mime_type: str
+ url: str | None = None
+ mime_type: str | None = None
size: int | None = None
filename: str | None = None
+ workspace_path: str | None = None
class MessageResponse(BaseModel):
@@ -28,10 +30,12 @@ class MessageResponse(BaseModel):
response: str
tokens_used: int
finished: bool
+ attachments: list[Attachment] = Field(default_factory=list)
class MessageChunk(BaseModel):
"""Один кусок стримингового ответа. При sync-режиме — единственный чанк с finished=True."""
+
message_id: str
delta: str
finished: bool
@@ -48,6 +52,7 @@ class UserSettings(BaseModel):
class AgentEvent(BaseModel):
"""Webhook-уведомление от платформы — агент закончил долгую задачу."""
+
event_id: str
user_id: str
chat_id: str
@@ -94,4 +99,5 @@ class PlatformClient(Protocol):
class WebhookReceiver(Protocol):
"""Регистрируется в боте. Платформа зовёт нас когда агент закончил долгую задачу."""
+
async def on_agent_event(self, event: AgentEvent) -> None: ...
diff --git a/platform/mock.py b/sdk/mock.py
similarity index 82%
rename from platform/mock.py
rename to sdk/mock.py
index 2a534e8..06e49ac 100644
--- a/platform/mock.py
+++ b/sdk/mock.py
@@ -4,12 +4,13 @@ from __future__ import annotations
import asyncio
import random
import uuid
+from collections.abc import AsyncIterator
from datetime import UTC, datetime
-from typing import Any, AsyncIterator, Literal
+from typing import Any, Literal
import structlog
-from platform.interface import (
+from sdk.interface import (
AgentEvent,
Attachment,
MessageChunk,
@@ -22,6 +23,30 @@ from platform.interface import (
logger = structlog.get_logger(__name__)
+DEFAULT_SKILLS = {
+ "web-search": True,
+ "fetch-url": True,
+ "email": False,
+ "browser": False,
+ "image-gen": False,
+ "files": True,
+}
+
+DEFAULT_SAFETY = {
+ "email-send": True,
+ "file-delete": True,
+ "social-post": True,
+}
+
+DEFAULT_SOUL = {"name": "Лямбда", "instructions": ""}
+
+DEFAULT_PLAN = {
+ "name": "Beta",
+ "tokens_used": 0,
+ "tokens_limit": 1000,
+}
+
+
class MockPlatformClient:
"""
Заглушка SDK платформы Lambda.
@@ -99,23 +124,19 @@ class MockPlatformClient:
attachments: list[Attachment] | None = None,
) -> AsyncIterator[MessageChunk]:
"""
- Сейчас: один чанк с полным ответом (sync под капотом).
- При реальном SDK: заменить на SSE/WebSocket итератор в platform/mock.py.
+ Сейчас: один чанк с полным ответом.
+ При реальном SDK: заменить на SSE/WebSocket итератор.
Адаптеры переписывать не нужно.
"""
await self._latency(200, 600)
message_id, response, tokens = self._build_response(user_id, chat_id, text, attachments)
logger.info("stream_message", user_id=user_id, chat_id=chat_id, message_id=message_id)
-
- async def _gen() -> AsyncIterator[MessageChunk]:
- yield MessageChunk(
- message_id=message_id,
- delta=response,
- finished=True,
- tokens_used=tokens,
- )
-
- return _gen()
+ yield MessageChunk(
+ message_id=message_id,
+ delta=response,
+ finished=True,
+ tokens_used=tokens,
+ )
# --------------------------------------------------------------- settings
@@ -123,26 +144,11 @@ class MockPlatformClient:
await self._latency()
stored = self._settings.get(user_id, {})
return UserSettings(
- skills=stored.get("skills", {
- "web-search": True,
- "fetch-url": True,
- "email": False,
- "browser": False,
- "image-gen": False,
- "files": True,
- }),
+ skills={**DEFAULT_SKILLS, **stored.get("skills", {})},
connectors=stored.get("connectors", {}),
- soul=stored.get("soul", {"name": "Лямбда", "instructions": ""}),
- safety=stored.get("safety", {
- "email-send": True,
- "file-delete": True,
- "social-post": True,
- }),
- plan=stored.get("plan", {
- "name": "Beta",
- "tokens_used": 0,
- "tokens_limit": 1000,
- }),
+ soul={**DEFAULT_SOUL, **stored.get("soul", {})},
+ safety={**DEFAULT_SAFETY, **stored.get("safety", {})},
+ plan={**DEFAULT_PLAN, **stored.get("plan", {})},
)
async def update_settings(self, user_id: str, action: Any) -> None:
@@ -150,13 +156,13 @@ class MockPlatformClient:
settings = self._settings.setdefault(user_id, {})
if action.action == "toggle_skill":
- skills = settings.setdefault("skills", {})
+ skills = settings.setdefault("skills", DEFAULT_SKILLS.copy())
skills[action.payload["skill"]] = action.payload.get("enabled", True)
elif action.action == "set_soul":
- soul = settings.setdefault("soul", {})
+ soul = settings.setdefault("soul", DEFAULT_SOUL.copy())
soul[action.payload["field"]] = action.payload["value"]
elif action.action == "set_safety":
- safety = settings.setdefault("safety", {})
+ safety = settings.setdefault("safety", DEFAULT_SAFETY.copy())
safety[action.payload["trigger"]] = action.payload.get("enabled", True)
logger.info("Settings updated", user_id=user_id, action=action.action)
@@ -217,14 +223,16 @@ class MockPlatformClient:
response = f"[MOCK] Ответ на: «{preview}»{attachment_note}"
tokens = len(text.split()) * 2
- self._messages[key].append({
- "message_id": message_id,
- "user_text": text,
- "response": response,
- "tokens_used": tokens,
- "finished": True,
- "created_at": datetime.now(UTC).isoformat(),
- })
+ self._messages[key].append(
+ {
+ "message_id": message_id,
+ "user_text": text,
+ "response": response,
+ "tokens_used": tokens,
+ "finished": True,
+ "created_at": datetime.now(UTC).isoformat(),
+ }
+ )
return message_id, response, tokens
async def _latency(self, min_ms: int = 10, max_ms: int = 80) -> None:
diff --git a/sdk/prototype_state.py b/sdk/prototype_state.py
new file mode 100644
index 0000000..6e5fd41
--- /dev/null
+++ b/sdk/prototype_state.py
@@ -0,0 +1,129 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from typing import Any
+
+from sdk.interface import User, UserSettings
+
+# Keep the prototype backend self-contained; do not import these from sdk.mock.
+DEFAULT_SKILLS: dict[str, bool] = {
+ "web-search": True,
+ "fetch-url": True,
+ "email": False,
+ "browser": False,
+ "image-gen": False,
+ "files": True,
+}
+DEFAULT_SAFETY: dict[str, bool] = {
+ "email-send": True,
+ "file-delete": True,
+ "social-post": True,
+}
+DEFAULT_SOUL: dict[str, str] = {"name": "Лямбда", "instructions": ""}
+DEFAULT_PLAN: dict[str, Any] = {
+ "name": "Beta",
+ "tokens_used": 0,
+ "tokens_limit": 1000,
+}
+
+
+class PrototypeStateStore:
+ def __init__(self) -> None:
+ self._users: dict[str, User] = {}
+ self._settings: dict[str, dict[str, Any]] = {}
+ self._saved_sessions: dict[str, list[dict[str, str]]] = {}
+ self._context_last_tokens_used: dict[str, int] = {}
+ self._context_current_session: dict[str, str] = {}
+
+ async def get_or_create_user(
+ self,
+ external_id: str,
+ platform: str,
+ display_name: str | None = None,
+ ) -> User:
+ key = f"{platform}:{external_id}"
+ existing = self._users.get(key)
+ if existing is not None:
+ stored = existing.model_copy(update={"is_new": False})
+ self._users[key] = stored
+ return stored.model_copy()
+
+ user = User(
+ user_id=f"usr-{platform}-{external_id}",
+ external_id=external_id,
+ platform=platform,
+ display_name=display_name,
+ created_at=datetime.now(UTC),
+ is_new=True,
+ )
+ self._users[key] = user.model_copy(update={"is_new": False})
+ return user.model_copy()
+
+ async def get_settings(self, user_id: str) -> UserSettings:
+ stored = self._settings.get(user_id, {})
+ return UserSettings(
+ skills={**DEFAULT_SKILLS, **stored.get("skills", {})},
+ connectors=dict(stored.get("connectors", {})),
+ soul={**DEFAULT_SOUL, **stored.get("soul", {})},
+ safety={**DEFAULT_SAFETY, **stored.get("safety", {})},
+ plan={**DEFAULT_PLAN, **stored.get("plan", {})},
+ )
+
+ async def update_settings(self, user_id: str, action: Any) -> None:
+ settings = self._settings.setdefault(user_id, {})
+
+ if action.action == "toggle_skill":
+ skills = settings.setdefault("skills", DEFAULT_SKILLS.copy())
+ skills[action.payload["skill"]] = action.payload.get("enabled", True)
+ elif action.action == "set_soul":
+ soul = settings.setdefault("soul", DEFAULT_SOUL.copy())
+ soul[action.payload["field"]] = action.payload["value"]
+ elif action.action == "set_safety":
+ safety = settings.setdefault("safety", DEFAULT_SAFETY.copy())
+ safety[action.payload["trigger"]] = action.payload.get("enabled", True)
+
+ async def add_saved_session(
+ self,
+ user_id: str,
+ name: str,
+ *,
+ source_context_id: str | None = None,
+ ) -> None:
+ sessions = self._saved_sessions.setdefault(user_id, [])
+ session = {"name": name, "created_at": datetime.now(UTC).isoformat()}
+ if source_context_id is not None:
+ session["source_context_id"] = source_context_id
+ sessions.append(session)
+
+ async def list_saved_sessions(self, user_id: str) -> list[dict[str, str]]:
+ return [dict(session) for session in self._saved_sessions.get(user_id, [])]
+
+ async def get_last_tokens_used_for_context(self, context_id: str) -> int:
+ return self._context_last_tokens_used.get(context_id, 0)
+
+ async def set_last_tokens_used_for_context(self, context_id: str, tokens: int) -> None:
+ self._context_last_tokens_used[context_id] = tokens
+
+ async def get_current_session_for_context(self, context_id: str) -> str | None:
+ return self._context_current_session.get(context_id)
+
+ async def set_current_session_for_context(self, context_id: str, name: str) -> None:
+ self._context_current_session[context_id] = name
+
+ async def clear_current_session_for_context(self, context_id: str) -> None:
+ self._context_current_session.pop(context_id, None)
+
+ async def get_last_tokens_used(self, context_id: str) -> int:
+ return await self.get_last_tokens_used_for_context(context_id)
+
+ async def set_last_tokens_used(self, context_id: str, tokens: int) -> None:
+ await self.set_last_tokens_used_for_context(context_id, tokens)
+
+ async def get_current_session(self, context_id: str) -> str | None:
+ return await self.get_current_session_for_context(context_id)
+
+ async def set_current_session(self, context_id: str, name: str) -> None:
+ await self.set_current_session_for_context(context_id, name)
+
+ async def clear_current_session(self, context_id: str) -> None:
+ await self.clear_current_session_for_context(context_id)
diff --git a/sdk/real.py b/sdk/real.py
new file mode 100644
index 0000000..47f639a
--- /dev/null
+++ b/sdk/real.py
@@ -0,0 +1,273 @@
+from __future__ import annotations
+
+import asyncio
+import os
+import re
+from collections.abc import AsyncIterator
+from pathlib import Path
+from urllib.parse import urljoin, urlsplit, urlunsplit
+
+import structlog
+
+from sdk.interface import (
+ Attachment,
+ MessageChunk,
+ MessageResponse,
+ PlatformClient,
+ PlatformError,
+ User,
+ UserSettings,
+)
+from sdk.prototype_state import PrototypeStateStore
+from sdk.upstream_agent_api import AgentApi, MsgEventSendFile, MsgEventTextChunk
+
+logger = structlog.get_logger(__name__)
+
+
+def _ws_debug_enabled() -> bool:
+ value = os.environ.get("SURFACES_DEBUG_WS", "")
+ return value.strip().lower() in {"1", "true", "yes", "on"}
+
+
+class RealPlatformClient(PlatformClient):
+ def __init__(
+ self,
+ agent_id: str,
+ agent_base_url: str,
+ prototype_state: PrototypeStateStore,
+ platform: str = "matrix",
+ agent_api_cls=AgentApi,
+ ) -> None:
+ self._agent_id = agent_id
+ self._raw_agent_base_url = agent_base_url
+ self._agent_base_url = self._normalize_agent_base_url(agent_base_url)
+ self._agent_api_cls = agent_api_cls
+ self._prototype_state = prototype_state
+ self._platform = platform
+ self._chat_send_locks: dict[str, asyncio.Lock] = {}
+ if _ws_debug_enabled():
+ logger.warning(
+ "agent_client_initialized",
+ agent_id=self._agent_id,
+ platform=self._platform,
+ raw_base_url=self._raw_agent_base_url,
+ normalized_base_url=self._agent_base_url,
+ )
+
+ @property
+ def agent_id(self) -> str:
+ return self._agent_id
+
+ @property
+ def agent_base_url(self) -> str:
+ return self._agent_base_url
+
+ def _get_chat_send_lock(self, chat_id: str) -> asyncio.Lock:
+ chat_key = str(chat_id)
+ lock = self._chat_send_locks.get(chat_key)
+ if lock is None:
+ lock = asyncio.Lock()
+ self._chat_send_locks[chat_key] = lock
+ return lock
+
+ async def get_or_create_user(
+ self,
+ external_id: str,
+ platform: str,
+ display_name: str | None = None,
+ ) -> User:
+ return await self._prototype_state.get_or_create_user(
+ external_id=external_id,
+ platform=platform,
+ display_name=display_name,
+ )
+
+ async def send_message(
+ self,
+ user_id: str,
+ chat_id: str,
+ text: str,
+ attachments: list[Attachment] | None = None,
+ ) -> MessageResponse:
+ response_parts: list[str] = []
+ sent_attachments: list[Attachment] = []
+ message_id = user_id
+
+ lock = self._get_chat_send_lock(chat_id)
+ async with lock:
+ chat_api = self._build_chat_api(chat_id)
+ try:
+ await chat_api.connect()
+ async for event in self._stream_agent_events(
+ chat_api, text, attachments=attachments
+ ):
+ message_id = user_id
+ if isinstance(event, MsgEventTextChunk) and event.text:
+ response_parts.append(event.text)
+ elif isinstance(event, MsgEventSendFile):
+ attachment = self._attachment_from_send_file_event(event)
+ if attachment is not None:
+ sent_attachments.append(attachment)
+ except Exception as exc:
+ raise self._to_platform_error(exc) from exc
+ finally:
+ await self._close_chat_api(chat_api)
+ await self._prototype_state.set_last_tokens_used(str(chat_id), 0)
+
+ response_kwargs = {
+ "message_id": message_id,
+ "response": "".join(response_parts),
+ "tokens_used": 0,
+ "finished": True,
+ "attachments": sent_attachments,
+ }
+ return MessageResponse(**response_kwargs)
+
+ async def stream_message(
+ self,
+ user_id: str,
+ chat_id: str,
+ text: str,
+ attachments: list[Attachment] | None = None,
+ ) -> AsyncIterator[MessageChunk]:
+ lock = self._get_chat_send_lock(chat_id)
+ async with lock:
+ chat_api = self._build_chat_api(chat_id)
+ try:
+ await chat_api.connect()
+ async for event in self._stream_agent_events(
+ chat_api, text, attachments=attachments
+ ):
+ if isinstance(event, MsgEventTextChunk):
+ yield MessageChunk(
+ message_id=user_id,
+ delta=event.text,
+ finished=False,
+ )
+ elif isinstance(event, MsgEventSendFile):
+ continue
+ except Exception as exc:
+ raise self._to_platform_error(exc) from exc
+ finally:
+ await self._close_chat_api(chat_api)
+ await self._prototype_state.set_last_tokens_used(str(chat_id), 0)
+ yield MessageChunk(
+ message_id=user_id,
+ delta="",
+ finished=True,
+ tokens_used=0,
+ )
+
+ async def get_settings(self, user_id: str) -> UserSettings:
+ return await self._prototype_state.get_settings(user_id)
+
+ async def update_settings(self, user_id: str, action) -> None:
+ await self._prototype_state.update_settings(user_id, action)
+
+ async def disconnect_chat(self, chat_id: str) -> None:
+ self._chat_send_locks.pop(str(chat_id), None)
+
+ async def close(self) -> None:
+ self._chat_send_locks.clear()
+
+ async def _stream_agent_events(
+ self,
+ chat_api,
+ text: str,
+ attachments: list[Attachment] | None = None,
+ ) -> AsyncIterator[object]:
+ attachment_paths = self._attachment_paths(attachments)
+ event_stream = chat_api.send_message(text, attachments=attachment_paths or None)
+ chunk_index = 0
+ async for event in event_stream:
+ if isinstance(event, MsgEventTextChunk):
+ logger.debug("agent_chunk", index=chunk_index, text=repr(event.text[:40]))
+ chunk_index += 1
+ else:
+ logger.debug("agent_event", index=chunk_index, type=type(event).__name__)
+ yield event
+
+ def _build_chat_api(self, chat_id: str):
+ if _ws_debug_enabled():
+ logger.warning(
+ "agent_chat_api_build",
+ agent_id=self._agent_id,
+ chat_id=str(chat_id),
+ normalized_base_url=self._agent_base_url,
+ ws_url=urljoin(self._agent_base_url, f"v1/agent_ws/{chat_id}/"),
+ )
+ return self._agent_api_cls(
+ agent_id=self._agent_id,
+ base_url=self._agent_base_url,
+ chat_id=str(chat_id),
+ )
+
+ @staticmethod
+ def _normalize_agent_base_url(base_url: str) -> str:
+ parsed = urlsplit(base_url)
+ path = re.sub(r"(?:/v1)?/agent_ws(?:/[^/]+)?/?$", "", parsed.path.rstrip("/"))
+ if path:
+ path = f"{path}/"
+ return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
+
+ @staticmethod
+ async def _close_chat_api(chat_api) -> None:
+ close = getattr(chat_api, "close", None)
+ if callable(close):
+ try:
+ await close()
+ except Exception:
+ pass
+
+ @staticmethod
+ def _to_platform_error(exc: Exception) -> PlatformError:
+ code = getattr(exc, "code", None) or "PLATFORM_CONNECTION_ERROR"
+ return PlatformError(str(exc), code=code)
+
+ @staticmethod
+ def _normalize_workspace_path(location: str) -> str | None:
+ if not location:
+ return None
+
+ path = Path(location)
+ if not path.is_absolute():
+ normalized = path.as_posix()
+ return normalized or None
+
+ parts = path.parts
+ if len(parts) >= 2 and parts[1] == "workspace":
+ relative = Path(*parts[2:]).as_posix()
+ return relative or None
+ if len(parts) >= 3 and parts[1] == "agents":
+ relative = Path(*parts[3:]).as_posix()
+ return relative or None
+
+ relative = path.as_posix().lstrip("/")
+ return relative or None
+
+ @staticmethod
+ def _attachment_paths(attachments: list[Attachment] | None) -> list[str]:
+ if not attachments:
+ return []
+ paths = []
+ for attachment in attachments:
+ if attachment.workspace_path:
+ normalized = RealPlatformClient._normalize_workspace_path(
+ attachment.workspace_path
+ )
+ if normalized:
+ paths.append(normalized)
+ return paths
+
+ @staticmethod
+ def _attachment_from_send_file_event(event: MsgEventSendFile) -> Attachment:
+ location = str(event.path)
+ filename = Path(location).name or None
+ workspace_path = RealPlatformClient._normalize_workspace_path(location)
+ return Attachment(
+ url=location,
+ mime_type="application/octet-stream",
+ size=None,
+ filename=filename,
+ workspace_path=workspace_path or None,
+ )
diff --git a/sdk/upstream_agent_api.py b/sdk/upstream_agent_api.py
new file mode 100644
index 0000000..d0bfdd7
--- /dev/null
+++ b/sdk/upstream_agent_api.py
@@ -0,0 +1,19 @@
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+_api_root = Path(__file__).resolve().parents[1] / "external" / "platform-agent_api"
+if str(_api_root) not in sys.path:
+ sys.path.insert(0, str(_api_root))
+
+from lambda_agent_api.agent_api import AgentApi, AgentBusyException, AgentException # noqa: E402
+from lambda_agent_api.server import MsgEventSendFile, MsgEventTextChunk # noqa: E402
+
+__all__ = [
+ "AgentApi",
+ "AgentBusyException",
+ "AgentException",
+ "MsgEventSendFile",
+ "MsgEventTextChunk",
+]
diff --git a/telegram-cloud-photo-size-2-5440546240941724952-y.jpg b/telegram-cloud-photo-size-2-5440546240941724952-y.jpg
new file mode 100644
index 0000000..af4606d
Binary files /dev/null and b/telegram-cloud-photo-size-2-5440546240941724952-y.jpg differ
diff --git a/tests/adapter/__init__.py b/tests/adapter/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/adapter/matrix/__init__.py b/tests/adapter/matrix/__init__.py
new file mode 100644
index 0000000..9d48db4
--- /dev/null
+++ b/tests/adapter/matrix/__init__.py
@@ -0,0 +1 @@
+from __future__ import annotations
diff --git a/tests/adapter/matrix/test_agent_registry.py b/tests/adapter/matrix/test_agent_registry.py
new file mode 100644
index 0000000..a918f84
--- /dev/null
+++ b/tests/adapter/matrix/test_agent_registry.py
@@ -0,0 +1,199 @@
+from pathlib import Path
+
+import pytest
+
+from adapter.matrix.agent_registry import AgentRegistryError, load_agent_registry
+
+
+def test_load_agent_registry_reads_yaml_entries(tmp_path: Path):
+ path = tmp_path / "agents.yaml"
+ path.write_text(
+ "agents:\n"
+ " - id: agent-1\n"
+ " label: Analyst\n"
+ " - id: agent-2\n"
+ " label: Research\n",
+ encoding="utf-8",
+ )
+
+ registry = load_agent_registry(path)
+
+ assert [agent.agent_id for agent in registry.agents] == ["agent-1", "agent-2"]
+ assert registry.get("agent-1").label == "Analyst"
+
+
+def test_agent_registry_agents_sequence_is_immutable(tmp_path: Path):
+ path = tmp_path / "agents.yaml"
+ path.write_text(
+ "agents:\n"
+ " - id: agent-1\n"
+ " label: Analyst\n",
+ encoding="utf-8",
+ )
+
+ registry = load_agent_registry(path)
+
+ with pytest.raises(AttributeError):
+ registry.agents.append( # type: ignore[attr-defined]
+ registry.agents[0]
+ )
+
+
+def test_load_agent_registry_rejects_duplicate_ids(tmp_path: Path):
+ path = tmp_path / "agents.yaml"
+ path.write_text(
+ "agents:\n"
+ " - id: agent-1\n"
+ " label: Analyst\n"
+ " - id: agent-1\n"
+ " label: Duplicate\n",
+ encoding="utf-8",
+ )
+
+ with pytest.raises(AgentRegistryError, match="duplicate agent id"):
+ load_agent_registry(path)
+
+
+def test_load_agent_registry_rejects_non_mapping_entries(tmp_path: Path):
+ path = tmp_path / "agents.yaml"
+ path.write_text(
+ "agents:\n"
+ " - agent-1\n",
+ encoding="utf-8",
+ )
+
+ with pytest.raises(AgentRegistryError, match="each agent entry requires id and label"):
+ load_agent_registry(path)
+
+
+@pytest.mark.parametrize(
+ "content",
+ [
+ "",
+ "agents: []\n",
+ "agents: agent-1\n",
+ "foo: bar\n",
+ ],
+)
+def test_load_agent_registry_rejects_missing_non_list_and_empty_agents(
+ tmp_path: Path, content: str
+):
+ path = tmp_path / "agents.yaml"
+ path.write_text(content, encoding="utf-8")
+
+ with pytest.raises(AgentRegistryError, match="agents registry must contain a non-empty agents list"):
+ load_agent_registry(path)
+
+
+@pytest.mark.parametrize(
+ "content, expected",
+ [
+ (
+ "agents:\n"
+ " - label: Analyst\n",
+ "each agent entry requires id and label",
+ ),
+ (
+ "agents:\n"
+ " - id: agent-1\n",
+ "each agent entry requires id and label",
+ ),
+ ],
+)
+def test_load_agent_registry_rejects_missing_id_or_label(tmp_path: Path, content: str, expected: str):
+ path = tmp_path / "agents.yaml"
+ path.write_text(content, encoding="utf-8")
+
+ with pytest.raises(AgentRegistryError, match=expected):
+ load_agent_registry(path)
+
+
+def test_load_agent_registry_rejects_invalid_top_level_yaml(tmp_path: Path):
+ path = tmp_path / "agents.yaml"
+ path.write_text(
+ "- id: agent-1\n"
+ " label: Analyst\n",
+ encoding="utf-8",
+ )
+
+ with pytest.raises(AgentRegistryError, match="agent registry must be a mapping with an agents list"):
+ load_agent_registry(path)
+
+
+def test_load_agent_registry_rejects_malformed_yaml(tmp_path: Path):
+ path = tmp_path / "agents.yaml"
+ path.write_text(
+ "agents:\n"
+ " - id: agent-1\n"
+ " label: Analyst\n"
+ " - id: agent-2\n"
+ " label: Research\n"
+ " - [\n",
+ encoding="utf-8",
+ )
+
+ with pytest.raises(AgentRegistryError, match="invalid agent registry YAML"):
+ load_agent_registry(path)
+
+
+@pytest.mark.parametrize(
+ "content",
+ [
+ "agents:\n"
+ " - id: null\n"
+ " label: Analyst\n",
+ "agents:\n"
+ " - id: agent-1\n"
+ " label: null\n",
+ ],
+)
+def test_load_agent_registry_rejects_null_id_or_label(tmp_path: Path, content: str):
+ path = tmp_path / "agents.yaml"
+ path.write_text(content, encoding="utf-8")
+
+ with pytest.raises(AgentRegistryError, match="each agent entry requires id and label"):
+ load_agent_registry(path)
+
+
+@pytest.mark.parametrize(
+ "content",
+ [
+ "agents:\n"
+ " - id: ' '\n"
+ " label: Analyst\n",
+ "agents:\n"
+ " - id: agent-1\n"
+ " label: ' '\n",
+ ],
+)
+def test_load_agent_registry_rejects_blank_id_or_label(tmp_path: Path, content: str):
+ path = tmp_path / "agents.yaml"
+ path.write_text(content, encoding="utf-8")
+
+ with pytest.raises(AgentRegistryError, match="each agent entry requires id and label"):
+ load_agent_registry(path)
+
+
+@pytest.mark.parametrize(
+ "content",
+ [
+ "agents:\n"
+ " - id: 123\n"
+ " label: Analyst\n",
+ "agents:\n"
+ " - id: agent-1\n"
+ " label: 456\n",
+ "agents:\n"
+ " - id: true\n"
+ " label: Analyst\n",
+ "agents:\n"
+ " - id: agent-1\n"
+ " label: false\n",
+ ],
+)
+def test_load_agent_registry_rejects_non_string_id_or_label(tmp_path: Path, content: str):
+ path = tmp_path / "agents.yaml"
+ path.write_text(content, encoding="utf-8")
+
+ with pytest.raises(AgentRegistryError, match="each agent entry requires id and label"):
+ load_agent_registry(path)
diff --git a/tests/adapter/matrix/test_chat_space.py b/tests/adapter/matrix/test_chat_space.py
new file mode 100644
index 0000000..e33fb98
--- /dev/null
+++ b/tests/adapter/matrix/test_chat_space.py
@@ -0,0 +1,202 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+from nio.api import RoomVisibility
+from nio.responses import RoomCreateError
+
+from adapter.matrix.handlers.chat import (
+ make_handle_archive,
+ make_handle_new_chat,
+ make_handle_rename,
+)
+from adapter.matrix.store import get_room_meta, set_user_meta
+from core.auth import AuthManager
+from core.chat import ChatManager
+from core.protocol import IncomingCommand, OutgoingMessage
+from core.settings import SettingsManager
+from core.store import InMemoryStore
+from sdk.mock import MockPlatformClient
+
+
+async def _setup():
+ platform = MockPlatformClient()
+ store = InMemoryStore()
+ chat_mgr = ChatManager(platform, store)
+ auth_mgr = AuthManager(platform, store)
+ settings_mgr = SettingsManager(platform, store)
+ await auth_mgr.confirm("@alice:example.org")
+ return platform, store, chat_mgr, auth_mgr, settings_mgr
+
+
+async def test_mat04_new_chat_calls_room_put_state_with_space_id():
+ platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup()
+ await set_user_meta(
+ store, "@alice:example.org", {"space_id": "!space:ex", "next_chat_index": 2}
+ )
+
+ client = SimpleNamespace(
+ room_create=AsyncMock(return_value=SimpleNamespace(room_id="!newroom:ex")),
+ room_put_state=AsyncMock(),
+ room_invite=AsyncMock(),
+ )
+ handler = make_handle_new_chat(client, store)
+ event = IncomingCommand(
+ user_id="@alice:example.org",
+ platform="matrix",
+ chat_id="C1",
+ command="new",
+ args=["Test"],
+ )
+ result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
+
+ client.room_create.assert_awaited_once_with(
+ name="Test",
+ visibility=RoomVisibility.private,
+ is_direct=False,
+ invite=["@alice:example.org"],
+ )
+ client.room_put_state.assert_awaited_once()
+ client.room_invite.assert_not_awaited()
+ kwargs = client.room_put_state.call_args.kwargs
+ assert kwargs.get("room_id") == "!space:ex"
+ assert kwargs.get("event_type") == "m.space.child"
+ assert kwargs.get("state_key") == "!newroom:ex"
+ room_meta = await get_room_meta(store, "!newroom:ex")
+ assert room_meta is not None
+ assert room_meta["platform_chat_id"] == "1"
+ assert any(isinstance(item, OutgoingMessage) and "Test" in item.text for item in result)
+
+
+async def test_mat05_new_chat_without_space_id_returns_error():
+ platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup()
+ await set_user_meta(store, "@alice:example.org", {"next_chat_index": 1})
+
+ client = SimpleNamespace(
+ room_create=AsyncMock(),
+ room_put_state=AsyncMock(),
+ room_invite=AsyncMock(),
+ )
+ handler = make_handle_new_chat(client, store)
+ event = IncomingCommand(
+ user_id="@alice:example.org",
+ platform="matrix",
+ chat_id="C1",
+ command="new",
+ )
+ result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
+
+ assert len(result) == 1
+ assert isinstance(result[0], OutgoingMessage)
+ assert "Space" in result[0].text or "ошибка" in result[0].text.lower()
+ client.room_create.assert_not_awaited()
+
+
+async def test_mat10_archive_calls_chat_mgr_archive():
+ platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup()
+
+ client = SimpleNamespace(room_leave=AsyncMock())
+ handler = make_handle_archive(client, store)
+ event = IncomingCommand(
+ user_id="@alice:example.org",
+ platform="matrix",
+ chat_id="C1",
+ command="archive",
+ )
+ await chat_mgr.get_or_create(
+ user_id="@alice:example.org",
+ chat_id="C1",
+ platform="matrix",
+ surface_ref="!room:ex",
+ name="Test",
+ )
+
+ result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
+
+ assert len(result) == 1
+ assert "архивирован" in result[0].text
+ client.room_leave.assert_awaited_once_with("!room:ex")
+ chats = await chat_mgr.list_active("@alice:example.org")
+ assert chats == []
+
+
+async def test_mat11_rename_updates_matrix_room_name_via_state_event():
+ platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup()
+ await chat_mgr.get_or_create(
+ user_id="@alice:example.org",
+ chat_id="C1",
+ platform="matrix",
+ surface_ref="!room:ex",
+ name="Old",
+ )
+
+ client = SimpleNamespace(room_put_state=AsyncMock())
+ handler = make_handle_rename(client, store)
+ event = IncomingCommand(
+ user_id="@alice:example.org",
+ platform="matrix",
+ chat_id="C1",
+ command="rename",
+ args=["New", "Name"],
+ )
+
+ result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
+
+ client.room_put_state.assert_awaited_once_with(
+ room_id="!room:ex",
+ event_type="m.room.name",
+ content={"name": "New Name"},
+ state_key="",
+ )
+ assert len(result) == 1
+ assert "Переименован" in result[0].text
+
+
+async def test_mat11b_rename_from_unregistered_room_returns_error_message():
+ platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup()
+
+ client = SimpleNamespace(room_put_state=AsyncMock())
+ handler = make_handle_rename(client, store)
+ event = IncomingCommand(
+ user_id="@alice:example.org",
+ platform="matrix",
+ chat_id="unregistered:!old:example.org",
+ command="rename",
+ args=["New"],
+ )
+
+ result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
+
+ client.room_put_state.assert_not_awaited()
+ assert len(result) == 1
+ assert "не найден" in result[0].text.lower() or "примите приглашение" in result[0].text.lower()
+
+
+async def test_mat12_room_create_error_returns_user_message():
+ platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup()
+ await set_user_meta(
+ store, "@alice:example.org", {"space_id": "!space:ex", "next_chat_index": 2}
+ )
+
+ client = SimpleNamespace(
+ room_create=AsyncMock(
+ return_value=RoomCreateError(message="rate limited", status_code="429")
+ ),
+ room_put_state=AsyncMock(),
+ room_invite=AsyncMock(),
+ )
+ handler = make_handle_new_chat(client, store)
+ event = IncomingCommand(
+ user_id="@alice:example.org",
+ platform="matrix",
+ chat_id="C1",
+ command="new",
+ args=["Fail"],
+ )
+ result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
+
+ assert len(result) == 1
+ assert isinstance(result[0], OutgoingMessage)
+ assert "Не удалось" in result[0].text or "не удалось" in result[0].text
+ client.room_put_state.assert_not_awaited()
diff --git a/tests/adapter/matrix/test_confirm.py b/tests/adapter/matrix/test_confirm.py
new file mode 100644
index 0000000..bf52613
--- /dev/null
+++ b/tests/adapter/matrix/test_confirm.py
@@ -0,0 +1,130 @@
+from __future__ import annotations
+
+from adapter.matrix.handlers.confirm import make_handle_cancel, make_handle_confirm
+from adapter.matrix.store import get_pending_confirm, set_pending_confirm
+from core.auth import AuthManager
+from core.chat import ChatManager
+from core.protocol import IncomingCallback, OutgoingMessage
+from core.settings import SettingsManager
+from core.store import InMemoryStore
+from sdk.mock import MockPlatformClient
+
+
+async def test_mat09_yes_reads_pending_confirm():
+ store = InMemoryStore()
+ platform = MockPlatformClient()
+ chat_mgr = ChatManager(platform, store)
+ auth_mgr = AuthManager(platform, store)
+ settings_mgr = SettingsManager(platform, store)
+
+ await set_pending_confirm(
+ store,
+ "@alice:example.org",
+ "!confirm:example.org",
+ {
+ "action_id": "delete_file",
+ "description": "Удалить файл config.yaml",
+ "payload": {},
+ },
+ )
+
+ handler = make_handle_confirm(store)
+ event = IncomingCallback(
+ user_id="@alice:example.org",
+ platform="matrix",
+ chat_id="C7",
+ action="confirm",
+ payload={"source": "command", "command": "yes", "room_id": "!confirm:example.org"},
+ )
+ result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
+
+ assert len(result) == 1
+ assert isinstance(result[0], OutgoingMessage)
+ assert "Удалить файл config.yaml" in result[0].text
+ assert await get_pending_confirm(store, "@alice:example.org", "!confirm:example.org") is None
+
+
+async def test_no_clears_pending_confirm():
+ store = InMemoryStore()
+ platform = MockPlatformClient()
+ chat_mgr = ChatManager(platform, store)
+ auth_mgr = AuthManager(platform, store)
+ settings_mgr = SettingsManager(platform, store)
+
+ await set_pending_confirm(
+ store,
+ "@alice:example.org",
+ "!confirm:example.org",
+ {
+ "action_id": "delete_file",
+ "description": "Удалить файл",
+ "payload": {},
+ },
+ )
+
+ handler = make_handle_cancel(store)
+ event = IncomingCallback(
+ user_id="@alice:example.org",
+ platform="matrix",
+ chat_id="C7",
+ action="cancel",
+ payload={"source": "command", "command": "no", "room_id": "!confirm:example.org"},
+ )
+ result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
+
+ assert len(result) == 1
+ assert "отменено" in result[0].text.lower()
+ assert await get_pending_confirm(store, "@alice:example.org", "!confirm:example.org") is None
+
+
+async def test_yes_without_pending_returns_no_pending():
+ store = InMemoryStore()
+ platform = MockPlatformClient()
+ chat_mgr = ChatManager(platform, store)
+ auth_mgr = AuthManager(platform, store)
+ settings_mgr = SettingsManager(platform, store)
+
+ handler = make_handle_confirm(store)
+ event = IncomingCallback(
+ user_id="@alice:example.org",
+ platform="matrix",
+ chat_id="C1",
+ action="confirm",
+ payload={},
+ )
+ result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
+
+ assert len(result) == 1
+ assert "Нет ожидающих" in result[0].text
+
+
+async def test_yes_falls_back_to_legacy_chat_key_without_room_payload():
+ store = InMemoryStore()
+ platform = MockPlatformClient()
+ chat_mgr = ChatManager(platform, store)
+ auth_mgr = AuthManager(platform, store)
+ settings_mgr = SettingsManager(platform, store)
+
+ await set_pending_confirm(
+ store,
+ "legacy-chat",
+ {
+ "action_id": "delete_file",
+ "description": "Legacy confirm",
+ "payload": {},
+ },
+ )
+
+ handler = make_handle_confirm(store)
+ event = IncomingCallback(
+ user_id="@alice:example.org",
+ platform="matrix",
+ chat_id="legacy-chat",
+ action="confirm",
+ payload={"source": "command", "command": "yes"},
+ )
+ result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
+
+ assert len(result) == 1
+ assert "Legacy confirm" in result[0].text
+ assert await get_pending_confirm(store, "legacy-chat") is None
diff --git a/tests/adapter/matrix/test_context_commands.py b/tests/adapter/matrix/test_context_commands.py
new file mode 100644
index 0000000..9264a06
--- /dev/null
+++ b/tests/adapter/matrix/test_context_commands.py
@@ -0,0 +1,350 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+
+from adapter.matrix.bot import MatrixBot, build_runtime
+from adapter.matrix.handlers import register_matrix_handlers
+from adapter.matrix.handlers.context_commands import (
+ make_handle_context,
+ make_handle_load,
+ make_handle_reset,
+ make_handle_save,
+)
+from adapter.matrix.store import (
+ get_load_pending,
+ set_load_pending,
+ set_room_meta,
+)
+from core.protocol import IncomingCommand, OutgoingMessage
+from core.store import InMemoryStore
+from sdk.interface import MessageResponse
+from sdk.mock import MockPlatformClient
+from sdk.prototype_state import PrototypeStateStore
+
+
+class MatrixCommandPlatform(MockPlatformClient):
+ def __init__(self) -> None:
+ super().__init__()
+ self._prototype_state = PrototypeStateStore()
+ self._agent_api = object()
+ self.disconnect_chat = AsyncMock()
+ self.send_message = AsyncMock(
+ return_value=MessageResponse(
+ message_id="msg-1",
+ response="ok",
+ tokens_used=0,
+ finished=True,
+ )
+ )
+
+
+@pytest.fixture(autouse=True)
+def clear_matrix_registry_env(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.delenv("MATRIX_AGENT_REGISTRY_PATH", raising=False)
+ monkeypatch.delenv("MATRIX_PLATFORM_BACKEND", raising=False)
+
+
+@pytest.mark.asyncio
+async def test_save_command_auto_name_records_session():
+ platform = MatrixCommandPlatform()
+ store = InMemoryStore()
+ await set_room_meta(
+ store,
+ "!room:example.org",
+ {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41"},
+ )
+ handler = make_handle_save(
+ agent_api=platform._agent_api,
+ store=store,
+ prototype_state=platform._prototype_state,
+ )
+ event = IncomingCommand(
+ user_id="u1",
+ platform="matrix",
+ chat_id="!room:example.org",
+ command="save",
+ args=[],
+ )
+
+ result = await handler(event, None, platform, None, None)
+
+ assert len(result) == 1
+ assert isinstance(result[0], OutgoingMessage)
+ assert "Запрос на сохранение отправлен агенту" in result[0].text
+ sessions = await platform._prototype_state.list_saved_sessions("u1")
+ assert len(sessions) == 1
+ assert sessions[0]["name"].startswith("context-")
+ assert sessions[0]["source_context_id"] == "41"
+
+
+@pytest.mark.asyncio
+async def test_save_command_with_name_uses_given_name():
+ platform = MatrixCommandPlatform()
+ store = InMemoryStore()
+ await set_room_meta(
+ store,
+ "!room:example.org",
+ {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41"},
+ )
+ handler = make_handle_save(
+ agent_api=platform._agent_api,
+ store=store,
+ prototype_state=platform._prototype_state,
+ )
+ event = IncomingCommand(
+ user_id="u1",
+ platform="matrix",
+ chat_id="!room:example.org",
+ command="save",
+ args=["my-session"],
+ )
+
+ await handler(event, None, platform, None, None)
+
+ sessions = await platform._prototype_state.list_saved_sessions("u1")
+ assert [session["name"] for session in sessions] == ["my-session"]
+
+
+@pytest.mark.asyncio
+async def test_load_command_shows_numbered_list_and_sets_pending():
+ platform = MatrixCommandPlatform()
+ runtime = build_runtime(platform=platform)
+ await runtime.chat_mgr.get_or_create(
+ user_id="u1",
+ chat_id="C1",
+ platform="matrix",
+ surface_ref="!room:example.org",
+ name="Chat 1",
+ )
+ await platform._prototype_state.add_saved_session("u1", "session-a")
+ await platform._prototype_state.add_saved_session("u1", "session-b")
+
+ handler = make_handle_load(store=runtime.store, prototype_state=platform._prototype_state)
+ event = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="load", args=[])
+
+ result = await handler(
+ event,
+ runtime.auth_mgr,
+ platform,
+ runtime.chat_mgr,
+ runtime.settings_mgr,
+ )
+
+ assert "1. session-a" in result[0].text
+ assert "2. session-b" in result[0].text
+ pending = await get_load_pending(runtime.store, "u1", "!room:example.org")
+ assert pending is not None
+ assert [session["name"] for session in pending["saves"]] == ["session-a", "session-b"]
+
+
+@pytest.mark.asyncio
+async def test_load_command_without_saved_sessions_reports_empty():
+ platform = MatrixCommandPlatform()
+ store = InMemoryStore()
+ handler = make_handle_load(store=store, prototype_state=platform._prototype_state)
+ event = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="load", args=[])
+
+ result = await handler(event, None, platform, None, None)
+
+ assert "Нет сохранённых сессий" in result[0].text
+
+
+@pytest.mark.asyncio
+async def test_reset_command_assigns_new_platform_chat_id():
+ from adapter.matrix.store import get_platform_chat_id, set_room_meta
+ from sdk.prototype_state import PrototypeStateStore
+
+ prototype_state = PrototypeStateStore()
+ platform = MatrixCommandPlatform()
+ runtime = build_runtime(platform=platform)
+ store = runtime.store
+
+ await set_room_meta(store, "!room:example.org", {"platform_chat_id": "7"})
+
+ handler = make_handle_reset(store=store, prototype_state=prototype_state)
+ event = IncomingCommand(
+ user_id="u1",
+ platform="matrix",
+ chat_id="!room:example.org",
+ command="reset",
+ args=[],
+ )
+
+ result = await handler(
+ event,
+ runtime.auth_mgr,
+ platform,
+ runtime.chat_mgr,
+ runtime.settings_mgr,
+ )
+
+ new_id = await get_platform_chat_id(store, "!room:example.org")
+ assert new_id != "7"
+ assert new_id == "1"
+ assert "сброшен" in result[0].text.lower()
+
+
+@pytest.mark.asyncio
+async def test_clear_command_rotates_only_current_room_and_disconnects_old_upstream_chat():
+ from adapter.matrix.store import get_platform_chat_id
+
+ platform = MatrixCommandPlatform()
+ runtime = build_runtime(platform=platform)
+ await runtime.chat_mgr.get_or_create(
+ user_id="u1",
+ chat_id="C1",
+ platform="matrix",
+ surface_ref="!room-a:example.org",
+ name="Chat A",
+ )
+ await runtime.chat_mgr.get_or_create(
+ user_id="u1",
+ chat_id="C2",
+ platform="matrix",
+ surface_ref="!room-b:example.org",
+ name="Chat B",
+ )
+ await set_room_meta(
+ runtime.store,
+ "!room-a:example.org",
+ {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41"},
+ )
+ await set_room_meta(
+ runtime.store,
+ "!room-b:example.org",
+ {"chat_id": "C2", "matrix_user_id": "u1", "platform_chat_id": "99"},
+ )
+
+ handler = make_handle_reset(store=runtime.store, prototype_state=platform._prototype_state)
+ event = IncomingCommand(
+ user_id="u1",
+ platform="matrix",
+ chat_id="C1",
+ command="clear",
+ args=[],
+ )
+
+ result = await handler(
+ event,
+ runtime.auth_mgr,
+ platform,
+ runtime.chat_mgr,
+ runtime.settings_mgr,
+ )
+
+ room_a_chat_id = await get_platform_chat_id(runtime.store, "!room-a:example.org")
+ room_b_chat_id = await get_platform_chat_id(runtime.store, "!room-b:example.org")
+ assert room_a_chat_id == "1"
+ assert room_a_chat_id != "41"
+ assert room_b_chat_id == "99"
+ platform.disconnect_chat.assert_awaited_once_with("41")
+ assert "сброшен" in result[0].text.lower()
+
+
+def test_register_matrix_handlers_exposes_clear_and_optional_reset_alias():
+ dispatcher = SimpleNamespace(register=Mock())
+
+ register_matrix_handlers(
+ dispatcher,
+ client=object(),
+ store=object(),
+ registry=None,
+ prototype_state=PrototypeStateStore(),
+ )
+
+ clear_calls = [
+ call
+ for call in dispatcher.register.call_args_list
+ if call.args[:2] == (IncomingCommand, "clear")
+ ]
+ reset_calls = [
+ call
+ for call in dispatcher.register.call_args_list
+ if call.args[:2] == (IncomingCommand, "reset")
+ ]
+ assert clear_calls
+ assert len(reset_calls) <= 1
+
+
+@pytest.mark.asyncio
+async def test_context_command_shows_current_snapshot():
+ platform = MatrixCommandPlatform()
+ runtime = build_runtime(platform=platform)
+ await runtime.chat_mgr.get_or_create(
+ user_id="u1",
+ chat_id="C1",
+ platform="matrix",
+ surface_ref="!room:example.org",
+ name="Chat 1",
+ )
+ await set_room_meta(
+ runtime.store,
+ "!room:example.org",
+ {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41"},
+ )
+ await platform._prototype_state.set_current_session("41", "session-a")
+ await platform._prototype_state.set_last_tokens_used("41", 99)
+ await platform._prototype_state.add_saved_session("u1", "session-a")
+ handler = make_handle_context(store=runtime.store, prototype_state=platform._prototype_state)
+ event = IncomingCommand(
+ user_id="u1",
+ platform="matrix",
+ chat_id="C1",
+ command="context",
+ args=[],
+ )
+
+ result = await handler(
+ event,
+ runtime.auth_mgr,
+ platform,
+ runtime.chat_mgr,
+ runtime.settings_mgr,
+ )
+
+ assert "Контекст чата: 41" in result[0].text
+ assert "Сессия: session-a" in result[0].text
+ assert "Токены (последний ответ): 99" in result[0].text
+ assert "session-a" in result[0].text
+
+
+@pytest.mark.asyncio
+async def test_bot_intercepts_numeric_load_selection():
+ platform = MatrixCommandPlatform()
+ runtime = build_runtime(platform=platform)
+ await set_room_meta(
+ runtime.store,
+ "!room:example.org",
+ {
+ "chat_id": "C1",
+ "matrix_user_id": "@alice:example.org",
+ "platform_chat_id": "41",
+ },
+ )
+ client = SimpleNamespace(
+ user_id="@bot:example.org",
+ room_send=AsyncMock(),
+ )
+ bot = MatrixBot(client, runtime)
+ await set_load_pending(
+ runtime.store,
+ "@alice:example.org",
+ "!room:example.org",
+ {"saves": [{"name": "session-a", "created_at": "2026-04-17T00:00:00+00:00"}]},
+ )
+ room = SimpleNamespace(room_id="!room:example.org")
+ event = SimpleNamespace(sender="@alice:example.org", body="1")
+
+ await bot.on_room_message(room, event)
+
+ platform.send_message.assert_awaited_once()
+ assert await platform._prototype_state.get_current_session("41") == "session-a"
+ assert await platform._prototype_state.get_current_session("C1") == "session-a"
+ client.room_send.assert_awaited_once_with(
+ "!room:example.org",
+ "m.room.message",
+ {"msgtype": "m.text", "body": "Запрос на загрузку отправлен агенту: session-a"},
+ )
diff --git a/tests/adapter/matrix/test_converter.py b/tests/adapter/matrix/test_converter.py
new file mode 100644
index 0000000..3513913
--- /dev/null
+++ b/tests/adapter/matrix/test_converter.py
@@ -0,0 +1,179 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import adapter.matrix.converter as converter
+from adapter.matrix.converter import from_command, from_room_event
+from core.protocol import IncomingCallback, IncomingCommand, IncomingMessage
+
+
+def text_event(body: str, sender: str = "@a:m.org", event_id: str = "$e1"):
+ return SimpleNamespace(
+ sender=sender, body=body, event_id=event_id, msgtype="m.text", replyto_event_id=None
+ )
+
+
+def file_event(url: str = "mxc://x/y", filename: str = "doc.pdf", mime: str = "application/pdf"):
+ return SimpleNamespace(
+ sender="@a:m.org",
+ body=filename,
+ event_id="$e2",
+ msgtype="m.file",
+ replyto_event_id=None,
+ url=url,
+ mimetype=mime,
+ )
+
+
+def image_event(url: str = "mxc://x/img", mime: str = "image/jpeg"):
+ return SimpleNamespace(
+ sender="@a:m.org",
+ body="img.jpg",
+ event_id="$e3",
+ msgtype="m.image",
+ replyto_event_id=None,
+ url=url,
+ mimetype=mime,
+ )
+
+
+def content_file_event():
+ return SimpleNamespace(
+ sender="@a:m.org",
+ body="doc.pdf",
+ event_id="$e4",
+ msgtype=None,
+ replyto_event_id=None,
+ content={
+ "msgtype": "m.file",
+ "body": "nested.pdf",
+ "url": "mxc://x/nested",
+ "info": {"mimetype": "application/pdf"},
+ },
+ )
+
+
+def source_only_content_file_event():
+ return SimpleNamespace(
+ sender="@a:m.org",
+ body="doc.pdf",
+ event_id="$e5",
+ msgtype=None,
+ replyto_event_id=None,
+ source={
+ "content": {
+ "msgtype": "m.file",
+ "body": "source-only.pdf",
+ "url": "mxc://x/source-only",
+ "info": {"mimetype": "application/pdf"},
+ }
+ },
+ )
+
+
+def test_plain_text_to_incoming_message():
+ result = from_room_event(text_event("Hello"), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingMessage)
+ assert result.text == "Hello"
+ assert result.platform == "matrix"
+ assert result.chat_id == "C1"
+ assert result.attachments == []
+
+
+def test_bang_command_to_incoming_command():
+ result = from_room_event(text_event("!new Analysis"), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingCommand)
+ assert result.command == "new"
+ assert result.args == ["Analysis"]
+
+
+def test_list_command_maps_to_matrix_list_attachments():
+ result = from_room_event(text_event("!list"), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingCommand)
+ assert result.command == "matrix_list_attachments"
+ assert result.args == []
+
+
+def test_remove_all_maps_to_matrix_remove_attachment():
+ result = from_room_event(text_event("!remove all"), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingCommand)
+ assert result.command == "matrix_remove_attachment"
+ assert result.args == ["all"]
+
+
+def test_remove_index_maps_to_matrix_remove_attachment():
+ result = from_room_event(text_event("!remove 2"), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingCommand)
+ assert result.command == "matrix_remove_attachment"
+ assert result.args == ["2"]
+
+
+def test_remove_arbitrary_index_maps_to_matrix_remove_attachment():
+ result = from_room_event(text_event("!remove 99"), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingCommand)
+ assert result.command == "matrix_remove_attachment"
+ assert result.args == ["99"]
+
+
+def test_skills_alias_to_settings_command():
+ result = from_command("!skills", sender="@a:m.org", chat_id="C1")
+ assert isinstance(result, IncomingCommand)
+ assert result.command == "settings_skills"
+
+
+def test_yes_to_callback():
+ result = from_room_event(text_event("!yes"), room_id="!room:example.org", chat_id="C7")
+ assert isinstance(result, IncomingCallback)
+ assert result.action == "confirm"
+ assert result.chat_id == "C7"
+ assert result.payload["room_id"] == "!room:example.org"
+
+
+def test_no_to_callback():
+ result = from_room_event(text_event("!no"), room_id="!room:example.org", chat_id="C7")
+ assert isinstance(result, IncomingCallback)
+ assert result.action == "cancel"
+ assert result.chat_id == "C7"
+ assert result.payload["room_id"] == "!room:example.org"
+
+
+def test_file_attachment():
+ result = from_room_event(file_event(), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingMessage)
+ assert len(result.attachments) == 1
+ a = result.attachments[0]
+ assert a.type == "document"
+ assert a.url == "mxc://x/y"
+ assert a.filename == "doc.pdf"
+ assert a.mime_type == "application/pdf"
+
+
+def test_image_attachment():
+ result = from_room_event(image_event(), room_id="!r:m.org", chat_id="C1")
+ assert result.attachments[0].type == "image"
+ assert result.attachments[0].filename == "img.jpg"
+ assert result.attachments[0].mime_type == "image/jpeg"
+
+
+def test_attachment_falls_back_to_content_payload():
+ result = from_room_event(content_file_event(), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingMessage)
+ a = result.attachments[0]
+ assert a.type == "document"
+ assert a.url == "mxc://x/nested"
+ assert a.filename == "nested.pdf"
+ assert a.mime_type == "application/pdf"
+
+
+def test_attachment_falls_back_to_source_content_payload():
+ result = from_room_event(source_only_content_file_event(), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingMessage)
+ a = result.attachments[0]
+ assert a.type == "document"
+ assert a.url == "mxc://x/source-only"
+ assert a.filename == "source-only.pdf"
+ assert a.mime_type == "application/pdf"
+
+
+def test_converter_module_does_not_expose_reaction_callbacks():
+ assert not hasattr(converter, "from_reaction")
diff --git a/tests/adapter/matrix/test_dispatcher.py b/tests/adapter/matrix/test_dispatcher.py
new file mode 100644
index 0000000..1240f86
--- /dev/null
+++ b/tests/adapter/matrix/test_dispatcher.py
@@ -0,0 +1,1110 @@
+from __future__ import annotations
+
+import importlib
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+from nio import (
+ RoomMessageAudio,
+ RoomMessageFile,
+ RoomMessageImage,
+ RoomMessageText,
+ RoomMessageVideo,
+)
+from nio.api import RoomVisibility
+from nio.responses import SyncResponse
+
+from adapter.matrix.agent_registry import AgentDefinition, AgentRegistry
+from adapter.matrix.bot import MatrixBot, build_runtime, prepare_live_sync
+from adapter.matrix.handlers.auth import handle_invite
+from adapter.matrix.routed_platform import RoutedPlatformClient
+from adapter.matrix.store import (
+ add_staged_attachment,
+ get_platform_chat_id,
+ get_room_meta,
+ get_staged_attachments,
+ get_user_meta,
+ set_load_pending,
+ set_room_meta,
+ set_user_meta,
+)
+from core.protocol import (
+ Attachment,
+ IncomingCallback,
+ IncomingCommand,
+ IncomingMessage,
+ OutgoingMessage,
+)
+from sdk.interface import PlatformError
+from sdk.mock import MockPlatformClient
+
+
+async def test_matrix_dispatcher_registers_custom_handlers():
+ runtime = build_runtime(platform=MockPlatformClient())
+ current_chat_id = "C9"
+
+ start = IncomingCommand(
+ user_id="u1", platform="matrix", chat_id=current_chat_id, command="start"
+ )
+ await runtime.dispatcher.dispatch(start)
+
+ new = IncomingCommand(
+ user_id="u1", platform="matrix", chat_id=current_chat_id, command="new", args=["Research"]
+ )
+ result = await runtime.dispatcher.dispatch(new)
+ assert any(isinstance(r, OutgoingMessage) and "Research" in r.text for r in result)
+
+ chats = await runtime.chat_mgr.list_active("u1")
+ assert [c.chat_id for c in chats] == ["C1"]
+ assert [c.surface_ref for c in chats] == [current_chat_id]
+
+ new2 = IncomingCommand(
+ user_id="u1", platform="matrix", chat_id=current_chat_id, command="new", args=["Ops"]
+ )
+ await runtime.dispatcher.dispatch(new2)
+ chats = await runtime.chat_mgr.list_active("u1")
+ assert [c.chat_id for c in chats] == ["C1", "C2"]
+
+ skills = IncomingCommand(
+ user_id="u1", platform="matrix", chat_id=current_chat_id, command="settings_skills"
+ )
+ result = await runtime.dispatcher.dispatch(skills)
+ assert any(isinstance(r, OutgoingMessage) and "mvp" in r.text.lower() for r in result)
+
+ toggle = IncomingCallback(
+ user_id="u1",
+ platform="matrix",
+ chat_id="C1",
+ action="toggle_skill",
+ payload={"skill_index": 2},
+ )
+ result = await runtime.dispatcher.dispatch(toggle)
+ assert any(isinstance(r, OutgoingMessage) and "mvp" in r.text.lower() for r in result)
+
+
+async def test_new_chat_creates_real_matrix_room_when_client_available():
+ client = SimpleNamespace(
+ room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r2:example")),
+ room_put_state=AsyncMock(),
+ room_invite=AsyncMock(),
+ )
+ runtime = build_runtime(platform=MockPlatformClient(), client=client)
+ await set_user_meta(runtime.store, "u1", {"space_id": "!space:example", "next_chat_index": 7})
+
+ start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C3", command="start")
+ await runtime.dispatcher.dispatch(start)
+
+ new = IncomingCommand(
+ user_id="u1",
+ platform="matrix",
+ chat_id="C3",
+ command="new",
+ args=["Research"],
+ )
+ result = await runtime.dispatcher.dispatch(new)
+
+ # room_create is now called with agent_id=None when registry is not configured
+ assert client.room_create.await_count >= 1
+ client.room_put_state.assert_awaited_once()
+ put_call = client.room_put_state.call_args
+ assert (
+ put_call.kwargs.get("room_id") == "!space:example"
+ or put_call.args[0] == "!space:example"
+ )
+ chats = await runtime.chat_mgr.list_active("u1")
+ assert [c.chat_id for c in chats] == ["C7"]
+ assert [c.surface_ref for c in chats] == ["!r2:example"]
+ assert any(isinstance(r, OutgoingMessage) and "Research" in r.text for r in result)
+
+
+async def test_invite_event_creates_space_and_chat_room():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 4})
+ space_resp = SimpleNamespace(room_id="!space:example.org")
+ chat_resp = SimpleNamespace(room_id="!chat1:example.org")
+ client = SimpleNamespace(
+ join=AsyncMock(),
+ room_create=AsyncMock(side_effect=[space_resp, chat_resp]),
+ room_put_state=AsyncMock(),
+ room_invite=AsyncMock(),
+ room_send=AsyncMock(),
+ )
+ room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice")
+ event = SimpleNamespace(sender="@alice:example.org", membership="invite")
+
+ await handle_invite(
+ client,
+ room,
+ event,
+ runtime.platform,
+ runtime.store,
+ runtime.auth_mgr,
+ runtime.chat_mgr,
+ )
+
+ assert client.room_create.await_count == 2
+ first_call = client.room_create.call_args_list[0]
+ assert first_call.kwargs.get("space") is True or (
+ len(first_call.args) > 0 and first_call.kwargs.get("space") is True
+ )
+ assert first_call.kwargs.get("visibility") is RoomVisibility.private
+ assert first_call.kwargs.get("invite") == ["@alice:example.org"]
+ second_call = client.room_create.call_args_list[1]
+ assert second_call.kwargs.get("visibility") is RoomVisibility.private
+ assert second_call.kwargs.get("invite") == ["@alice:example.org"]
+ client.room_invite.assert_not_awaited()
+
+ client.room_put_state.assert_awaited_once()
+ put_state_call = client.room_put_state.call_args
+ assert (
+ put_state_call.kwargs.get("event_type") == "m.space.child"
+ or put_state_call.args[1] == "m.space.child"
+ )
+
+ user_meta = await get_user_meta(runtime.store, "@alice:example.org")
+ assert user_meta is not None
+ assert user_meta.get("space_id") == "!space:example.org"
+
+ room_meta = await get_room_meta(runtime.store, "!chat1:example.org")
+ assert room_meta is not None
+ assert room_meta["chat_id"] == "C4"
+ assert room_meta["space_id"] == "!space:example.org"
+
+ assert await runtime.auth_mgr.is_authenticated("@alice:example.org") is True
+ assert user_meta.get("next_chat_index") == 5
+ client.room_send.assert_awaited_once()
+
+
+async def test_invite_event_is_idempotent_per_user():
+ runtime = build_runtime(platform=MockPlatformClient())
+ space_resp = SimpleNamespace(room_id="!space:example.org")
+ chat_resp = SimpleNamespace(room_id="!chat1:example.org")
+ client = SimpleNamespace(
+ join=AsyncMock(),
+ room_create=AsyncMock(side_effect=[space_resp, chat_resp]),
+ room_put_state=AsyncMock(),
+ room_invite=AsyncMock(),
+ room_send=AsyncMock(),
+ )
+ room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice")
+ event = SimpleNamespace(sender="@alice:example.org", membership="invite")
+
+ await handle_invite(
+ client,
+ room,
+ event,
+ runtime.platform,
+ runtime.store,
+ runtime.auth_mgr,
+ runtime.chat_mgr,
+ )
+ await handle_invite(
+ client,
+ room,
+ event,
+ runtime.platform,
+ runtime.store,
+ runtime.auth_mgr,
+ runtime.chat_mgr,
+ )
+
+ assert client.join.await_count == 2
+ assert client.room_create.await_count == 2
+ assert client.room_send.await_count == 2
+
+
+async def test_bot_ignores_its_own_messages():
+ runtime = build_runtime(platform=MockPlatformClient())
+ client = SimpleNamespace(user_id="@bot:example.org")
+ bot = MatrixBot(client, runtime)
+ bot._send_all = AsyncMock()
+ runtime.dispatcher.dispatch = AsyncMock()
+ room = SimpleNamespace(room_id="!dm:example.org")
+ event = SimpleNamespace(sender="@bot:example.org", body="hello")
+
+ await bot.on_room_message(room, event)
+
+ runtime.dispatcher.dispatch.assert_not_awaited()
+ bot._send_all.assert_not_awaited()
+
+
+async def test_bot_degrades_platform_errors_to_user_reply():
+ runtime = build_runtime(platform=MockPlatformClient())
+ client = SimpleNamespace(
+ user_id="@bot:example.org",
+ room_send=AsyncMock(),
+ )
+ bot = MatrixBot(client, runtime)
+ runtime.dispatcher.dispatch = AsyncMock(
+ side_effect=PlatformError("Missing Authentication header", code="401")
+ )
+ room = SimpleNamespace(room_id="!dm:example.org")
+ event = SimpleNamespace(sender="@alice:example.org", body="hello")
+
+ await bot.on_room_message(room, event)
+
+ client.room_send.assert_awaited_once_with(
+ "!dm:example.org",
+ "m.room.message",
+ {
+ "msgtype": "m.text",
+ "body": "Сервис временно недоступен. Попробуйте ещё раз позже.",
+ },
+ )
+
+
+async def test_bot_assigns_platform_chat_id_for_existing_managed_room():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await set_room_meta(
+ runtime.store,
+ "!chat1:example.org",
+ {"chat_id": "C1", "matrix_user_id": "@alice:example.org"},
+ )
+ client = SimpleNamespace(user_id="@bot:example.org")
+ bot = MatrixBot(client, runtime)
+ bot._send_all = AsyncMock()
+ runtime.dispatcher.dispatch = AsyncMock(return_value=[])
+ room = SimpleNamespace(room_id="!chat1:example.org")
+ event = SimpleNamespace(sender="@alice:example.org", body="hello")
+
+ await bot.on_room_message(room, event)
+
+ assert await get_platform_chat_id(runtime.store, "!chat1:example.org") == "1"
+ runtime.dispatcher.dispatch.assert_awaited_once()
+
+
+async def test_bot_keeps_local_chat_id_for_plain_messages():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await set_room_meta(
+ runtime.store,
+ "!chat1:example.org",
+ {
+ "chat_id": "C1",
+ "matrix_user_id": "@alice:example.org",
+ "platform_chat_id": "41",
+ },
+ )
+ client = SimpleNamespace(user_id="@bot:example.org")
+ bot = MatrixBot(client, runtime)
+ bot._send_all = AsyncMock()
+ runtime.dispatcher.dispatch = AsyncMock(return_value=[])
+ room = SimpleNamespace(room_id="!chat1:example.org")
+ event = SimpleNamespace(sender="@alice:example.org", body="hello")
+
+ await bot.on_room_message(room, event)
+
+ dispatched = runtime.dispatcher.dispatch.await_args.args[0]
+ assert dispatched.chat_id == "C1"
+ assert dispatched.text == "hello"
+
+
+async def test_bot_downloads_matrix_file_to_workspace_before_staging(tmp_path, monkeypatch):
+ monkeypatch.setenv("SURFACES_WORKSPACE_DIR", str(tmp_path))
+ runtime = build_runtime(platform=MockPlatformClient())
+ await set_room_meta(
+ runtime.store,
+ "!chat1:example.org",
+ {
+ "chat_id": "C1",
+ "matrix_user_id": "@alice:example.org",
+ "platform_chat_id": "41",
+ },
+ )
+ client = SimpleNamespace(
+ user_id="@bot:example.org",
+ download=AsyncMock(return_value=SimpleNamespace(body=b"%PDF-1.7")),
+ )
+ bot = MatrixBot(client, runtime)
+ bot._send_all = AsyncMock()
+ runtime.dispatcher.dispatch = AsyncMock(return_value=[])
+ room = SimpleNamespace(room_id="!chat1:example.org")
+ event = SimpleNamespace(
+ sender="@alice:example.org",
+ body="report.pdf",
+ msgtype="m.file",
+ replyto_event_id=None,
+ url="mxc://server/id",
+ mimetype="application/pdf",
+ )
+
+ await bot.on_room_message(room, event)
+
+ runtime.dispatcher.dispatch.assert_not_awaited()
+ staged = await get_staged_attachments(runtime.store, "!chat1:example.org", "@alice:example.org")
+ assert staged[0]["workspace_path"] is not None
+ assert (tmp_path / staged[0]["workspace_path"]).read_bytes() == b"%PDF-1.7"
+ bot._send_all.assert_not_awaited()
+
+
+async def test_bot_downloads_matrix_file_to_configured_agent_workspace(tmp_path, monkeypatch):
+ monkeypatch.setenv("SURFACES_WORKSPACE_DIR", str(tmp_path / "agents"))
+ runtime = build_runtime(platform=MockPlatformClient())
+ runtime.registry = AgentRegistry(
+ [
+ AgentDefinition(
+ agent_id="agent-17",
+ label="Agent 17",
+ base_url="http://lambda.coredump.ru:7000/agent_17/",
+ workspace_path=str(tmp_path / "agents" / "17"),
+ )
+ ],
+ user_agents={"@alice:example.org": "agent-17"},
+ )
+ await set_room_meta(
+ runtime.store,
+ "!chat17:example.org",
+ {
+ "chat_id": "C17",
+ "matrix_user_id": "@alice:example.org",
+ "platform_chat_id": "17",
+ "agent_id": "agent-17",
+ },
+ )
+ client = SimpleNamespace(
+ user_id="@bot:example.org",
+ download=AsyncMock(return_value=SimpleNamespace(body=b"%PDF-1.7")),
+ )
+ bot = MatrixBot(client, runtime)
+ runtime.dispatcher.dispatch = AsyncMock(return_value=[])
+ room = SimpleNamespace(room_id="!chat17:example.org")
+ event = SimpleNamespace(
+ sender="@alice:example.org",
+ body="report.pdf",
+ msgtype="m.file",
+ replyto_event_id=None,
+ url="mxc://server/id",
+ mimetype="application/pdf",
+ )
+
+ await bot.on_room_message(room, event)
+
+ staged = await get_staged_attachments(
+ runtime.store, "!chat17:example.org", "@alice:example.org"
+ )
+ assert staged[0]["workspace_path"] == "report.pdf"
+ assert (
+ tmp_path / "agents" / "17" / staged[0]["workspace_path"]
+ ).read_bytes() == b"%PDF-1.7"
+
+
+async def test_bot_uploads_agent_output_from_configured_agent_workspace(tmp_path, monkeypatch):
+ monkeypatch.setenv("SURFACES_WORKSPACE_DIR", str(tmp_path / "agents"))
+ output_file = tmp_path / "agents" / "17" / "result.txt"
+ output_file.parent.mkdir(parents=True)
+ output_file.write_text("ready", encoding="utf-8")
+ runtime = build_runtime(platform=MockPlatformClient())
+ runtime.registry = AgentRegistry(
+ [
+ AgentDefinition(
+ agent_id="agent-17",
+ label="Agent 17",
+ base_url="http://lambda.coredump.ru:7000/agent_17/",
+ workspace_path=str(tmp_path / "agents" / "17"),
+ )
+ ],
+ user_agents={"@alice:example.org": "agent-17"},
+ )
+ await set_room_meta(
+ runtime.store,
+ "!chat17:example.org",
+ {
+ "chat_id": "C17",
+ "matrix_user_id": "@alice:example.org",
+ "platform_chat_id": "17",
+ "agent_id": "agent-17",
+ },
+ )
+ client = SimpleNamespace(
+ user_id="@bot:example.org",
+ upload=AsyncMock(return_value=(SimpleNamespace(content_uri="mxc://server/result"), {})),
+ room_send=AsyncMock(),
+ )
+ bot = MatrixBot(client, runtime)
+ runtime.dispatcher.dispatch = AsyncMock(
+ return_value=[
+ OutgoingMessage(
+ chat_id="C17",
+ text="Файл готов",
+ attachments=[
+ Attachment(
+ type="document",
+ filename="result.txt",
+ mime_type="text/plain",
+ workspace_path="result.txt",
+ )
+ ],
+ )
+ ]
+ )
+ room = SimpleNamespace(room_id="!chat17:example.org")
+ event = SimpleNamespace(
+ sender="@alice:example.org",
+ body="сделай отчёт",
+ msgtype="m.text",
+ replyto_event_id=None,
+ )
+
+ await bot.on_room_message(room, event)
+
+ uploaded_handle = client.upload.await_args.args[0]
+ assert uploaded_handle.name == str(output_file)
+ assert client.room_send.await_args_list[1].args[2]["url"] == "mxc://server/result"
+
+
+async def test_file_only_event_is_staged_and_does_not_dispatch():
+ runtime = build_runtime(platform=MockPlatformClient())
+ client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
+ bot = MatrixBot(client, runtime)
+ runtime.dispatcher.dispatch = AsyncMock(return_value=[])
+ bot._materialize_incoming_attachments = AsyncMock(
+ return_value=IncomingMessage(
+ user_id="@alice:example.org",
+ platform="matrix",
+ chat_id="!r:example.org",
+ text="",
+ attachments=[
+ Attachment(
+ type="document",
+ filename="report.pdf",
+ workspace_path="surfaces/matrix/alice/r/inbox/report.pdf",
+ mime_type="application/pdf",
+ )
+ ],
+ )
+ )
+ room = SimpleNamespace(room_id="!r:example.org")
+ event = SimpleNamespace(
+ sender="@alice:example.org",
+ body="report.pdf",
+ msgtype="m.file",
+ url="mxc://hs/id",
+ mimetype="application/pdf",
+ replyto_event_id=None,
+ )
+
+ await bot.on_room_message(room, event)
+
+ runtime.dispatcher.dispatch.assert_not_awaited()
+ staged = await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org")
+ assert [item["filename"] for item in staged] == ["report.pdf"]
+ client.room_send.assert_not_awaited()
+
+
+async def test_list_command_returns_current_staged_attachments():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await add_staged_attachment(
+ runtime.store,
+ "!r:example.org",
+ "@alice:example.org",
+ {"filename": "a.pdf", "workspace_path": "a.pdf"},
+ )
+ await add_staged_attachment(
+ runtime.store,
+ "!r:example.org",
+ "@alice:example.org",
+ {"filename": "b.pdf", "workspace_path": "b.pdf"},
+ )
+ client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
+ bot = MatrixBot(client, runtime)
+ runtime.dispatcher.dispatch = AsyncMock(return_value=[])
+ room = SimpleNamespace(room_id="!r:example.org")
+ event = SimpleNamespace(
+ sender="@alice:example.org", body="!list", msgtype="m.text", replyto_event_id=None
+ )
+
+ await bot.on_room_message(room, event)
+
+ runtime.dispatcher.dispatch.assert_not_awaited()
+ body = client.room_send.await_args.args[2]["body"]
+ assert "1. a.pdf" in body
+ assert "2. b.pdf" in body
+
+
+async def test_remove_invalid_index_returns_short_error():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await add_staged_attachment(
+ runtime.store,
+ "!r:example.org",
+ "@alice:example.org",
+ {"filename": "a.pdf", "workspace_path": "a.pdf"},
+ )
+ client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
+ bot = MatrixBot(client, runtime)
+ runtime.dispatcher.dispatch = AsyncMock(return_value=[])
+ room = SimpleNamespace(room_id="!r:example.org")
+ event = SimpleNamespace(
+ sender="@alice:example.org", body="!remove 9", msgtype="m.text", replyto_event_id=None
+ )
+
+ await bot.on_room_message(room, event)
+
+ runtime.dispatcher.dispatch.assert_not_awaited()
+ assert client.room_send.await_args.args[2]["body"] == "Нет такого вложения."
+
+
+async def test_remove_attachment_updates_list_and_state():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await add_staged_attachment(
+ runtime.store,
+ "!r:example.org",
+ "@alice:example.org",
+ {"filename": "a.pdf", "workspace_path": "a.pdf"},
+ )
+ await add_staged_attachment(
+ runtime.store,
+ "!r:example.org",
+ "@alice:example.org",
+ {"filename": "b.pdf", "workspace_path": "b.pdf"},
+ )
+ client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
+ bot = MatrixBot(client, runtime)
+ runtime.dispatcher.dispatch = AsyncMock(return_value=[])
+ room = SimpleNamespace(room_id="!r:example.org")
+ event = SimpleNamespace(
+ sender="@alice:example.org", body="!remove 1", msgtype="m.text", replyto_event_id=None
+ )
+
+ await bot.on_room_message(room, event)
+
+ runtime.dispatcher.dispatch.assert_not_awaited()
+ staged = await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org")
+ assert [item["filename"] for item in staged] == ["b.pdf"]
+ body = client.room_send.await_args.args[2]["body"]
+ assert "1. b.pdf" in body
+ assert "a.pdf" not in body
+
+
+async def test_remove_all_clears_state():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await add_staged_attachment(
+ runtime.store,
+ "!r:example.org",
+ "@alice:example.org",
+ {"filename": "a.pdf", "workspace_path": "a.pdf"},
+ )
+ client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
+ bot = MatrixBot(client, runtime)
+ runtime.dispatcher.dispatch = AsyncMock(return_value=[])
+ room = SimpleNamespace(room_id="!r:example.org")
+ event = SimpleNamespace(
+ sender="@alice:example.org",
+ body="!remove all",
+ msgtype="m.text",
+ replyto_event_id=None,
+ )
+
+ await bot.on_room_message(room, event)
+
+ runtime.dispatcher.dispatch.assert_not_awaited()
+ assert await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org") == []
+ assert client.room_send.await_args.args[2]["body"] == "Все вложения удалены."
+
+
+async def test_staged_attachment_commands_are_scoped_by_room_and_user():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await add_staged_attachment(
+ runtime.store,
+ "!r-one:example.org",
+ "@alice:example.org",
+ {"filename": "alice-room-one.pdf", "workspace_path": "alice-room-one.pdf"},
+ )
+ await add_staged_attachment(
+ runtime.store,
+ "!r-two:example.org",
+ "@alice:example.org",
+ {"filename": "alice-room-two.pdf", "workspace_path": "alice-room-two.pdf"},
+ )
+ await add_staged_attachment(
+ runtime.store,
+ "!r-one:example.org",
+ "@bob:example.org",
+ {"filename": "bob-room-one.pdf", "workspace_path": "bob-room-one.pdf"},
+ )
+ client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
+ bot = MatrixBot(client, runtime)
+ runtime.dispatcher.dispatch = AsyncMock(return_value=[])
+ room = SimpleNamespace(room_id="!r-one:example.org")
+ event = SimpleNamespace(
+ sender="@alice:example.org",
+ body="!list",
+ msgtype="m.text",
+ replyto_event_id=None,
+ )
+
+ await bot.on_room_message(room, event)
+
+ runtime.dispatcher.dispatch.assert_not_awaited()
+ body = client.room_send.await_args.args[2]["body"]
+ assert "alice-room-one.pdf" in body
+ assert "alice-room-two.pdf" not in body
+ assert "bob-room-one.pdf" not in body
+
+
+async def test_next_normal_message_commits_staged_attachments():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await set_room_meta(
+ runtime.store,
+ "!r:example.org",
+ {
+ "chat_id": "C1",
+ "matrix_user_id": "@alice:example.org",
+ "platform_chat_id": "41",
+ },
+ )
+ await add_staged_attachment(
+ runtime.store,
+ "!r:example.org",
+ "@alice:example.org",
+ {
+ "type": "document",
+ "filename": "report.pdf",
+ "workspace_path": "surfaces/matrix/alice/r/inbox/report.pdf",
+ "mime_type": "application/pdf",
+ },
+ )
+ client = SimpleNamespace(user_id="@bot:example.org")
+ bot = MatrixBot(client, runtime)
+ bot._send_all = AsyncMock()
+ runtime.dispatcher.dispatch = AsyncMock(return_value=[])
+ room = SimpleNamespace(room_id="!r:example.org")
+ event = SimpleNamespace(
+ sender="@alice:example.org",
+ body="Проанализируй",
+ msgtype="m.text",
+ replyto_event_id=None,
+ )
+
+ await bot.on_room_message(room, event)
+
+ dispatched = runtime.dispatcher.dispatch.await_args.args[0]
+ assert isinstance(dispatched, IncomingMessage)
+ assert dispatched.text == "Проанализируй"
+ assert [a.workspace_path for a in dispatched.attachments] == [
+ "surfaces/matrix/alice/r/inbox/report.pdf"
+ ]
+ assert await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org") == []
+
+
+async def test_failed_commit_preserves_staged_attachments():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await set_room_meta(
+ runtime.store,
+ "!r:example.org",
+ {
+ "chat_id": "C1",
+ "matrix_user_id": "@alice:example.org",
+ "platform_chat_id": "41",
+ },
+ )
+ await add_staged_attachment(
+ runtime.store,
+ "!r:example.org",
+ "@alice:example.org",
+ {
+ "type": "document",
+ "filename": "report.pdf",
+ "workspace_path": "surfaces/matrix/alice/r/inbox/report.pdf",
+ },
+ )
+ client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
+ bot = MatrixBot(client, runtime)
+ runtime.dispatcher.dispatch = AsyncMock(side_effect=PlatformError("boom"))
+ room = SimpleNamespace(room_id="!r:example.org")
+ event = SimpleNamespace(
+ sender="@alice:example.org",
+ body="Проанализируй",
+ msgtype="m.text",
+ replyto_event_id=None,
+ )
+
+ await bot.on_room_message(room, event)
+
+ staged = await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org")
+ assert [item["filename"] for item in staged] == ["report.pdf"]
+
+
+async def test_bot_keeps_commands_on_local_chat_id():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await set_room_meta(
+ runtime.store,
+ "!chat1:example.org",
+ {
+ "chat_id": "C1",
+ "matrix_user_id": "@alice:example.org",
+ "platform_chat_id": "41",
+ },
+ )
+ client = SimpleNamespace(user_id="@bot:example.org")
+ bot = MatrixBot(client, runtime)
+ bot._send_all = AsyncMock()
+ runtime.dispatcher.dispatch = AsyncMock(return_value=[])
+ room = SimpleNamespace(room_id="!chat1:example.org")
+ event = SimpleNamespace(sender="@alice:example.org", body="!rename New")
+
+ await bot.on_room_message(room, event)
+
+ dispatched = runtime.dispatcher.dispatch.await_args.args[0]
+ assert dispatched.chat_id == "C1"
+ assert dispatched.command == "rename"
+
+
+async def test_bot_leaves_existing_platform_chat_id_unchanged():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await set_room_meta(
+ runtime.store,
+ "!chat1:example.org",
+ {
+ "chat_id": "C1",
+ "matrix_user_id": "@alice:example.org",
+ "platform_chat_id": "99",
+ },
+ )
+ client = SimpleNamespace(user_id="@bot:example.org")
+ bot = MatrixBot(client, runtime)
+ bot._send_all = AsyncMock()
+ runtime.dispatcher.dispatch = AsyncMock(return_value=[])
+ room = SimpleNamespace(room_id="!chat1:example.org")
+ event = SimpleNamespace(sender="@alice:example.org", body="hello")
+
+ await bot.on_room_message(room, event)
+
+ assert await get_platform_chat_id(runtime.store, "!chat1:example.org") == "99"
+ runtime.dispatcher.dispatch.assert_awaited_once()
+
+
+async def test_bot_assigns_platform_chat_id_before_load_selection():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await set_room_meta(
+ runtime.store,
+ "!chat1:example.org",
+ {"chat_id": "C1", "matrix_user_id": "@alice:example.org"},
+ )
+ client = SimpleNamespace(
+ user_id="@bot:example.org",
+ room_send=AsyncMock(),
+ )
+ bot = MatrixBot(client, runtime)
+ await set_load_pending(
+ runtime.store,
+ "@alice:example.org",
+ "!chat1:example.org",
+ {"saves": [{"name": "session-a", "created_at": "2026-04-17T00:00:00+00:00"}]},
+ )
+ room = SimpleNamespace(room_id="!chat1:example.org")
+ event = SimpleNamespace(sender="@alice:example.org", body="0")
+
+ await bot.on_room_message(room, event)
+
+ assert await get_platform_chat_id(runtime.store, "!chat1:example.org") == "1"
+ client.room_send.assert_awaited_once_with(
+ "!chat1:example.org",
+ "m.room.message",
+ {"msgtype": "m.text", "body": "Отменено."},
+ )
+
+
+async def test_unregistered_room_bootstraps_space_and_chat_on_first_message():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 1})
+ space_resp = SimpleNamespace(room_id="!space:example.org")
+ chat_resp = SimpleNamespace(room_id="!chat1:example.org")
+ client = SimpleNamespace(
+ user_id="@bot:example.org",
+ room_create=AsyncMock(side_effect=[space_resp, chat_resp]),
+ room_put_state=AsyncMock(),
+ room_send=AsyncMock(),
+ )
+ bot = MatrixBot(client, runtime)
+ room = SimpleNamespace(room_id="!entry:example.org", display_name="Entry")
+ event = SimpleNamespace(sender="@alice:example.org", body="hello")
+
+ await bot.on_room_message(room, event)
+
+ assert client.room_create.await_count == 2
+ first_call = client.room_create.call_args_list[0]
+ second_call = client.room_create.call_args_list[1]
+ assert first_call.kwargs.get("space") is True
+ assert first_call.kwargs.get("invite") == ["@alice:example.org"]
+ assert second_call.kwargs.get("name") == "Чат 1"
+ assert second_call.kwargs.get("invite") == ["@alice:example.org"]
+ client.room_put_state.assert_awaited_once()
+ room_meta = await get_room_meta(runtime.store, "!chat1:example.org")
+ assert room_meta is not None
+ assert room_meta["chat_id"] == "C1"
+ user_meta = await get_user_meta(runtime.store, "@alice:example.org")
+ assert user_meta is not None
+ assert user_meta["space_id"] == "!space:example.org"
+ room_send_calls = client.room_send.await_args_list
+ assert any(call.args[0] == "!chat1:example.org" for call in room_send_calls)
+ assert any(call.args[0] == "!entry:example.org" for call in room_send_calls)
+ entry_meta = await get_room_meta(runtime.store, "!entry:example.org")
+ assert entry_meta == {
+ "matrix_user_id": "@alice:example.org",
+ "redirect_room_id": "!chat1:example.org",
+ "redirect_chat_id": "C1",
+ }
+
+
+async def test_unregistered_room_second_message_reuses_existing_bootstrap():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 1})
+ space_resp = SimpleNamespace(room_id="!space:example.org")
+ chat_resp = SimpleNamespace(room_id="!chat1:example.org")
+ client = SimpleNamespace(
+ user_id="@bot:example.org",
+ room_create=AsyncMock(side_effect=[space_resp, chat_resp]),
+ room_put_state=AsyncMock(),
+ room_send=AsyncMock(),
+ )
+ bot = MatrixBot(client, runtime)
+ room = SimpleNamespace(room_id="!entry:example.org", display_name="Entry")
+
+ await bot.on_room_message(room, SimpleNamespace(sender="@alice:example.org", body="hello"))
+ await bot.on_room_message(
+ room, SimpleNamespace(sender="@alice:example.org", body="hello again")
+ )
+
+ assert client.room_create.await_count == 2
+ room_send_calls = client.room_send.await_args_list
+ assert any(call.args[0] == "!entry:example.org" for call in room_send_calls)
+ assert any(
+ call.args[0] == "!entry:example.org"
+ and "Рабочий чат уже создан: C1" in call.args[2]["body"]
+ for call in room_send_calls
+ )
+ entry_meta = await get_room_meta(runtime.store, "!entry:example.org")
+ assert entry_meta is not None
+ assert "platform_chat_id" not in entry_meta
+
+
+async def test_unregistered_room_welcome_send_failure_does_not_repeat_bootstrap():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 1})
+ space_resp = SimpleNamespace(room_id="!space:example.org")
+ chat_resp = SimpleNamespace(room_id="!chat1:example.org")
+ client = SimpleNamespace(
+ user_id="@bot:example.org",
+ room_create=AsyncMock(side_effect=[space_resp, chat_resp]),
+ room_put_state=AsyncMock(),
+ room_send=AsyncMock(side_effect=[RuntimeError("welcome failed"), None]),
+ )
+ bot = MatrixBot(client, runtime)
+ room = SimpleNamespace(room_id="!entry:example.org", display_name="Entry")
+
+ with pytest.raises(RuntimeError, match="welcome failed"):
+ await bot.on_room_message(room, SimpleNamespace(sender="@alice:example.org", body="hello"))
+
+ entry_meta = await get_room_meta(runtime.store, "!entry:example.org")
+ assert entry_meta == {
+ "matrix_user_id": "@alice:example.org",
+ "redirect_room_id": "!chat1:example.org",
+ "redirect_chat_id": "C1",
+ }
+
+ await bot.on_room_message(
+ room, SimpleNamespace(sender="@alice:example.org", body="hello again")
+ )
+
+ assert client.room_create.await_count == 2
+ room_send_calls = client.room_send.await_args_list
+ assert any(
+ call.args[0] == "!entry:example.org"
+ and "Рабочий чат уже создан: C1" in call.args[2]["body"]
+ for call in room_send_calls
+ )
+
+
+async def test_unregistered_room_creates_new_chat_in_existing_space():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await set_user_meta(
+ runtime.store,
+ "@alice:example.org",
+ {"space_id": "!space:example.org", "next_chat_index": 4},
+ )
+ chat_resp = SimpleNamespace(room_id="!chat4:example.org")
+ client = SimpleNamespace(
+ user_id="@bot:example.org",
+ room_create=AsyncMock(return_value=chat_resp),
+ room_put_state=AsyncMock(),
+ room_send=AsyncMock(),
+ )
+ bot = MatrixBot(client, runtime)
+ room = SimpleNamespace(room_id="!entry:example.org", display_name="Entry")
+ event = SimpleNamespace(sender="@alice:example.org", body="hello")
+
+ await bot.on_room_message(room, event)
+
+ client.room_create.assert_awaited_once_with(
+ name="Чат 4",
+ visibility=RoomVisibility.private,
+ is_direct=False,
+ invite=["@alice:example.org"],
+ )
+ client.room_put_state.assert_awaited_once()
+ room_meta = await get_room_meta(runtime.store, "!chat4:example.org")
+ assert room_meta is not None
+ assert room_meta["chat_id"] == "C4"
+
+
+async def test_mat11_settings_returns_mvp_unavailable_message():
+ runtime = build_runtime(platform=MockPlatformClient())
+ current_chat_id = "C9"
+
+ start = IncomingCommand(
+ user_id="u1", platform="matrix", chat_id=current_chat_id, command="start"
+ )
+ await runtime.dispatcher.dispatch(start)
+
+ settings_cmd = IncomingCommand(
+ user_id="u1", platform="matrix", chat_id=current_chat_id, command="settings"
+ )
+ result = await runtime.dispatcher.dispatch(settings_cmd)
+
+ assert len(result) == 1
+ text = result[0].text
+ assert "недоступна" in text.lower()
+ assert "mvp" in text.lower()
+
+
+async def test_mat12_help_returns_command_reference():
+ runtime = build_runtime(platform=MockPlatformClient())
+
+ result = await runtime.dispatcher.dispatch(
+ IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="help")
+ )
+
+ assert len(result) == 1
+ text = result[0].text
+ assert "!new" in text
+ assert "!chats" in text
+ assert "!rename" in text
+ assert "!archive" in text
+ assert "!clear" in text
+ assert "!list" in text
+ assert "!yes" in text
+ assert "!context" not in text
+ assert "!save" not in text
+ assert "!load" not in text
+ assert "!agent" not in text
+ assert "!settings" not in text
+ assert "!skills" not in text
+
+
+async def test_unknown_command_returns_helpful_message():
+ runtime = build_runtime(platform=MockPlatformClient())
+
+ result = await runtime.dispatcher.dispatch(
+ IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="clear")
+ )
+
+ assert len(result) == 1
+ assert "неизвестная команда" in result[0].text.lower()
+
+
+async def test_prepare_live_sync_returns_next_batch_from_bootstrap_sync():
+ client = SimpleNamespace(
+ sync=AsyncMock(
+ return_value=SyncResponse(
+ next_batch="s123",
+ rooms={},
+ device_key_count={},
+ device_list=SimpleNamespace(changed=[], left=[]),
+ to_device_events=[],
+ presence_events=[],
+ account_data_events=[],
+ )
+ )
+ )
+
+ since = await prepare_live_sync(client)
+
+ client.sync.assert_awaited_once_with(timeout=0, full_state=True)
+ assert since == "s123"
+
+
+async def test_build_runtime_uses_routed_platform_when_matrix_backend_is_real(
+ monkeypatch, tmp_path
+):
+ registry_path = tmp_path / "agents.yaml"
+ registry_path.write_text(
+ "agents:\n - id: agent-1\n label: Analyst\n", encoding="utf-8"
+ )
+ monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real")
+ monkeypatch.setenv("AGENT_BASE_URL", "http://agent.example")
+ monkeypatch.setenv("MATRIX_AGENT_REGISTRY_PATH", str(registry_path))
+
+ runtime = build_runtime()
+
+ assert isinstance(runtime.platform, RoutedPlatformClient)
+
+
+async def test_matrix_main_closes_platform_without_connecting_root_agent(monkeypatch):
+ bot_module = importlib.import_module("adapter.matrix.bot")
+
+ platform_close = AsyncMock()
+ runtime = SimpleNamespace(platform=SimpleNamespace(close=platform_close))
+
+ class FakeAsyncClient:
+ def __init__(self, *args, **kwargs):
+ self.access_token = None
+ self.callbacks = []
+ self.sync_forever = AsyncMock()
+ self.close = AsyncMock()
+
+ async def login(self, *args, **kwargs):
+ raise AssertionError("login should not be called when access token is provided")
+
+ def add_event_callback(self, callback, event_type):
+ self.callbacks.append((callback, event_type))
+
+ monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
+ monkeypatch.setenv("MATRIX_USER_ID", "@bot:example.org")
+ monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "token")
+ monkeypatch.setattr(bot_module, "AsyncClient", FakeAsyncClient)
+ monkeypatch.setattr(bot_module, "build_runtime", lambda **kwargs: runtime)
+ monkeypatch.setattr(bot_module, "prepare_live_sync", AsyncMock(return_value="s123"))
+
+ await bot_module.main()
+
+ platform_close.assert_awaited_once()
+
+
+async def test_matrix_main_registers_media_message_callbacks(monkeypatch):
+ bot_module = importlib.import_module("adapter.matrix.bot")
+
+ runtime = SimpleNamespace(platform=SimpleNamespace(close=AsyncMock()))
+ created_clients = []
+
+ class FakeAsyncClient:
+ def __init__(self, *args, **kwargs):
+ self.access_token = None
+ self.callbacks = []
+ self.sync_forever = AsyncMock()
+ self.close = AsyncMock()
+ created_clients.append(self)
+
+ async def login(self, *args, **kwargs):
+ raise AssertionError("login should not be called when access token is provided")
+
+ def add_event_callback(self, callback, event_type):
+ self.callbacks.append((callback, event_type))
+
+ monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
+ monkeypatch.setenv("MATRIX_USER_ID", "@bot:example.org")
+ monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "token")
+ monkeypatch.setattr(bot_module, "AsyncClient", FakeAsyncClient)
+ monkeypatch.setattr(bot_module, "build_runtime", lambda **kwargs: runtime)
+ monkeypatch.setattr(bot_module, "prepare_live_sync", AsyncMock(return_value="s123"))
+
+ await bot_module.main()
+
+ assert len(created_clients) == 1
+ registered_types = [event_type for _, event_type in created_clients[0].callbacks]
+ assert (
+ RoomMessageText,
+ RoomMessageFile,
+ RoomMessageImage,
+ RoomMessageVideo,
+ RoomMessageAudio,
+ ) in registered_types
diff --git a/tests/adapter/matrix/test_files.py b/tests/adapter/matrix/test_files.py
new file mode 100644
index 0000000..a3a9146
--- /dev/null
+++ b/tests/adapter/matrix/test_files.py
@@ -0,0 +1,94 @@
+from __future__ import annotations
+
+from pathlib import Path
+from types import SimpleNamespace
+
+from adapter.matrix.files import (
+ build_agent_workspace_path,
+ download_matrix_attachment,
+)
+from core.protocol import Attachment
+
+
+async def test_download_matrix_attachment_persists_file_and_returns_workspace_path(tmp_path: Path):
+ async def download(url: str):
+ assert url == "mxc://server/id"
+ return SimpleNamespace(body=b"%PDF-1.7")
+
+ client = SimpleNamespace(download=download)
+ attachment = Attachment(
+ type="document",
+ url="mxc://server/id",
+ filename="report.pdf",
+ mime_type="application/pdf",
+ )
+
+ saved = await download_matrix_attachment(
+ client=client,
+ workspace_root=tmp_path,
+ matrix_user_id="@alice:example.org",
+ room_id="!room:example.org",
+ attachment=attachment,
+ timestamp="20260420-153000",
+ )
+
+ assert saved.workspace_path == "report.pdf"
+ assert (tmp_path / "report.pdf").read_bytes() == b"%PDF-1.7"
+
+
+def test_build_agent_workspace_path_uses_agent_workspace_volume(tmp_path: Path):
+ rel_path, abs_path = build_agent_workspace_path(
+ workspace_root=tmp_path / "agents" / "17",
+ filename="quarterly status.pdf",
+ )
+
+ assert rel_path == "quarterly status.pdf"
+ assert abs_path == tmp_path / "agents" / "17" / rel_path
+
+
+def test_build_agent_workspace_path_uses_windows_style_copy_index(tmp_path: Path):
+ workspace_root = tmp_path / "agents" / "17"
+ workspace_root.mkdir(parents=True)
+ (workspace_root / "report.pdf").write_bytes(b"old")
+ (workspace_root / "report (1).pdf").write_bytes(b"older")
+
+ rel_path, abs_path = build_agent_workspace_path(
+ workspace_root=workspace_root,
+ filename="report.pdf",
+ )
+
+ assert rel_path == "report (2).pdf"
+ assert abs_path == workspace_root / "report (2).pdf"
+
+
+def test_build_agent_workspace_path_sanitizes_to_basename(tmp_path: Path):
+ rel_path, abs_path = build_agent_workspace_path(
+ workspace_root=tmp_path / "agents" / "17",
+ filename="../../quarterly: status?.pdf",
+ )
+
+ assert rel_path == "quarterly_ status_.pdf"
+ assert abs_path == tmp_path / "agents" / "17" / "quarterly_ status_.pdf"
+
+
+async def test_download_matrix_attachment_uses_agent_workspace_root(tmp_path: Path):
+ async def download(url: str):
+ assert url == "mxc://server/id"
+ return SimpleNamespace(body=b"%PDF-1.7")
+
+ saved = await download_matrix_attachment(
+ client=SimpleNamespace(download=download),
+ workspace_root=tmp_path / "agents" / "17",
+ matrix_user_id="@alice:example.org",
+ room_id="!room:example.org",
+ attachment=Attachment(
+ type="document",
+ url="mxc://server/id",
+ filename="report.pdf",
+ mime_type="application/pdf",
+ ),
+ timestamp="20260428-110000",
+ )
+
+ assert saved.workspace_path == "report.pdf"
+ assert (tmp_path / "agents" / "17" / saved.workspace_path).read_bytes() == b"%PDF-1.7"
diff --git a/tests/adapter/matrix/test_invite_space.py b/tests/adapter/matrix/test_invite_space.py
new file mode 100644
index 0000000..15ca57c
--- /dev/null
+++ b/tests/adapter/matrix/test_invite_space.py
@@ -0,0 +1,174 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+from nio.api import RoomVisibility
+
+from adapter.matrix.bot import build_runtime
+from adapter.matrix.handlers.auth import handle_invite
+from adapter.matrix.store import get_room_meta, get_user_meta, set_room_meta, set_user_meta
+from sdk.mock import MockPlatformClient
+
+
+def _make_client():
+ space_resp = SimpleNamespace(room_id="!space:example.org")
+ chat_resp = SimpleNamespace(room_id="!chat1:example.org")
+ return SimpleNamespace(
+ join=AsyncMock(),
+ room_create=AsyncMock(side_effect=[space_resp, chat_resp]),
+ room_put_state=AsyncMock(),
+ room_invite=AsyncMock(),
+ room_send=AsyncMock(),
+ )
+
+
+async def test_mat01_invite_creates_space_and_chat1():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 4})
+ client = _make_client()
+ room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice")
+ event = SimpleNamespace(sender="@alice:example.org", membership="invite")
+
+ await handle_invite(
+ client,
+ room,
+ event,
+ runtime.platform,
+ runtime.store,
+ runtime.auth_mgr,
+ runtime.chat_mgr,
+ )
+
+ first_call = client.room_create.call_args_list[0]
+ assert first_call.kwargs.get("space") is True
+ assert first_call.kwargs.get("visibility") is RoomVisibility.private
+ assert first_call.kwargs.get("invite") == ["@alice:example.org"]
+ second_call = client.room_create.call_args_list[1]
+ assert second_call.kwargs.get("visibility") is RoomVisibility.private
+ assert second_call.kwargs.get("invite") == ["@alice:example.org"]
+ assert client.room_create.await_count == 2
+ client.room_invite.assert_not_awaited()
+
+ client.room_put_state.assert_awaited_once()
+ kwargs = client.room_put_state.call_args.kwargs
+ assert kwargs.get("event_type") == "m.space.child"
+ assert kwargs.get("state_key") == "!chat1:example.org"
+ assert kwargs.get("room_id") == "!space:example.org"
+
+ user_meta = await get_user_meta(runtime.store, "@alice:example.org")
+ assert user_meta is not None
+ assert user_meta["space_id"] == "!space:example.org"
+
+ room_meta = await get_room_meta(runtime.store, "!chat1:example.org")
+ assert room_meta is not None
+ assert room_meta["chat_id"] == "C4"
+ assert room_meta["space_id"] == "!space:example.org"
+ assert room_meta["platform_chat_id"] == "1"
+ assert user_meta["next_chat_index"] == 5
+
+ chats = await runtime.chat_mgr.list_active("@alice:example.org")
+ assert [chat.chat_id for chat in chats] == ["C4"]
+ assert [chat.surface_ref for chat in chats] == ["!chat1:example.org"]
+
+
+async def test_mat02_invite_idempotent():
+ runtime = build_runtime(platform=MockPlatformClient())
+ client = _make_client()
+ room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice")
+ event = SimpleNamespace(sender="@alice:example.org", membership="invite")
+
+ await handle_invite(
+ client,
+ room,
+ event,
+ runtime.platform,
+ runtime.store,
+ runtime.auth_mgr,
+ runtime.chat_mgr,
+ )
+ await handle_invite(
+ client,
+ room,
+ event,
+ runtime.platform,
+ runtime.store,
+ runtime.auth_mgr,
+ runtime.chat_mgr,
+ )
+
+ assert client.room_create.await_count == 2
+
+
+async def test_existing_user_invite_reinvites_space_and_active_chats():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await set_user_meta(
+ runtime.store,
+ "@alice:example.org",
+ {"space_id": "!space:example.org", "next_chat_index": 2},
+ )
+ await set_room_meta(
+ runtime.store,
+ "!chat1:example.org",
+ {
+ "room_type": "chat",
+ "chat_id": "C1",
+ "display_name": "Чат 1",
+ "matrix_user_id": "@alice:example.org",
+ "space_id": "!space:example.org",
+ "platform_chat_id": "1",
+ "agent_id": "agent-1",
+ },
+ )
+ await runtime.chat_mgr.get_or_create(
+ user_id="@alice:example.org",
+ chat_id="C1",
+ platform="matrix",
+ surface_ref="!chat1:example.org",
+ name="Чат 1",
+ )
+ client = _make_client()
+ room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice")
+ event = SimpleNamespace(sender="@alice:example.org", membership="invite")
+
+ await handle_invite(
+ client,
+ room,
+ event,
+ runtime.platform,
+ runtime.store,
+ runtime.auth_mgr,
+ runtime.chat_mgr,
+ )
+
+ client.room_create.assert_not_awaited()
+ client.room_invite.assert_any_await("!space:example.org", "@alice:example.org")
+ client.room_invite.assert_any_await("!chat1:example.org", "@alice:example.org")
+ client.room_send.assert_awaited()
+
+
+async def test_mat03_no_hardcoded_c1():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 7})
+ client = _make_client()
+ room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice")
+ event = SimpleNamespace(sender="@alice:example.org", membership="invite")
+
+ await handle_invite(
+ client,
+ room,
+ event,
+ runtime.platform,
+ runtime.store,
+ runtime.auth_mgr,
+ runtime.chat_mgr,
+ )
+
+ room_meta = await get_room_meta(runtime.store, "!chat1:example.org")
+ assert room_meta is not None
+ assert room_meta["chat_id"] == "C7"
+ assert room_meta["platform_chat_id"] == "1"
+
+ user_meta = await get_user_meta(runtime.store, "@alice:example.org")
+ assert user_meta is not None
+ assert user_meta["next_chat_index"] == 8
diff --git a/tests/adapter/matrix/test_reactions.py b/tests/adapter/matrix/test_reactions.py
new file mode 100644
index 0000000..7974239
--- /dev/null
+++ b/tests/adapter/matrix/test_reactions.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from adapter.matrix.reactions import (
+ build_confirmation_text,
+ build_skills_text,
+)
+from sdk.interface import UserSettings
+
+
+def test_build_skills_text():
+ settings = UserSettings(
+ skills={"web-search": True, "fetch-url": False},
+ connectors={},
+ soul={},
+ safety={},
+ plan={},
+ )
+ text = build_skills_text(settings)
+ assert "web-search" in text
+ assert "fetch-url" in text
+ assert "!skill on/off" in text
+ assert "1️⃣" not in text
+ assert "2️⃣" not in text
+ assert "👍" not in text
+ assert "❌" not in text
+
+
+def test_build_confirmation_text():
+ text = build_confirmation_text("Отправить письмо?")
+ assert "Отправить письмо?" in text
+ assert "!yes" in text
+ assert "!no" in text
diff --git a/tests/adapter/matrix/test_reconciliation.py b/tests/adapter/matrix/test_reconciliation.py
new file mode 100644
index 0000000..c44ffc0
--- /dev/null
+++ b/tests/adapter/matrix/test_reconciliation.py
@@ -0,0 +1,253 @@
+from __future__ import annotations
+
+import importlib
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+from adapter.matrix.agent_registry import AgentDefinition, AgentRegistry
+from adapter.matrix.bot import MatrixBot, build_runtime
+from adapter.matrix.reconciliation import reconcile_startup_state
+from adapter.matrix.store import get_room_meta, get_user_meta, set_room_meta, set_user_meta
+from sdk.mock import MockPlatformClient
+
+
+def _room(
+ room_id: str,
+ name: str,
+ members: list[str],
+ *,
+ parents: tuple[str, ...] = (),
+):
+ return SimpleNamespace(
+ room_id=room_id,
+ name=name,
+ display_name=name,
+ users={user_id: SimpleNamespace(user_id=user_id) for user_id in members},
+ space_parents=set(parents),
+ )
+
+
+async def test_reconcile_startup_state_restores_space_room_and_chat_bindings():
+ runtime = build_runtime(platform=MockPlatformClient())
+ client = SimpleNamespace(
+ user_id="@bot:example.org",
+ rooms={
+ "!space:example.org": _room(
+ "!space:example.org",
+ "Lambda - Alice",
+ ["@bot:example.org", "@alice:example.org"],
+ ),
+ "!chat3:example.org": _room(
+ "!chat3:example.org",
+ "Чат 3",
+ ["@bot:example.org", "@alice:example.org"],
+ parents=("!space:example.org",),
+ ),
+ },
+ )
+
+ await reconcile_startup_state(client, runtime)
+
+ user_meta = await get_user_meta(runtime.store, "@alice:example.org")
+ assert user_meta is not None
+ assert user_meta["space_id"] == "!space:example.org"
+ assert user_meta["next_chat_index"] == 4
+
+ room_meta = await get_room_meta(runtime.store, "!chat3:example.org")
+ assert room_meta is not None
+ assert room_meta["room_type"] == "chat"
+ assert room_meta["chat_id"] == "C3"
+ assert room_meta["space_id"] == "!space:example.org"
+ assert room_meta["matrix_user_id"] == "@alice:example.org"
+ assert room_meta["platform_chat_id"] == "1"
+
+ chats = await runtime.chat_mgr.list_active("@alice:example.org")
+ assert [chat.chat_id for chat in chats] == ["C3"]
+ assert [chat.surface_ref for chat in chats] == ["!chat3:example.org"]
+
+
+async def test_reconcile_startup_state_is_idempotent_with_existing_local_state():
+ runtime = build_runtime(platform=MockPlatformClient())
+ client = SimpleNamespace(
+ user_id="@bot:example.org",
+ rooms={
+ "!space:example.org": _room(
+ "!space:example.org",
+ "Lambda - Alice",
+ ["@bot:example.org", "@alice:example.org"],
+ ),
+ "!chat3:example.org": _room(
+ "!chat3:example.org",
+ "Чат 3",
+ ["@bot:example.org", "@alice:example.org"],
+ parents=("!space:example.org",),
+ ),
+ },
+ )
+ await set_user_meta(
+ runtime.store,
+ "@alice:example.org",
+ {"space_id": "!space:example.org", "next_chat_index": 8},
+ )
+ await set_room_meta(
+ runtime.store,
+ "!chat3:example.org",
+ {
+ "room_type": "chat",
+ "chat_id": "C3",
+ "display_name": "Existing name",
+ "matrix_user_id": "@alice:example.org",
+ "space_id": "!space:example.org",
+ "platform_chat_id": "42",
+ },
+ )
+ await runtime.chat_mgr.get_or_create(
+ user_id="@alice:example.org",
+ chat_id="C3",
+ platform="matrix",
+ surface_ref="!chat3:example.org",
+ name="Existing name",
+ )
+
+ await reconcile_startup_state(client, runtime)
+ await reconcile_startup_state(client, runtime)
+
+ user_meta = await get_user_meta(runtime.store, "@alice:example.org")
+ assert user_meta == {"space_id": "!space:example.org", "next_chat_index": 8}
+
+ room_meta = await get_room_meta(runtime.store, "!chat3:example.org")
+ assert room_meta is not None
+ assert room_meta["display_name"] == "Existing name"
+ assert room_meta["platform_chat_id"] == "42"
+
+ chats = await runtime.chat_mgr.list_active("@alice:example.org")
+ assert len(chats) == 1
+ assert chats[0].chat_id == "C3"
+
+
+async def test_reconcile_updates_default_agent_assignment_after_user_is_configured():
+ runtime = build_runtime(platform=MockPlatformClient())
+ runtime.registry = AgentRegistry(
+ [
+ AgentDefinition("agent-default", "Default"),
+ AgentDefinition("agent-alice", "Alice"),
+ ],
+ user_agents={"@alice:example.org": "agent-alice"},
+ )
+ client = SimpleNamespace(
+ user_id="@bot:example.org",
+ rooms={
+ "!space:example.org": _room(
+ "!space:example.org",
+ "Lambda - Alice",
+ ["@bot:example.org", "@alice:example.org"],
+ ),
+ "!chat3:example.org": _room(
+ "!chat3:example.org",
+ "Чат 3",
+ ["@bot:example.org", "@alice:example.org"],
+ parents=("!space:example.org",),
+ ),
+ },
+ )
+ await set_room_meta(
+ runtime.store,
+ "!chat3:example.org",
+ {
+ "room_type": "chat",
+ "chat_id": "C3",
+ "display_name": "Чат 3",
+ "matrix_user_id": "@alice:example.org",
+ "space_id": "!space:example.org",
+ "platform_chat_id": "42",
+ "agent_id": "agent-default",
+ "agent_assignment": "default",
+ },
+ )
+
+ await reconcile_startup_state(client, runtime)
+
+ room_meta = await get_room_meta(runtime.store, "!chat3:example.org")
+ assert room_meta is not None
+ assert room_meta["agent_id"] == "agent-alice"
+ assert room_meta["agent_assignment"] == "configured"
+ assert room_meta["platform_chat_id"] == "42"
+
+
+async def test_reconciliation_prevents_lazy_bootstrap_for_existing_room():
+ runtime = build_runtime(platform=MockPlatformClient())
+ client = SimpleNamespace(
+ user_id="@bot:example.org",
+ rooms={
+ "!space:example.org": _room(
+ "!space:example.org",
+ "Lambda - Alice",
+ ["@bot:example.org", "@alice:example.org"],
+ ),
+ "!chat3:example.org": _room(
+ "!chat3:example.org",
+ "Чат 3",
+ ["@bot:example.org", "@alice:example.org"],
+ parents=("!space:example.org",),
+ ),
+ },
+ room_send=AsyncMock(),
+ )
+ bot = MatrixBot(client=client, runtime=runtime)
+ bot._bootstrap_unregistered_room = AsyncMock()
+ runtime.dispatcher.dispatch = AsyncMock(return_value=[])
+
+ await reconcile_startup_state(client, runtime)
+ await bot.on_room_message(
+ SimpleNamespace(room_id="!chat3:example.org"),
+ SimpleNamespace(sender="@alice:example.org", body="hello"),
+ )
+
+ bot._bootstrap_unregistered_room.assert_not_awaited()
+ runtime.dispatcher.dispatch.assert_awaited_once()
+
+
+async def test_matrix_main_runs_reconciliation_before_sync_forever(monkeypatch):
+ bot_module = importlib.import_module("adapter.matrix.bot")
+
+ runtime = SimpleNamespace(platform=SimpleNamespace(close=AsyncMock()))
+ call_order: list[str] = []
+
+ class FakeAsyncClient:
+ def __init__(self, *args, **kwargs):
+ self.access_token = None
+ self.callbacks = []
+ self.close = AsyncMock()
+ self.sync_forever = AsyncMock(side_effect=self._sync_forever)
+
+ async def _sync_forever(self, *args, **kwargs):
+ call_order.append("sync_forever")
+
+ async def login(self, *args, **kwargs):
+ raise AssertionError("login should not be called when access token is provided")
+
+ def add_event_callback(self, callback, event_type):
+ self.callbacks.append((callback, event_type))
+
+ async def fake_prepare_live_sync(client):
+ call_order.append("prepare_live_sync")
+ return "s123"
+
+ async def fake_reconcile_startup_state(client, runtime):
+ call_order.append("reconcile_startup_state")
+
+ monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
+ monkeypatch.setenv("MATRIX_USER_ID", "@bot:example.org")
+ monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "token")
+ monkeypatch.setattr(bot_module, "AsyncClient", FakeAsyncClient)
+ monkeypatch.setattr(bot_module, "build_runtime", lambda **kwargs: runtime)
+ monkeypatch.setattr(bot_module, "prepare_live_sync", fake_prepare_live_sync)
+ monkeypatch.setattr(bot_module, "reconcile_startup_state", fake_reconcile_startup_state)
+
+ await bot_module.main()
+
+ assert call_order == [
+ "prepare_live_sync",
+ "reconcile_startup_state",
+ "sync_forever",
+ ]
diff --git a/tests/adapter/matrix/test_restart_persistence.py b/tests/adapter/matrix/test_restart_persistence.py
new file mode 100644
index 0000000..ac05423
--- /dev/null
+++ b/tests/adapter/matrix/test_restart_persistence.py
@@ -0,0 +1,114 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+from adapter.matrix.bot import build_runtime
+from adapter.matrix.reconciliation import reconcile_startup_state
+from adapter.matrix.store import (
+ get_room_meta,
+ next_platform_chat_id,
+ set_room_meta,
+)
+from core.store import SQLiteStore
+from sdk.mock import MockPlatformClient
+
+
+async def test_room_agent_id_and_platform_chat_id_survive_restart(tmp_path):
+ db = str(tmp_path / "state.db")
+ store = SQLiteStore(db)
+ await set_room_meta(store, "!room:example.org", {
+ "room_type": "chat",
+ "agent_id": "agent-1",
+ "platform_chat_id": "42",
+ })
+
+ store2 = SQLiteStore(db)
+ meta = await get_room_meta(store2, "!room:example.org")
+ assert meta is not None
+ assert meta["agent_id"] == "agent-1"
+ assert meta["platform_chat_id"] == "42"
+
+
+async def test_platform_chat_seq_survives_restart(tmp_path):
+ db = str(tmp_path / "state.db")
+ store = SQLiteStore(db)
+ assert await next_platform_chat_id(store) == "1"
+ assert await next_platform_chat_id(store) == "2"
+ assert await next_platform_chat_id(store) == "3"
+
+ store2 = SQLiteStore(db)
+ assert await next_platform_chat_id(store2) == "4"
+
+
+async def test_routing_state_survives_restart_and_routes_correctly(tmp_path):
+ db = str(tmp_path / "state.db")
+ store = SQLiteStore(db)
+ await set_room_meta(store, "!convo:example.org", {
+ "room_type": "chat",
+ "agent_id": "agent-1",
+ "platform_chat_id": "10",
+ })
+
+ store2 = SQLiteStore(db)
+ meta = await get_room_meta(store2, "!convo:example.org")
+ assert meta is not None
+ assert meta["agent_id"] == "agent-1"
+ assert meta["platform_chat_id"] == "10"
+
+
+async def test_missing_durable_store_starts_clean(tmp_path):
+ db = str(tmp_path / "brand_new.db")
+ store = SQLiteStore(db)
+ assert await get_room_meta(store, "!nonexistent:example.org") is None
+
+
+async def test_startup_reconciliation_backfills_legacy_platform_chat_id_before_restart_routes(
+ tmp_path,
+):
+ db = str(tmp_path / "state.db")
+ store = SQLiteStore(db)
+ await set_room_meta(
+ store,
+ "!chat2:example.org",
+ {
+ "room_type": "chat",
+ "chat_id": "C2",
+ "display_name": "Чат 2",
+ "matrix_user_id": "@alice:example.org",
+ "space_id": "!space:example.org",
+ },
+ )
+
+ runtime = build_runtime(platform=MockPlatformClient(), store=store)
+ client = SimpleNamespace(
+ user_id="@bot:example.org",
+ rooms={
+ "!space:example.org": SimpleNamespace(
+ room_id="!space:example.org",
+ name="Lambda - Alice",
+ display_name="Lambda - Alice",
+ users={
+ "@bot:example.org": SimpleNamespace(user_id="@bot:example.org"),
+ "@alice:example.org": SimpleNamespace(user_id="@alice:example.org"),
+ },
+ space_parents=set(),
+ ),
+ "!chat2:example.org": SimpleNamespace(
+ room_id="!chat2:example.org",
+ name="Чат 2",
+ display_name="Чат 2",
+ users={
+ "@bot:example.org": SimpleNamespace(user_id="@bot:example.org"),
+ "@alice:example.org": SimpleNamespace(user_id="@alice:example.org"),
+ },
+ space_parents={"!space:example.org"},
+ ),
+ },
+ )
+
+ await reconcile_startup_state(client, runtime)
+
+ store2 = SQLiteStore(db)
+ room_meta = await get_room_meta(store2, "!chat2:example.org")
+ assert room_meta is not None
+ assert room_meta["platform_chat_id"] == "1"
diff --git a/tests/adapter/matrix/test_routed_platform.py b/tests/adapter/matrix/test_routed_platform.py
new file mode 100644
index 0000000..c3efca5
--- /dev/null
+++ b/tests/adapter/matrix/test_routed_platform.py
@@ -0,0 +1,342 @@
+from __future__ import annotations
+
+from collections.abc import AsyncIterator
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+from adapter.matrix.bot import MatrixBot, build_runtime
+from adapter.matrix.routed_platform import RoutedPlatformClient
+from adapter.matrix.store import set_room_meta
+from core.chat import ChatManager
+from core.store import InMemoryStore
+from sdk.interface import MessageChunk, MessageResponse, User, UserSettings
+from sdk.mock import MockPlatformClient
+from sdk.interface import PlatformError
+
+
+class FakeDelegate:
+ def __init__(self, *, name: str) -> None:
+ self.name = name
+ self.send_calls: list[dict] = []
+ self.stream_calls: list[dict] = []
+ self.user_calls: list[dict] = []
+ self.settings_calls: list[str] = []
+ self.update_calls: list[tuple[str, object]] = []
+
+ async def get_or_create_user(
+ self,
+ external_id: str,
+ platform: str,
+ display_name: str | None = None,
+ ) -> User:
+ self.user_calls.append(
+ {
+ "external_id": external_id,
+ "platform": platform,
+ "display_name": display_name,
+ }
+ )
+ return User(
+ user_id=f"user-{self.name}",
+ external_id=external_id,
+ platform=platform,
+ display_name=display_name,
+ created_at="2025-01-01T00:00:00Z",
+ is_new=False,
+ )
+
+ async def send_message(
+ self,
+ user_id: str,
+ chat_id: str,
+ text: str,
+ attachments=None,
+ ) -> MessageResponse:
+ self.send_calls.append(
+ {
+ "user_id": user_id,
+ "chat_id": chat_id,
+ "text": text,
+ "attachments": attachments,
+ }
+ )
+ return MessageResponse(
+ message_id=f"msg-{self.name}",
+ response=f"reply-{self.name}",
+ tokens_used=0,
+ finished=True,
+ )
+
+ async def stream_message(
+ self,
+ user_id: str,
+ chat_id: str,
+ text: str,
+ attachments=None,
+ ) -> AsyncIterator[MessageChunk]:
+ self.stream_calls.append(
+ {
+ "user_id": user_id,
+ "chat_id": chat_id,
+ "text": text,
+ "attachments": attachments,
+ }
+ )
+ yield MessageChunk(
+ message_id=f"stream-{self.name}",
+ delta=f"delta-{self.name}",
+ finished=True,
+ tokens_used=0,
+ )
+
+ async def get_settings(self, user_id: str) -> UserSettings:
+ self.settings_calls.append(user_id)
+ return UserSettings(skills={"files": True})
+
+ async def update_settings(self, user_id: str, action: object) -> None:
+ self.update_calls.append((user_id, action))
+
+
+@pytest.fixture(autouse=True)
+def clear_matrix_registry_env(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.delenv("MATRIX_AGENT_REGISTRY_PATH", raising=False)
+ monkeypatch.delenv("MATRIX_PLATFORM_BACKEND", raising=False)
+
+
+@pytest.mark.asyncio
+async def test_send_message_routes_by_room_agent_and_platform_chat_id():
+ store = InMemoryStore()
+ chat_mgr = ChatManager(None, store)
+ await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org")
+ await set_room_meta(
+ store,
+ "!room:example.org",
+ {"platform_chat_id": "41", "agent_id": "agent-2"},
+ )
+ delegates = {
+ "agent-1": FakeDelegate(name="agent-1"),
+ "agent-2": FakeDelegate(name="agent-2"),
+ }
+ platform = RoutedPlatformClient(chat_mgr=chat_mgr, store=store, delegates=delegates)
+
+ response = await platform.send_message("u1", "C1", "hello", attachments=[])
+
+ assert response.response == "reply-agent-2"
+ assert delegates["agent-1"].send_calls == []
+ assert delegates["agent-2"].send_calls == [
+ {
+ "user_id": "u1",
+ "chat_id": "41",
+ "text": "hello",
+ "attachments": [],
+ }
+ ]
+
+
+@pytest.mark.asyncio
+async def test_stream_message_routes_by_room_agent_and_platform_chat_id():
+ store = InMemoryStore()
+ chat_mgr = ChatManager(None, store)
+ await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org")
+ await set_room_meta(
+ store,
+ "!room:example.org",
+ {"platform_chat_id": "41", "agent_id": "agent-2"},
+ )
+ delegates = {
+ "agent-1": FakeDelegate(name="agent-1"),
+ "agent-2": FakeDelegate(name="agent-2"),
+ }
+ platform = RoutedPlatformClient(chat_mgr=chat_mgr, store=store, delegates=delegates)
+
+ chunks = [chunk async for chunk in platform.stream_message("u1", "C1", "hello")]
+
+ assert [chunk.delta for chunk in chunks] == ["delta-agent-2"]
+ assert delegates["agent-1"].stream_calls == []
+ assert delegates["agent-2"].stream_calls == [
+ {
+ "user_id": "u1",
+ "chat_id": "41",
+ "text": "hello",
+ "attachments": None,
+ }
+ ]
+
+
+@pytest.mark.asyncio
+async def test_send_message_fails_fast_when_platform_chat_id_is_missing():
+ store = InMemoryStore()
+ chat_mgr = ChatManager(None, store)
+ await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org")
+ await set_room_meta(
+ store,
+ "!room:example.org",
+ {"agent_id": "agent-2"},
+ )
+ platform = RoutedPlatformClient(
+ chat_mgr=chat_mgr,
+ store=store,
+ delegates={"agent-2": FakeDelegate(name="agent-2")},
+ )
+
+ with pytest.raises(PlatformError, match="routing is incomplete") as exc_info:
+ await platform.send_message("u1", "C1", "hello")
+
+ assert exc_info.value.code == "MATRIX_ROUTE_INCOMPLETE"
+
+
+@pytest.mark.asyncio
+async def test_stream_message_fails_fast_when_agent_id_is_missing():
+ store = InMemoryStore()
+ chat_mgr = ChatManager(None, store)
+ await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org")
+ await set_room_meta(
+ store,
+ "!room:example.org",
+ {"platform_chat_id": "41"},
+ )
+ platform = RoutedPlatformClient(
+ chat_mgr=chat_mgr,
+ store=store,
+ delegates={"agent-2": FakeDelegate(name="agent-2")},
+ )
+
+ with pytest.raises(PlatformError, match="routing is incomplete") as exc_info:
+ await anext(platform.stream_message("u1", "C1", "hello"))
+
+ assert exc_info.value.code == "MATRIX_ROUTE_INCOMPLETE"
+
+
+@pytest.mark.asyncio
+async def test_routing_uses_repaired_room_metadata_without_runtime_backfill():
+ store = InMemoryStore()
+ chat_mgr = ChatManager(None, store)
+ await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org")
+ await set_room_meta(
+ store,
+ "!room:example.org",
+ {"platform_chat_id": "restored-41", "agent_id": "agent-2"},
+ )
+ delegate = FakeDelegate(name="agent-2")
+ platform = RoutedPlatformClient(
+ chat_mgr=chat_mgr,
+ store=store,
+ delegates={"agent-2": delegate},
+ )
+
+ await platform.send_message("u1", "C1", "hello")
+
+ assert delegate.send_calls == [
+ {
+ "user_id": "u1",
+ "chat_id": "restored-41",
+ "text": "hello",
+ "attachments": None,
+ }
+ ]
+
+
+@pytest.mark.asyncio
+async def test_user_and_settings_delegate_to_default_client():
+ store = InMemoryStore()
+ chat_mgr = ChatManager(None, store)
+ delegates = {
+ "agent-1": FakeDelegate(name="agent-1"),
+ "agent-2": FakeDelegate(name="agent-2"),
+ }
+ platform = RoutedPlatformClient(chat_mgr=chat_mgr, store=store, delegates=delegates)
+
+ user = await platform.get_or_create_user("ext-1", "matrix", display_name="Alice")
+ settings = await platform.get_settings("u1")
+ await platform.update_settings("u1", {"action": "noop"})
+
+ assert user.user_id == "user-agent-1"
+ assert settings.skills == {"files": True}
+ assert delegates["agent-1"].user_calls == [
+ {
+ "external_id": "ext-1",
+ "platform": "matrix",
+ "display_name": "Alice",
+ }
+ ]
+ assert delegates["agent-2"].user_calls == []
+ assert delegates["agent-1"].settings_calls == ["u1"]
+ assert delegates["agent-1"].update_calls == [("u1", {"action": "noop"})]
+
+
+@pytest.mark.asyncio
+async def test_build_runtime_real_backend_uses_routed_platform_with_registry(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+):
+ registry_path = tmp_path / "matrix-agents.yaml"
+ registry_path.write_text(
+ "agents:\n"
+ " - id: agent-1\n"
+ " label: Analyst\n"
+ " - id: agent-2\n"
+ " label: Research\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real")
+ monkeypatch.setenv("MATRIX_AGENT_REGISTRY_PATH", str(registry_path))
+ monkeypatch.setenv("AGENT_BASE_URL", "http://agent.example")
+
+ runtime = build_runtime()
+
+ assert isinstance(runtime.platform, RoutedPlatformClient)
+ assert set(runtime.platform._delegates) == {"agent-1", "agent-2"}
+ assert runtime.platform._delegates["agent-1"].agent_base_url == "http://agent.example"
+ assert runtime.platform._delegates["agent-1"].agent_id == "agent-1"
+ assert runtime.platform._delegates["agent-2"].agent_id == "agent-2"
+
+
+def test_build_runtime_real_backend_requires_registry_path(monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real")
+ monkeypatch.delenv("MATRIX_AGENT_REGISTRY_PATH", raising=False)
+ monkeypatch.setenv("AGENT_BASE_URL", "http://agent.example")
+
+ with pytest.raises(RuntimeError, match="MATRIX_AGENT_REGISTRY_PATH"):
+ build_runtime()
+
+
+def test_build_runtime_real_backend_fails_explicitly_on_invalid_registry(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+):
+ registry_path = tmp_path / "missing.yaml"
+ monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real")
+ monkeypatch.setenv("MATRIX_AGENT_REGISTRY_PATH", str(registry_path))
+ monkeypatch.setenv("AGENT_BASE_URL", "http://agent.example")
+
+ with pytest.raises(RuntimeError, match="failed to load matrix agent registry"):
+ build_runtime()
+
+
+@pytest.mark.asyncio
+async def test_bot_keeps_local_chat_id_for_plain_message_dispatch():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await set_room_meta(
+ runtime.store,
+ "!chat1:example.org",
+ {
+ "chat_id": "C1",
+ "matrix_user_id": "@alice:example.org",
+ "platform_chat_id": "41",
+ "agent_id": "agent-2",
+ },
+ )
+ runtime.dispatcher.dispatch = AsyncMock(return_value=[])
+ bot = MatrixBot(SimpleNamespace(user_id="@bot:example.org"), runtime)
+
+ await bot.on_room_message(
+ SimpleNamespace(room_id="!chat1:example.org"),
+ SimpleNamespace(sender="@alice:example.org", body="hello"),
+ )
+
+ dispatched = runtime.dispatcher.dispatch.await_args.args[0]
+ assert dispatched.chat_id == "C1"
+ assert dispatched.text == "hello"
diff --git a/tests/adapter/matrix/test_send_outgoing.py b/tests/adapter/matrix/test_send_outgoing.py
new file mode 100644
index 0000000..72b9fa6
--- /dev/null
+++ b/tests/adapter/matrix/test_send_outgoing.py
@@ -0,0 +1,194 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+from adapter.matrix.bot import send_outgoing
+from adapter.matrix.converter import from_room_event
+from adapter.matrix.handlers.confirm import make_handle_cancel, make_handle_confirm
+from adapter.matrix.store import get_pending_confirm, set_room_meta
+from core.auth import AuthManager
+from core.chat import ChatManager
+from core.protocol import Attachment, OutgoingMessage, OutgoingUI, UIButton
+from core.settings import SettingsManager
+from core.store import InMemoryStore
+from sdk.mock import MockPlatformClient
+
+
+async def test_mat06_outgoing_ui_renders_text_with_yes_no():
+ client = SimpleNamespace(room_send=AsyncMock())
+ store = InMemoryStore()
+ await set_room_meta(store, "!confirm:example.org", {"matrix_user_id": "@alice:example.org"})
+ event = OutgoingUI(
+ chat_id="C7",
+ text="Удалить файл?",
+ buttons=[UIButton(label="Подтвердить", action="confirm")],
+ )
+
+ await send_outgoing(client, "!confirm:example.org", event, store=store)
+
+ client.room_send.assert_awaited_once()
+ body = client.room_send.call_args.args[2]["body"]
+ assert "Удалить файл?" in body
+ assert "!yes" in body
+ assert "!no" in body
+ assert "Подтвердить" in body
+
+
+async def test_mat07_outgoing_ui_no_reaction_sent():
+ client = SimpleNamespace(room_send=AsyncMock())
+ store = InMemoryStore()
+ await set_room_meta(store, "!confirm:example.org", {"matrix_user_id": "@alice:example.org"})
+ event = OutgoingUI(
+ chat_id="C7",
+ text="Confirm action?",
+ buttons=[UIButton(label="OK", action="confirm", payload={"id": 1})],
+ )
+
+ await send_outgoing(client, "!confirm:example.org", event, store=store)
+
+ assert client.room_send.await_count == 1
+ assert client.room_send.call_args.args[1] == "m.room.message"
+ for call in client.room_send.call_args_list:
+ assert call.args[1] != "m.reaction"
+
+ pending = await get_pending_confirm(store, "@alice:example.org", "!confirm:example.org")
+ assert pending == {
+ "action_id": "confirm",
+ "description": "Confirm action?",
+ "payload": {"id": 1},
+ }
+
+
+async def test_outgoing_ui_yes_round_trip_uses_user_and_room_scope():
+ client = SimpleNamespace(room_send=AsyncMock())
+ store = InMemoryStore()
+ platform = MockPlatformClient()
+ chat_mgr = ChatManager(platform, store)
+ auth_mgr = AuthManager(platform, store)
+ settings_mgr = SettingsManager(platform, store)
+ await set_room_meta(store, "!confirm:example.org", {"matrix_user_id": "@alice:example.org"})
+ await set_room_meta(store, "!other:example.org", {"matrix_user_id": "@bob:example.org"})
+
+ await send_outgoing(
+ client,
+ "!confirm:example.org",
+ OutgoingUI(
+ chat_id="C7",
+ text="Archive room",
+ buttons=[UIButton(label="Confirm", action="archive", payload={"id": 7})],
+ ),
+ store=store,
+ )
+ await send_outgoing(
+ client,
+ "!other:example.org",
+ OutgoingUI(
+ chat_id="C8",
+ text="Keep other room",
+ buttons=[UIButton(label="Confirm", action="archive", payload={"id": 8})],
+ ),
+ store=store,
+ )
+
+ callback = from_room_event(
+ SimpleNamespace(
+ sender="@alice:example.org",
+ body="!yes",
+ event_id="$yes",
+ msgtype="m.text",
+ replyto_event_id=None,
+ ),
+ room_id="!confirm:example.org",
+ chat_id="C7",
+ )
+ result = await make_handle_confirm(store)(callback, auth_mgr, platform, chat_mgr, settings_mgr)
+
+ assert "Archive room" in result[0].text
+ assert await get_pending_confirm(store, "@alice:example.org", "!confirm:example.org") is None
+ assert await get_pending_confirm(store, "@bob:example.org", "!other:example.org") is not None
+
+
+async def test_outgoing_ui_no_round_trip_uses_user_and_room_scope():
+ client = SimpleNamespace(room_send=AsyncMock())
+ store = InMemoryStore()
+ platform = MockPlatformClient()
+ chat_mgr = ChatManager(platform, store)
+ auth_mgr = AuthManager(platform, store)
+ settings_mgr = SettingsManager(platform, store)
+ await set_room_meta(store, "!confirm:example.org", {"matrix_user_id": "@alice:example.org"})
+ await set_room_meta(store, "!other:example.org", {"matrix_user_id": "@bob:example.org"})
+
+ await send_outgoing(
+ client,
+ "!confirm:example.org",
+ OutgoingUI(
+ chat_id="C7",
+ text="Delete room",
+ buttons=[UIButton(label="Confirm", action="delete", payload={"id": 7})],
+ ),
+ store=store,
+ )
+ await send_outgoing(
+ client,
+ "!other:example.org",
+ OutgoingUI(
+ chat_id="C8",
+ text="Keep other room",
+ buttons=[UIButton(label="Confirm", action="archive", payload={"id": 8})],
+ ),
+ store=store,
+ )
+
+ callback = from_room_event(
+ SimpleNamespace(
+ sender="@alice:example.org",
+ body="!no",
+ event_id="$no",
+ msgtype="m.text",
+ replyto_event_id=None,
+ ),
+ room_id="!confirm:example.org",
+ chat_id="C7",
+ )
+ result = await make_handle_cancel(store)(callback, auth_mgr, platform, chat_mgr, settings_mgr)
+
+ assert "отменено" in result[0].text.lower()
+ assert await get_pending_confirm(store, "@alice:example.org", "!confirm:example.org") is None
+ assert await get_pending_confirm(store, "@bob:example.org", "!other:example.org") is not None
+
+
+async def test_send_outgoing_uploads_workspace_file_attachment(tmp_path, monkeypatch):
+ workspace_file = tmp_path / "surfaces" / "matrix" / "alice" / "room" / "inbox" / "result.txt"
+ workspace_file.parent.mkdir(parents=True, exist_ok=True)
+ workspace_file.write_text("ready")
+ monkeypatch.setenv("SURFACES_WORKSPACE_DIR", str(tmp_path))
+
+ client = SimpleNamespace(
+ upload=AsyncMock(return_value=(SimpleNamespace(content_uri="mxc://server/file"), {})),
+ room_send=AsyncMock(),
+ )
+
+ await send_outgoing(
+ client,
+ "!room:example.org",
+ OutgoingMessage(
+ chat_id="!room:example.org",
+ text="Файл готов",
+ attachments=[
+ Attachment(
+ type="document",
+ filename="result.txt",
+ mime_type="text/plain",
+ workspace_path="surfaces/matrix/alice/room/inbox/result.txt",
+ )
+ ],
+ ),
+ )
+
+ client.upload.assert_awaited_once()
+ client.room_send.assert_awaited()
+ assert client.room_send.await_args_list[0].args[2]["body"] == "Файл готов"
+ file_call = client.room_send.await_args_list[1]
+ assert file_call.args[2]["msgtype"] == "m.file"
+ assert file_call.args[2]["url"] == "mxc://server/file"
diff --git a/tests/adapter/matrix/test_store.py b/tests/adapter/matrix/test_store.py
new file mode 100644
index 0000000..7c4a216
--- /dev/null
+++ b/tests/adapter/matrix/test_store.py
@@ -0,0 +1,246 @@
+from __future__ import annotations
+
+import pytest
+
+from adapter.matrix.store import (
+ STAGED_ATTACHMENTS_PREFIX,
+ add_staged_attachment,
+ clear_pending_confirm,
+ clear_staged_attachments,
+ get_pending_confirm,
+ get_platform_chat_id,
+ get_room_meta,
+ get_room_state,
+ get_skills_message_id,
+ get_staged_attachments,
+ get_user_meta,
+ next_chat_id,
+ next_platform_chat_id,
+ remove_staged_attachment_at,
+ set_pending_confirm,
+ set_platform_chat_id,
+ set_room_meta,
+ set_room_state,
+ set_skills_message_id,
+ set_user_meta,
+)
+from core.store import InMemoryStore
+
+
+@pytest.fixture
+def store() -> InMemoryStore:
+ return InMemoryStore()
+
+
+async def test_room_meta_roundtrip(store: InMemoryStore):
+ meta = {
+ "room_type": "chat",
+ "chat_id": "C1",
+ "display_name": "Чат 1",
+ "matrix_user_id": "@alice:m.org",
+ }
+ await set_room_meta(store, "!r:m.org", meta)
+ assert await get_room_meta(store, "!r:m.org") == meta
+
+
+async def test_room_meta_roundtrip_with_platform_chat_id(store: InMemoryStore):
+ meta = {
+ "chat_id": "C1",
+ "matrix_user_id": "@alice:example.org",
+ "platform_chat_id": "chat-platform-1",
+ }
+ await set_room_meta(store, "!r:m.org", meta)
+ saved = await get_room_meta(store, "!r:m.org")
+ assert saved is not None
+ assert saved["platform_chat_id"] == "chat-platform-1"
+
+
+async def test_platform_chat_id_helpers_roundtrip(store: InMemoryStore):
+ meta = {
+ "chat_id": "C1",
+ "matrix_user_id": "@alice:example.org",
+ "display_name": "Research",
+ }
+ await set_room_meta(store, "!r:m.org", meta)
+ await set_platform_chat_id(store, "!r:m.org", "chat-platform-1")
+
+ assert await get_platform_chat_id(store, "!r:m.org") == "chat-platform-1"
+ assert await get_room_meta(store, "!r:m.org") == {
+ "chat_id": "C1",
+ "matrix_user_id": "@alice:example.org",
+ "display_name": "Research",
+ "platform_chat_id": "chat-platform-1",
+ }
+
+
+async def test_room_meta_missing(store: InMemoryStore):
+ assert await get_room_meta(store, "!nonexistent:m.org") is None
+
+
+async def test_user_meta_roundtrip(store: InMemoryStore):
+ meta = {
+ "platform_user_id": "usr-1",
+ "display_name": "Alice",
+ "space_id": None,
+ "settings_room_id": None,
+ "next_chat_index": 1,
+ }
+ await set_user_meta(store, "@alice:m.org", meta)
+ assert await get_user_meta(store, "@alice:m.org") == meta
+
+
+async def test_room_state_roundtrip(store: InMemoryStore):
+ await set_room_state(store, "!r:m.org", "idle")
+ assert await get_room_state(store, "!r:m.org") == "idle"
+ await set_room_state(store, "!r:m.org", "waiting_response")
+ assert await get_room_state(store, "!r:m.org") == "waiting_response"
+
+
+async def test_room_state_default_idle(store: InMemoryStore):
+ assert await get_room_state(store, "!unknown:m.org") == "idle"
+
+
+async def test_next_chat_id_increments(store: InMemoryStore):
+ uid = "@alice:m.org"
+ await set_user_meta(store, uid, {"next_chat_index": 1})
+ assert await next_chat_id(store, uid) == "C1"
+ assert await next_chat_id(store, uid) == "C2"
+ assert await next_chat_id(store, uid) == "C3"
+
+
+async def test_next_platform_chat_id_increments(store: InMemoryStore):
+ assert await next_platform_chat_id(store) == "1"
+ assert await next_platform_chat_id(store) == "2"
+ assert await next_platform_chat_id(store) == "3"
+
+
+async def test_skills_message_roundtrip(store: InMemoryStore):
+ await set_skills_message_id(store, "!room", "$event")
+ assert await get_skills_message_id(store, "!room") == "$event"
+
+
+async def test_pending_confirm_roundtrip(store: InMemoryStore):
+ assert await get_pending_confirm(store, "!room:m.org") is None
+
+ meta = {"action_id": "test", "description": "Do thing"}
+ await set_pending_confirm(store, "!room:m.org", meta)
+ assert await get_pending_confirm(store, "!room:m.org") == meta
+
+ await clear_pending_confirm(store, "!room:m.org")
+ assert await get_pending_confirm(store, "!room:m.org") is None
+
+
+async def test_staged_attachments_roundtrip(store: InMemoryStore):
+ room_id = "!room:m.org"
+ user_id = "@alice:m.org"
+
+ assert await get_staged_attachments(store, room_id, user_id) == []
+
+ first = {"id": "att-1", "name": "screenshot.png"}
+ second = {"id": "att-2", "name": "invoice.pdf"}
+
+ await add_staged_attachment(store, room_id, user_id, first)
+ await add_staged_attachment(store, room_id, user_id, second)
+
+ assert await get_staged_attachments(store, room_id, user_id) == [
+ first,
+ second,
+ ]
+
+
+@pytest.mark.parametrize(
+ "stored_value",
+ [
+ None,
+ "not-a-dict",
+ [],
+ 123,
+ ],
+)
+async def test_staged_attachments_invalid_container_state_returns_empty_list(
+ store: InMemoryStore,
+ stored_value,
+):
+ room_id = "!room:m.org"
+ user_id = "@alice:m.org"
+
+ await store.set(f"{STAGED_ATTACHMENTS_PREFIX}{room_id}:{user_id}", stored_value)
+
+ assert await get_staged_attachments(store, room_id, user_id) == []
+
+
+async def test_staged_attachments_filters_invalid_entries(store: InMemoryStore):
+ room_id = "!room:m.org"
+ user_id = "@alice:m.org"
+ valid_one = {"id": "att-1", "name": "alpha.png"}
+ valid_two = {"id": "att-2", "name": "beta.pdf"}
+
+ await store.set(
+ f"{STAGED_ATTACHMENTS_PREFIX}{room_id}:{user_id}",
+ {
+ "attachments": [
+ valid_one,
+ "bad-entry",
+ None,
+ {"id": "ignored"},
+ valid_two,
+ ]
+ },
+ )
+
+ assert await get_staged_attachments(store, room_id, user_id) == [
+ valid_one,
+ {"id": "ignored"},
+ valid_two,
+ ]
+
+
+async def test_staged_attachments_are_scoped_by_room_and_user(store: InMemoryStore):
+ room_a = "!room-a:m.org"
+ room_b = "!room-b:m.org"
+ user_a = "@alice:m.org"
+ user_b = "@bob:m.org"
+
+ attachment_a = {"id": "att-a", "name": "alpha.png"}
+ attachment_b = {"id": "att-b", "name": "beta.png"}
+ attachment_c = {"id": "att-c", "name": "gamma.png"}
+
+ await add_staged_attachment(store, room_a, user_a, attachment_a)
+ await add_staged_attachment(store, room_a, user_b, attachment_b)
+ await add_staged_attachment(store, room_b, user_a, attachment_c)
+
+ assert await get_staged_attachments(store, room_a, user_a) == [attachment_a]
+ assert await get_staged_attachments(store, room_a, user_b) == [attachment_b]
+ assert await get_staged_attachments(store, room_b, user_a) == [attachment_c]
+ assert await get_staged_attachments(store, room_b, user_b) == []
+
+
+async def test_remove_staged_attachment_at_by_zero_based_index(
+ store: InMemoryStore,
+):
+ room_id = "!room:m.org"
+ user_id = "@alice:m.org"
+ first = {"id": "att-1", "name": "first.png"}
+ second = {"id": "att-2", "name": "second.png"}
+ third = {"id": "att-3", "name": "third.png"}
+
+ await add_staged_attachment(store, room_id, user_id, first)
+ await add_staged_attachment(store, room_id, user_id, second)
+ await add_staged_attachment(store, room_id, user_id, third)
+
+ assert await remove_staged_attachment_at(store, room_id, user_id, 1) == second
+ assert await get_staged_attachments(store, room_id, user_id) == [first, third]
+ assert await remove_staged_attachment_at(store, room_id, user_id, 99) is None
+ assert await remove_staged_attachment_at(store, room_id, user_id, -1) is None
+
+
+async def test_clear_staged_attachments(store: InMemoryStore):
+ room_id = "!room:m.org"
+ user_id = "@alice:m.org"
+
+ await add_staged_attachment(store, room_id, user_id, {"id": "att-1"})
+ await add_staged_attachment(store, room_id, user_id, {"id": "att-2"})
+
+ await clear_staged_attachments(store, room_id, user_id)
+
+ assert await get_staged_attachments(store, room_id, user_id) == []
diff --git a/tests/adapter/telegram/__init__.py b/tests/adapter/telegram/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/adapter/telegram/test_commands.py b/tests/adapter/telegram/test_commands.py
new file mode 100644
index 0000000..a9b6676
--- /dev/null
+++ b/tests/adapter/telegram/test_commands.py
@@ -0,0 +1,120 @@
+from __future__ import annotations
+
+import importlib
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from aiogram.exceptions import TelegramBadRequest
+
+
+@pytest.fixture(autouse=True)
+def fresh_db(tmp_path, monkeypatch):
+ monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db"))
+ import adapter.telegram.db as db_mod
+ importlib.reload(db_mod)
+ db_mod.init_db()
+ return db_mod
+
+
+def make_message(*, user_id=1, thread_id=42, chat_id=100, text="/new"):
+ m = SimpleNamespace()
+ m.from_user = SimpleNamespace(id=user_id, full_name="Alice")
+ m.message_thread_id = thread_id
+ m.chat = SimpleNamespace(id=chat_id)
+ m.text = text
+ m.answer = AsyncMock()
+ m.reply = AsyncMock()
+ m.bot = MagicMock()
+ m.bot.create_forum_topic = AsyncMock(
+ return_value=SimpleNamespace(message_thread_id=200)
+ )
+ m.bot.close_forum_topic = AsyncMock()
+ m.bot.delete_forum_topic = AsyncMock()
+ m.bot.edit_forum_topic = AsyncMock()
+ m.bot.send_message = AsyncMock()
+ return m
+
+
+async def test_cmd_new_creates_topic(fresh_db, monkeypatch):
+ import adapter.telegram.handlers.commands as mod
+ importlib.reload(mod)
+ fresh_db.create_chat(1, 42, "Чат #1")
+ msg = make_message(user_id=1, thread_id=42, chat_id=100)
+ await mod.cmd_new(msg)
+ msg.bot.create_forum_topic.assert_called_once()
+ call_kwargs = str(msg.bot.create_forum_topic.call_args)
+ assert "Чат #2" in call_kwargs
+ assert fresh_db.get_chat(1, 200) is not None
+
+
+async def test_cmd_archive_deletes_topic_when_possible(fresh_db, monkeypatch):
+ """When delete_forum_topic succeeds (user-created topic), no answer is sent."""
+ import adapter.telegram.handlers.commands as mod
+ importlib.reload(mod)
+ fresh_db.create_chat(1, 42, "Чат #1")
+ msg = make_message(user_id=1, thread_id=42, chat_id=100)
+ await mod.cmd_archive(msg)
+ msg.bot.delete_forum_topic.assert_called_once_with(chat_id=100, message_thread_id=42)
+ assert fresh_db.get_chat(1, 42)["archived_at"] is not None
+ msg.answer.assert_not_called()
+
+
+async def test_cmd_archive_fallback_message_when_delete_fails(fresh_db, monkeypatch):
+ """When delete_forum_topic fails (bot-created topic), user gets explanation."""
+ import adapter.telegram.handlers.commands as mod
+ importlib.reload(mod)
+ fresh_db.create_chat(1, 42, "Чат #1")
+ msg = make_message(user_id=1, thread_id=42, chat_id=100)
+ msg.bot.delete_forum_topic = AsyncMock(
+ side_effect=TelegramBadRequest(method=MagicMock(), message="not a supergroup forum")
+ )
+ await mod.cmd_archive(msg)
+ assert fresh_db.get_chat(1, 42)["archived_at"] is not None
+ msg.answer.assert_called_once()
+ assert "архивирован" in msg.answer.call_args[0][0]
+
+
+async def test_cmd_archive_unknown_topic_replies_error(fresh_db, monkeypatch):
+ import adapter.telegram.handlers.commands as mod
+ importlib.reload(mod)
+ msg = make_message(user_id=1, thread_id=999, chat_id=100)
+ await mod.cmd_archive(msg)
+ msg.answer.assert_called_once()
+
+
+async def test_cmd_rename_updates_db_and_topic(fresh_db, monkeypatch):
+ import adapter.telegram.handlers.commands as mod
+ importlib.reload(mod)
+ fresh_db.create_chat(1, 42, "Чат #1")
+ msg = make_message(user_id=1, thread_id=42, chat_id=100, text="/rename Работа")
+ await mod.cmd_rename(msg)
+ msg.bot.edit_forum_topic.assert_called_once_with(
+ chat_id=100, message_thread_id=42, name="Работа"
+ )
+ assert fresh_db.get_chat(1, 42)["chat_name"] == "Работа"
+
+
+async def test_cmd_new_topics_limit(fresh_db):
+ """When Telegram returns topics limit error, user gets a friendly message."""
+ import adapter.telegram.handlers.commands as mod
+ importlib.reload(mod)
+ msg = make_message(user_id=1, thread_id=42, chat_id=100)
+ msg.bot.create_forum_topic = AsyncMock(
+ side_effect=TelegramBadRequest(method=MagicMock(), message="topics limit exceeded")
+ )
+ await mod.cmd_new(msg)
+ msg.answer.assert_called_once()
+ assert "лимит" in msg.answer.call_args[0][0]
+ # No chat should be created
+ assert fresh_db.count_active_chats(1) == 0
+
+
+async def test_cmd_archive_general_topic(fresh_db):
+ """/archive in General topic (thread_id=None) replies with 'not found'."""
+ import adapter.telegram.handlers.commands as mod
+ importlib.reload(mod)
+ msg = make_message(user_id=1, thread_id=None, chat_id=100)
+ await mod.cmd_archive(msg)
+ msg.answer.assert_called_once()
+ msg.bot.close_forum_topic.assert_not_called()
diff --git a/tests/adapter/telegram/test_converter.py b/tests/adapter/telegram/test_converter.py
new file mode 100644
index 0000000..38fd70a
--- /dev/null
+++ b/tests/adapter/telegram/test_converter.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+from adapter.telegram.converter import format_outgoing, from_message
+from core.protocol import OutgoingMessage, OutgoingUI
+
+
+def make_message(*, text="hello", thread_id=42, user_id=1):
+ m = SimpleNamespace()
+ m.text = text
+ m.caption = None
+ m.photo = None
+ m.document = None
+ m.voice = None
+ m.message_thread_id = thread_id
+ m.from_user = SimpleNamespace(id=user_id, full_name="Alice")
+ return m
+
+
+def test_from_message_in_topic():
+ msg = make_message(thread_id=42, user_id=7)
+ result = from_message(msg)
+ assert result is not None
+ assert result.user_id == "7"
+ assert result.chat_id == "42"
+ assert result.text == "hello"
+ assert result.platform == "telegram"
+
+
+def test_from_message_in_general_returns_none():
+ msg = make_message(thread_id=None)
+ assert from_message(msg) is None
+
+
+def test_from_message_uses_caption_if_no_text():
+ msg = make_message(text=None, thread_id=10)
+ msg.caption = "caption text"
+ result = from_message(msg)
+ assert result.text == "caption text"
+
+
+def test_format_outgoing_message():
+ event = OutgoingMessage(chat_id="42", text="response")
+ assert format_outgoing(event) == "response"
+
+
+def test_format_outgoing_ui():
+ event = OutgoingUI(chat_id="42", text="choose")
+ assert format_outgoing(event) == "choose"
diff --git a/tests/adapter/telegram/test_message.py b/tests/adapter/telegram/test_message.py
new file mode 100644
index 0000000..69aab1e
--- /dev/null
+++ b/tests/adapter/telegram/test_message.py
@@ -0,0 +1,87 @@
+from __future__ import annotations
+
+import importlib
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def fresh_db(tmp_path, monkeypatch):
+ monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db"))
+ import adapter.telegram.db as db_mod
+ importlib.reload(db_mod)
+ db_mod.init_db()
+ return db_mod
+
+
+def make_message(*, user_id=1, thread_id=42, chat_id=100):
+ m = SimpleNamespace()
+ m.from_user = SimpleNamespace(id=user_id, full_name="Alice")
+ m.message_thread_id = thread_id
+ m.chat = SimpleNamespace(id=chat_id)
+ m.text = "Hello"
+ m.photo = None
+ m.document = None
+ m.voice = None
+ m.video = None
+ m.sticker = None
+ m.answer = AsyncMock()
+ placeholder = MagicMock()
+ placeholder.edit_text = AsyncMock()
+ m.reply = AsyncMock(return_value=placeholder)
+ m.bot = MagicMock()
+ return m, placeholder
+
+
+def make_dispatcher(chunks=None, raise_exc=None):
+ """Build a mock EventDispatcher with configurable stream_message behaviour."""
+ async def _stream(*args, **kwargs):
+ if raise_exc is not None:
+ raise raise_exc
+ for chunk in (chunks or []):
+ yield chunk
+
+ platform = MagicMock()
+ platform.get_or_create_user = AsyncMock(
+ return_value=SimpleNamespace(user_id="uid-1")
+ )
+ platform.stream_message = _stream
+
+ dispatcher = MagicMock()
+ dispatcher._platform = platform
+ return dispatcher
+
+
+async def test_stream_exception_shows_error(fresh_db):
+ """When stream_message raises, the placeholder is updated with an error message."""
+ fresh_db.create_chat(1, 42, "Чат #1")
+ import adapter.telegram.handlers.message as mod
+ importlib.reload(mod)
+
+ msg, placeholder = make_message()
+ dispatcher = make_dispatcher(raise_exc=RuntimeError("boom"))
+
+ await mod.handle_topic_message(msg, dispatcher)
+
+ placeholder.edit_text.assert_called()
+ last_call_text = placeholder.edit_text.call_args[0][0]
+ assert "недоступен" in last_call_text or "ошибка" in last_call_text.lower()
+
+
+async def test_stream_success_edits_placeholder(fresh_db):
+ """When stream_message succeeds, the placeholder is updated with the response."""
+ fresh_db.create_chat(1, 42, "Чат #1")
+ import adapter.telegram.handlers.message as mod
+ importlib.reload(mod)
+
+ chunks = [SimpleNamespace(delta="Hello "), SimpleNamespace(delta="world")]
+ msg, placeholder = make_message()
+ dispatcher = make_dispatcher(chunks=chunks)
+
+ await mod.handle_topic_message(msg, dispatcher)
+
+ placeholder.edit_text.assert_called()
+ last_call_text = placeholder.edit_text.call_args[0][0]
+ assert "Hello world" in last_call_text
diff --git a/tests/adapter/telegram/test_topic_events.py b/tests/adapter/telegram/test_topic_events.py
new file mode 100644
index 0000000..fb490af
--- /dev/null
+++ b/tests/adapter/telegram/test_topic_events.py
@@ -0,0 +1,74 @@
+from __future__ import annotations
+
+import importlib
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def fresh_db(tmp_path, monkeypatch):
+ monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db"))
+ import adapter.telegram.db as db_mod
+ importlib.reload(db_mod)
+ db_mod.init_db()
+ return db_mod
+
+
+BOT_ID = 9999 # distinct from any test user_id
+
+
+def make_service_message(*, user_id=1, thread_id=42, topic_name="Мой чат"):
+ m = SimpleNamespace()
+ m.message_thread_id = thread_id
+ m.from_user = SimpleNamespace(id=user_id, full_name="Alice")
+ m.chat = SimpleNamespace(id=user_id)
+ m.forum_topic_created = SimpleNamespace(name=topic_name)
+ m.forum_topic_edited = SimpleNamespace(name="Новое имя")
+ m.forum_topic_closed = SimpleNamespace()
+ m.answer = AsyncMock()
+ m.bot = SimpleNamespace(id=BOT_ID)
+ return m
+
+
+async def test_on_topic_created_registers_chat(fresh_db, monkeypatch):
+ import adapter.telegram.handlers.topic_events as mod
+ importlib.reload(mod)
+ msg = make_service_message(user_id=5, thread_id=99, topic_name="Мой чат")
+ await mod.on_topic_created(msg)
+ chat = fresh_db.get_chat(5, 99)
+ assert chat is not None
+ assert chat["chat_name"] == "Мой чат"
+
+
+async def test_on_topic_edited_renames_chat(fresh_db, monkeypatch):
+ import adapter.telegram.handlers.topic_events as mod
+ importlib.reload(mod)
+ fresh_db.create_chat(5, 99, "Старое имя")
+ msg = make_service_message(user_id=5, thread_id=99)
+ await mod.on_topic_edited(msg)
+ assert fresh_db.get_chat(5, 99)["chat_name"] == "Новое имя"
+
+
+async def test_on_topic_edited_unknown_chat_is_noop(fresh_db):
+ import adapter.telegram.handlers.topic_events as mod
+ importlib.reload(mod)
+ msg = make_service_message(user_id=5, thread_id=999)
+ await mod.on_topic_edited(msg) # should not raise
+
+
+async def test_on_topic_closed_archives_chat(fresh_db):
+ import adapter.telegram.handlers.topic_events as mod
+ importlib.reload(mod)
+ fresh_db.create_chat(5, 99, "Чат #1")
+ msg = make_service_message(user_id=5, thread_id=99)
+ await mod.on_topic_closed(msg)
+ assert fresh_db.get_chat(5, 99)["archived_at"] is not None
+
+
+async def test_on_topic_closed_unknown_chat_is_noop(fresh_db):
+ import adapter.telegram.handlers.topic_events as mod
+ importlib.reload(mod)
+ msg = make_service_message(user_id=5, thread_id=999)
+ await mod.on_topic_closed(msg) # should not raise
diff --git a/tests/adapter/test_forum_db.py b/tests/adapter/test_forum_db.py
new file mode 100644
index 0000000..e69adc4
--- /dev/null
+++ b/tests/adapter/test_forum_db.py
@@ -0,0 +1,80 @@
+from __future__ import annotations
+
+import importlib
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def fresh_db(tmp_path, monkeypatch):
+ monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db"))
+ import adapter.telegram.db as db_mod
+ importlib.reload(db_mod)
+ db_mod.init_db()
+ return db_mod
+
+
+def test_create_and_get_chat(fresh_db):
+ db = fresh_db
+ db.create_chat(user_id=1, thread_id=100, chat_name="Чат #1")
+ chat = db.get_chat(user_id=1, thread_id=100)
+ assert chat is not None
+ assert chat["chat_name"] == "Чат #1"
+ assert chat["archived_at"] is None
+
+
+def test_get_chat_missing(fresh_db):
+ assert fresh_db.get_chat(user_id=1, thread_id=999) is None
+
+
+def test_archive_chat(fresh_db):
+ db = fresh_db
+ db.create_chat(1, 100, "Чат #1")
+ db.archive_chat(1, 100)
+ chat = db.get_chat(1, 100)
+ assert chat["archived_at"] is not None
+
+
+def test_rename_chat(fresh_db):
+ db = fresh_db
+ db.create_chat(1, 100, "Чат #1")
+ db.rename_chat(1, 100, "Новое имя")
+ assert db.get_chat(1, 100)["chat_name"] == "Новое имя"
+
+
+def test_get_active_chats(fresh_db):
+ db = fresh_db
+ db.create_chat(1, 100, "Чат #1")
+ db.create_chat(1, 200, "Чат #2")
+ db.archive_chat(1, 100)
+ chats = db.get_active_chats(1)
+ assert len(chats) == 1
+ assert chats[0]["thread_id"] == 200
+
+
+def test_display_number(fresh_db):
+ db = fresh_db
+ db.create_chat(1, 100, "Чат #1")
+ db.create_chat(1, 200, "Чат #2")
+ db.create_chat(1, 300, "Чат #3")
+ assert db.get_display_number(1, 100) == 1
+ assert db.get_display_number(1, 200) == 2
+ assert db.get_display_number(1, 300) == 3
+
+
+def test_count_active_chats(fresh_db):
+ db = fresh_db
+ db.create_chat(1, 100, "Чат #1")
+ db.create_chat(1, 200, "Чат #2")
+ db.archive_chat(1, 100)
+ assert db.count_active_chats(1) == 1
+
+
+def test_different_users_isolated(fresh_db):
+ db = fresh_db
+ db.create_chat(1, 100, "Чат #1")
+ db.create_chat(2, 100, "Чат #1") # same thread_id, different user
+ assert db.get_chat(1, 100)["chat_name"] == "Чат #1"
+ assert db.get_chat(2, 100)["chat_name"] == "Чат #1"
+ db.archive_chat(1, 100)
+ assert db.get_chat(1, 100)["archived_at"] is not None
+ assert db.get_chat(2, 100)["archived_at"] is None
diff --git a/tests/core/test_auth.py b/tests/core/test_auth.py
index 9c02e7a..78ec9e1 100644
--- a/tests/core/test_auth.py
+++ b/tests/core/test_auth.py
@@ -2,7 +2,7 @@
import pytest
from core.auth import AuthManager
from core.store import InMemoryStore
-from platform.mock import MockPlatformClient
+from sdk.mock import MockPlatformClient
@pytest.fixture
diff --git a/tests/core/test_chat.py b/tests/core/test_chat.py
index 83d2252..b557e5a 100644
--- a/tests/core/test_chat.py
+++ b/tests/core/test_chat.py
@@ -2,7 +2,7 @@
import pytest
from core.chat import ChatManager
from core.store import InMemoryStore
-from platform.mock import MockPlatformClient
+from sdk.mock import MockPlatformClient
@pytest.fixture
diff --git a/tests/core/test_dispatcher.py b/tests/core/test_dispatcher.py
index 08309dc..fad2a4f 100644
--- a/tests/core/test_dispatcher.py
+++ b/tests/core/test_dispatcher.py
@@ -9,7 +9,7 @@ from core.chat import ChatManager
from core.auth import AuthManager
from core.settings import SettingsManager
from core.store import InMemoryStore
-from platform.mock import MockPlatformClient
+from sdk.mock import MockPlatformClient
@pytest.fixture
@@ -75,6 +75,27 @@ async def test_dispatch_routes_audio_before_catchall(dispatcher):
assert (await dispatcher.dispatch(text_msg))[0].text == "text"
+async def test_dispatch_routes_document_before_catchall(dispatcher):
+ async def document_handler(event, **kwargs):
+ return [OutgoingMessage(chat_id=event.chat_id, text="document")]
+
+ async def catch_all(event, **kwargs):
+ return [OutgoingMessage(chat_id=event.chat_id, text="text")]
+
+ dispatcher.register(IncomingMessage, "document", document_handler)
+ dispatcher.register(IncomingMessage, "*", catch_all)
+
+ document_msg = IncomingMessage(
+ user_id="u1",
+ platform="matrix",
+ chat_id="C1",
+ text="",
+ attachments=[Attachment(type="document", workspace_path="surfaces/matrix/u1/file.pdf")],
+ )
+
+ assert (await dispatcher.dispatch(document_msg))[0].text == "document"
+
+
async def test_dispatch_callback_by_action(dispatcher):
async def confirm_handler(event, **kwargs):
return [OutgoingMessage(chat_id=event.chat_id, text="confirmed")]
diff --git a/tests/core/test_integration.py b/tests/core/test_integration.py
index 40eb1f5..9260ec8 100644
--- a/tests/core/test_integration.py
+++ b/tests/core/test_integration.py
@@ -4,18 +4,57 @@ Smoke test: полный цикл через dispatcher + реальные manag
Имитирует что делает адаптер (Telegram или Matrix) при получении события.
"""
import pytest
-from platform.mock import MockPlatformClient
-from core.store import InMemoryStore
-from core.chat import ChatManager
+
from core.auth import AuthManager
-from core.settings import SettingsManager
+from core.chat import ChatManager
from core.handler import EventDispatcher
from core.handlers import register_all
from core.protocol import (
- IncomingCommand, IncomingMessage, IncomingCallback,
- OutgoingMessage, OutgoingUI,
- Attachment, SettingsAction,
+ Attachment,
+ IncomingCallback,
+ IncomingCommand,
+ IncomingMessage,
+ OutgoingMessage,
+ OutgoingUI,
)
+from core.settings import SettingsManager
+from core.store import InMemoryStore
+from sdk.mock import MockPlatformClient
+from sdk.prototype_state import PrototypeStateStore
+from sdk.real import RealPlatformClient
+from sdk.upstream_agent_api import MsgEventTextChunk
+
+
+class FakeAgentApi:
+ def __init__(self, agent_id: str, base_url: str, chat_id: str) -> None:
+ self.agent_id = agent_id
+ self.base_url = base_url
+ self.chat_id = chat_id
+ self.calls: list[tuple[str, list[str]]] = []
+ self.connect_calls = 0
+ self.close_calls = 0
+
+ async def connect(self) -> None:
+ self.connect_calls += 1
+
+ async def close(self) -> None:
+ self.close_calls += 1
+
+ async def send_message(self, text: str, attachments: list[str] | None = None):
+ self.calls.append((text, attachments or []))
+ yield MsgEventTextChunk(text=f"[REAL] {text}")
+
+
+class FakeAgentApiFactory:
+ def __init__(self) -> None:
+ self.created_chat_ids: list[str] = []
+ self.instances: dict[str, list[FakeAgentApi]] = {}
+
+ def __call__(self, agent_id: str, base_url: str, chat_id: str) -> FakeAgentApi:
+ chat_api = FakeAgentApi(agent_id, base_url, chat_id)
+ self.created_chat_ids.append(chat_id)
+ self.instances.setdefault(chat_id, []).append(chat_api)
+ return chat_api
@pytest.fixture
@@ -32,6 +71,27 @@ def dispatcher():
return d
+@pytest.fixture
+def real_dispatcher():
+ agent_api = FakeAgentApiFactory()
+ platform = RealPlatformClient(
+ agent_id="matrix-bot",
+ agent_base_url="http://platform-agent:8000",
+ agent_api_cls=agent_api,
+ prototype_state=PrototypeStateStore(),
+ platform="matrix",
+ )
+ store = InMemoryStore()
+ d = EventDispatcher(
+ platform=platform,
+ chat_mgr=ChatManager(platform, store),
+ auth_mgr=AuthManager(platform, store),
+ settings_mgr=SettingsManager(platform, store),
+ )
+ register_all(d)
+ return d, agent_api
+
+
async def test_full_flow_start_then_message(dispatcher):
start = IncomingCommand(user_id="tg_123", platform="telegram", chat_id="C1", command="start")
result = await dispatcher.dispatch(start)
@@ -47,7 +107,13 @@ async def test_new_chat_command(dispatcher):
start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start")
await dispatcher.dispatch(start)
- new = IncomingCommand(user_id="u1", platform="matrix", chat_id="C2", command="new", args=["Анализ"])
+ new = IncomingCommand(
+ user_id="u1",
+ platform="matrix",
+ chat_id="C2",
+ command="new",
+ args=["Анализ"],
+ )
result = await dispatcher.dispatch(new)
assert any("Анализ" in r.text for r in result if isinstance(r, OutgoingMessage))
@@ -83,3 +149,46 @@ async def test_toggle_skill_callback(dispatcher):
)
result = await dispatcher.dispatch(cb)
assert any("browser" in r.text for r in result if isinstance(r, OutgoingMessage))
+
+
+async def test_full_flow_with_real_platform_uses_direct_agent_api(real_dispatcher):
+ dispatcher, agent_api = real_dispatcher
+
+ start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start")
+ result = await dispatcher.dispatch(start)
+ assert any(isinstance(r, OutgoingMessage) for r in result)
+
+ msg = IncomingMessage(user_id="u1", platform="matrix", chat_id="C1", text="Привет!")
+ result = await dispatcher.dispatch(msg)
+ texts = [r.text for r in result if isinstance(r, OutgoingMessage)]
+
+ assert texts == ["[REAL] Привет!"]
+ assert agent_api.created_chat_ids == ["C1"]
+ assert [instance.calls for instance in agent_api.instances["C1"]] == [[("Привет!", [])]]
+
+
+async def test_full_flow_with_real_platform_forwards_workspace_attachment(real_dispatcher):
+ dispatcher, agent_api = real_dispatcher
+
+ start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start")
+ await dispatcher.dispatch(start)
+
+ msg = IncomingMessage(
+ user_id="u1",
+ platform="matrix",
+ chat_id="C1",
+ text="Посмотри файл",
+ attachments=[
+ Attachment(
+ type="document",
+ filename="report.pdf",
+ mime_type="application/pdf",
+ workspace_path="surfaces/matrix/u1/room/inbox/report.pdf",
+ )
+ ],
+ )
+ await dispatcher.dispatch(msg)
+
+ assert [instance.calls for instance in agent_api.instances["C1"]] == [
+ [("Посмотри файл", ["surfaces/matrix/u1/room/inbox/report.pdf"])]
+ ]
diff --git a/tests/core/test_settings.py b/tests/core/test_settings.py
index b491ab9..ddd5e96 100644
--- a/tests/core/test_settings.py
+++ b/tests/core/test_settings.py
@@ -3,7 +3,7 @@ import pytest
from core.settings import SettingsManager
from core.store import InMemoryStore
from core.protocol import SettingsAction
-from platform.mock import MockPlatformClient
+from sdk.mock import MockPlatformClient
@pytest.fixture
diff --git a/tests/core/test_voice_slot.py b/tests/core/test_voice_slot.py
index 00cd976..461ca4c 100644
--- a/tests/core/test_voice_slot.py
+++ b/tests/core/test_voice_slot.py
@@ -6,7 +6,7 @@ from core.store import InMemoryStore
from core.auth import AuthManager
from core.chat import ChatManager
from core.settings import SettingsManager
-from platform.mock import MockPlatformClient
+from sdk.mock import MockPlatformClient
@pytest.fixture
diff --git a/tests/platform/test_agent_session.py b/tests/platform/test_agent_session.py
new file mode 100644
index 0000000..c398e8c
--- /dev/null
+++ b/tests/platform/test_agent_session.py
@@ -0,0 +1,27 @@
+"""Compatibility tests after the Phase 4 migration."""
+
+from pathlib import Path
+
+
+def test_lambda_agent_api_module_is_importable():
+ from sdk.upstream_agent_api import AgentApi
+
+ assert AgentApi is not None
+
+
+def test_lambda_agent_api_preserves_base_url_path_suffix():
+ from sdk.upstream_agent_api import AgentApi
+
+ api = AgentApi(
+ agent_id="matrix-bot",
+ base_url="http://platform-agent:8000/proxy/",
+ chat_id="chat-7",
+ )
+
+ assert api.url == "http://platform-agent:8000/proxy/v1/agent_ws/chat-7/"
+
+
+def test_agent_session_module_is_intentionally_stubbed():
+ contents = Path(__file__).resolve().parents[2] / "sdk" / "agent_session.py"
+
+ assert "replaced by direct AgentApi usage" in contents.read_text()
diff --git a/tests/platform/test_mock.py b/tests/platform/test_mock.py
index 03771ae..18003d2 100644
--- a/tests/platform/test_mock.py
+++ b/tests/platform/test_mock.py
@@ -1,6 +1,6 @@
# tests/platform/test_mock.py
-from platform.mock import MockPlatformClient
-from platform.interface import User, MessageResponse, UserSettings
+from sdk.mock import MockPlatformClient
+from sdk.interface import User, MessageResponse, UserSettings
from core.protocol import SettingsAction
@@ -43,3 +43,19 @@ async def test_update_settings_toggle_skill():
await client.update_settings("usr-1", action)
settings = await client.get_settings("usr-1")
assert settings.skills.get("browser") is True
+
+
+async def test_update_settings_toggle_skill_preserves_other_skills():
+ client = MockPlatformClient()
+
+ initial = await client.get_settings("usr-1")
+ initial_skill_names = set(initial.skills)
+
+ action = SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True})
+ await client.update_settings("usr-1", action)
+
+ settings = await client.get_settings("usr-1")
+
+ assert set(settings.skills) == initial_skill_names
+ assert settings.skills["browser"] is True
+ assert settings.skills["web-search"] is True
diff --git a/tests/platform/test_prototype_state.py b/tests/platform/test_prototype_state.py
new file mode 100644
index 0000000..376c0c4
--- /dev/null
+++ b/tests/platform/test_prototype_state.py
@@ -0,0 +1,184 @@
+import pytest
+
+from core.protocol import SettingsAction
+from sdk.interface import UserSettings
+from sdk.prototype_state import PrototypeStateStore
+
+
+@pytest.mark.asyncio
+async def test_get_or_create_user_is_stable_per_surface_identity():
+ store = PrototypeStateStore()
+
+ first = await store.get_or_create_user("@alice:example.org", "matrix", "Alice")
+ second = await store.get_or_create_user("@alice:example.org", "matrix")
+
+ assert first.user_id == "usr-matrix-@alice:example.org"
+ assert first.is_new is True
+ assert store._users["matrix:@alice:example.org"].is_new is False
+
+ first.display_name = "Mallory"
+ first.is_new = False
+
+ assert second.user_id == first.user_id
+ assert second.is_new is False
+ assert second.display_name == "Alice"
+ assert store._users["matrix:@alice:example.org"].display_name == "Alice"
+ assert store._users["matrix:@alice:example.org"].is_new is False
+
+
+@pytest.mark.asyncio
+async def test_settings_defaults_match_existing_mock_shape():
+ store = PrototypeStateStore()
+
+ settings = await store.get_settings("usr-matrix-@alice:example.org")
+
+ assert isinstance(settings, UserSettings)
+ assert settings.skills == {
+ "web-search": True,
+ "fetch-url": True,
+ "email": False,
+ "browser": False,
+ "image-gen": False,
+ "files": True,
+ }
+ assert settings.safety == {
+ "email-send": True,
+ "file-delete": True,
+ "social-post": True,
+ }
+ assert settings.soul == {"name": "Лямбда", "instructions": ""}
+ assert settings.plan == {"name": "Beta", "tokens_used": 0, "tokens_limit": 1000}
+
+
+@pytest.mark.asyncio
+async def test_get_settings_returns_connectors_copy():
+ store = PrototypeStateStore()
+ store._settings["usr-matrix-@alice:example.org"] = {
+ "connectors": {"github": {"enabled": True}},
+ }
+
+ settings = await store.get_settings("usr-matrix-@alice:example.org")
+ settings.connectors["github"]["enabled"] = False
+ settings.connectors["slack"] = {"enabled": True}
+
+ assert store._settings["usr-matrix-@alice:example.org"]["connectors"] == {
+ "github": {"enabled": True},
+ }
+
+
+@pytest.mark.asyncio
+async def test_update_settings_supports_toggle_skill_and_setters():
+ store = PrototypeStateStore()
+
+ await store.update_settings(
+ "usr-matrix-@alice:example.org",
+ SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True}),
+ )
+ await store.update_settings(
+ "usr-matrix-@alice:example.org",
+ SettingsAction(action="set_soul", payload={"field": "instructions", "value": "Be concise"}),
+ )
+ await store.update_settings(
+ "usr-matrix-@alice:example.org",
+ SettingsAction(action="set_safety", payload={"trigger": "social-post", "enabled": False}),
+ )
+
+ settings = await store.get_settings("usr-matrix-@alice:example.org")
+
+ assert settings.skills["browser"] is True
+ assert settings.skills["web-search"] is True
+ assert settings.soul["instructions"] == "Be concise"
+ assert settings.safety["social-post"] is False
+
+
+@pytest.mark.asyncio
+async def test_add_saved_session_appends_named_entries():
+ store = PrototypeStateStore()
+
+ await store.add_saved_session(
+ "usr-matrix-@alice:example.org",
+ "alpha",
+ source_context_id="ctx-room-1",
+ )
+ await store.add_saved_session("usr-matrix-@alice:example.org", "beta")
+
+ sessions = await store.list_saved_sessions("usr-matrix-@alice:example.org")
+
+ assert [session["name"] for session in sessions] == ["alpha", "beta"]
+ assert all("created_at" in session for session in sessions)
+ assert sessions[0]["source_context_id"] == "ctx-room-1"
+
+
+@pytest.mark.asyncio
+async def test_list_saved_sessions_returns_copy():
+ store = PrototypeStateStore()
+
+ await store.add_saved_session("usr-matrix-@alice:example.org", "alpha")
+
+ sessions = await store.list_saved_sessions("usr-matrix-@alice:example.org")
+ sessions.append({"name": "tampered", "created_at": "never"})
+
+ stored = await store.list_saved_sessions("usr-matrix-@alice:example.org")
+
+ assert [session["name"] for session in stored] == ["alpha"]
+
+
+@pytest.mark.asyncio
+async def test_get_last_tokens_used_defaults_to_zero():
+ store = PrototypeStateStore()
+
+ assert await store.get_last_tokens_used_for_context("ctx-room-1") == 0
+
+
+@pytest.mark.asyncio
+async def test_live_tokens_used_are_scoped_per_context():
+ store = PrototypeStateStore()
+
+ await store.set_last_tokens_used_for_context("ctx-room-1", 321)
+ await store.set_last_tokens_used_for_context("ctx-room-2", 654)
+
+ assert await store.get_last_tokens_used_for_context("ctx-room-1") == 321
+ assert await store.get_last_tokens_used_for_context("ctx-room-2") == 654
+
+
+@pytest.mark.asyncio
+async def test_current_session_roundtrip_is_scoped_per_context():
+ store = PrototypeStateStore()
+
+ assert await store.get_current_session_for_context("ctx-room-1") is None
+ assert await store.get_current_session_for_context("ctx-room-2") is None
+
+ await store.set_current_session_for_context("ctx-room-1", "session-1")
+ await store.set_current_session_for_context("ctx-room-2", "session-2")
+
+ assert await store.get_current_session_for_context("ctx-room-1") == "session-1"
+ assert await store.get_current_session_for_context("ctx-room-2") == "session-2"
+
+
+@pytest.mark.asyncio
+async def test_clear_current_session_removes_only_target_context():
+ store = PrototypeStateStore()
+
+ await store.set_current_session_for_context("ctx-room-1", "session-1")
+ await store.set_current_session_for_context("ctx-room-2", "session-2")
+
+ await store.clear_current_session_for_context("ctx-room-1")
+
+ assert await store.get_current_session_for_context("ctx-room-1") is None
+ assert await store.get_current_session_for_context("ctx-room-2") == "session-2"
+
+
+@pytest.mark.asyncio
+async def test_saved_sessions_remain_user_scoped_separate_from_live_context_state():
+ store = PrototypeStateStore()
+
+ await store.set_current_session_for_context("ctx-room-1", "room-session")
+ await store.set_last_tokens_used_for_context("ctx-room-1", 77)
+ await store.add_saved_session("usr-matrix-@alice:example.org", "alpha")
+
+ sessions = await store.list_saved_sessions("usr-matrix-@alice:example.org")
+
+ assert [session["name"] for session in sessions] == ["alpha"]
+ assert all(isinstance(session["created_at"], str) for session in sessions)
+ assert await store.get_current_session_for_context("ctx-room-1") == "room-session"
+ assert await store.get_last_tokens_used_for_context("ctx-room-1") == 77
diff --git a/tests/platform/test_real.py b/tests/platform/test_real.py
new file mode 100644
index 0000000..8bce30b
--- /dev/null
+++ b/tests/platform/test_real.py
@@ -0,0 +1,465 @@
+import asyncio
+
+import pytest
+from pydantic import Field
+
+from core.protocol import SettingsAction
+from sdk.interface import Attachment, MessageChunk, MessageResponse, PlatformError, UserSettings
+from sdk.prototype_state import PrototypeStateStore
+from sdk.real import RealPlatformClient
+from sdk.upstream_agent_api import MsgEventSendFile, MsgEventTextChunk
+
+
+class FakeChatAgentApi:
+ def __init__(self, agent_id: str, base_url: str, chat_id: str) -> None:
+ self.agent_id = agent_id
+ self.base_url = base_url
+ self.chat_id = str(chat_id)
+ self.calls: list[str] = []
+ self.connect_calls = 0
+ self.close_calls = 0
+
+ async def connect(self) -> None:
+ self.connect_calls += 1
+
+ async def close(self) -> None:
+ self.close_calls += 1
+
+ async def send_message(self, text: str, attachments: list[str] | None = None):
+ self.calls.append(text)
+ midpoint = len(text) // 2
+ yield MsgEventTextChunk(text=text[:midpoint])
+ yield MsgEventTextChunk(text=text[midpoint:])
+
+
+class FakeAgentApiFactory:
+ def __init__(self, chat_api_cls=FakeChatAgentApi) -> None:
+ self.chat_api_cls = chat_api_cls
+ self.created_calls: list[tuple[str, str, str]] = []
+ self.instances_by_chat: dict[str, list[FakeChatAgentApi]] = {}
+
+ def __call__(self, agent_id: str, base_url: str, chat_id: str):
+ chat_key = str(chat_id)
+ chat_api = self.chat_api_cls(agent_id, base_url, chat_key)
+ self.created_calls.append((agent_id, base_url, chat_key))
+ self.instances_by_chat.setdefault(chat_key, []).append(chat_api)
+ return chat_api
+
+ def latest(self, chat_id: str):
+ return self.instances_by_chat[str(chat_id)][-1]
+
+
+class BlockingTracker:
+ def __init__(self) -> None:
+ self.active_calls = 0
+ self.max_active_calls = 0
+ self.started = asyncio.Event()
+ self.release = asyncio.Event()
+
+
+class BlockingChatAgentApi(FakeChatAgentApi):
+ def __init__(
+ self,
+ agent_id: str,
+ base_url: str,
+ chat_id: str,
+ *,
+ tracker: BlockingTracker,
+ ) -> None:
+ super().__init__(agent_id, base_url, chat_id)
+ self._tracker = tracker
+
+ async def send_message(self, text: str, attachments: list[str] | None = None):
+ self.calls.append(text)
+ self._tracker.active_calls += 1
+ self._tracker.max_active_calls = max(
+ self._tracker.max_active_calls,
+ self._tracker.active_calls,
+ )
+ self._tracker.started.set()
+ await self._tracker.release.wait()
+ self._tracker.active_calls -= 1
+ yield MsgEventTextChunk(text=text)
+
+
+class BlockingAgentApiFactory(FakeAgentApiFactory):
+ def __init__(self) -> None:
+ super().__init__()
+ self.tracker = BlockingTracker()
+
+ def __call__(self, agent_id: str, base_url: str, chat_id: str):
+ chat_key = str(chat_id)
+ chat_api = BlockingChatAgentApi(
+ agent_id,
+ base_url,
+ chat_key,
+ tracker=self.tracker,
+ )
+ self.created_calls.append((agent_id, base_url, chat_key))
+ self.instances_by_chat.setdefault(chat_key, []).append(chat_api)
+ return chat_api
+
+
+class AttachmentTrackingChatAgentApi(FakeChatAgentApi):
+ def __init__(self, agent_id: str, base_url: str, chat_id: str) -> None:
+ super().__init__(agent_id, base_url, chat_id)
+ self.calls: list[tuple[str, list[str] | None]] = []
+
+ async def send_message(self, text: str, attachments: list[str] | None = None):
+ self.calls.append((text, attachments))
+ yield MsgEventTextChunk(text=text)
+
+
+class FlakyChatAgentApi(FakeChatAgentApi):
+ async def send_message(self, text: str, attachments: list[str] | None = None):
+ raise ConnectionError("Connection closed")
+ yield
+
+
+class ReuseSensitiveChatAgentApi(FakeChatAgentApi):
+ def __init__(self, agent_id: str, base_url: str, chat_id: str) -> None:
+ super().__init__(agent_id, base_url, chat_id)
+ self._send_calls = 0
+
+ async def send_message(self, text: str, attachments: list[str] | None = None):
+ self.calls.append(text)
+ self._send_calls += 1
+ if text == "first":
+ yield MsgEventTextChunk(text="tool ok")
+ return
+ if text == "second" and self._send_calls == 1:
+ yield MsgEventTextChunk(text="Missing")
+
+
+class MessageResponseWithAttachments(MessageResponse):
+ attachments: list[Attachment] = Field(default_factory=list)
+
+
+def make_real_platform_client(
+ agent_api_cls,
+ *,
+ prototype_state: PrototypeStateStore | None = None,
+) -> RealPlatformClient:
+ return RealPlatformClient(
+ agent_id="matrix-bot",
+ agent_base_url="http://platform-agent:8000",
+ agent_api_cls=agent_api_cls,
+ prototype_state=prototype_state or PrototypeStateStore(),
+ platform="matrix",
+ )
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_get_or_create_user_uses_local_state():
+ client = make_real_platform_client(FakeAgentApiFactory())
+
+ first = await client.get_or_create_user("u1", "matrix", "Alice")
+ second = await client.get_or_create_user("u1", "matrix")
+
+ assert first.user_id == "usr-matrix-u1"
+ assert first.is_new is True
+ assert second.user_id == first.user_id
+ assert second.is_new is False
+ assert second.display_name == "Alice"
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_send_message_uses_direct_agent_api_per_chat():
+ agent_api = FakeAgentApiFactory()
+ prototype_state = PrototypeStateStore()
+ client = make_real_platform_client(agent_api, prototype_state=prototype_state)
+
+ result = await client.send_message("@alice:example.org", "chat-7", "hello")
+
+ assert result == MessageResponse(
+ message_id="@alice:example.org",
+ response="hello",
+ tokens_used=0,
+ finished=True,
+ )
+ assert agent_api.created_calls == [("matrix-bot", "http://platform-agent:8000", "chat-7")]
+ assert agent_api.latest("chat-7").chat_id == "chat-7"
+ assert agent_api.latest("chat-7").calls == ["hello"]
+ assert agent_api.latest("chat-7").connect_calls == 1
+ assert agent_api.latest("chat-7").close_calls == 1
+ assert await prototype_state.get_last_tokens_used_for_context("chat-7") == 0
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_preserves_path_base_url_without_trailing_slash():
+ agent_api = FakeAgentApiFactory()
+ client = RealPlatformClient(
+ agent_id="agent-17",
+ agent_base_url="http://lambda.coredump.ru:7000/agent_17",
+ agent_api_cls=agent_api,
+ prototype_state=PrototypeStateStore(),
+ platform="matrix",
+ )
+
+ await client.send_message("@alice:example.org", "41", "hello")
+
+ assert agent_api.created_calls == [
+ ("agent-17", "http://lambda.coredump.ru:7000/agent_17/", "41")
+ ]
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_forwards_attachments_to_chat_api():
+ agent_api = FakeAgentApiFactory(chat_api_cls=AttachmentTrackingChatAgentApi)
+ client = make_real_platform_client(agent_api)
+ attachment = Attachment(
+ url="/agents/7/surfaces/matrix/alice/room/inbox/report.pdf",
+ workspace_path="surfaces/matrix/alice/room/inbox/report.pdf",
+ mime_type="application/pdf",
+ filename="report.pdf",
+ size=123,
+ )
+
+ result = await client.send_message(
+ "@alice:example.org",
+ "chat-7",
+ "hello",
+ attachments=[attachment],
+ )
+
+ assert agent_api.latest("chat-7").calls == [
+ ("hello", ["surfaces/matrix/alice/room/inbox/report.pdf"])
+ ]
+ assert result.response == "hello"
+ assert result.tokens_used == 0
+
+
+def test_attachment_paths_normalize_workspace_roots_to_relative_paths():
+ attachments = [
+ Attachment(workspace_path="/workspace/report.pdf"),
+ Attachment(workspace_path="/agents/7/report.csv"),
+ Attachment(workspace_path="note.txt"),
+ ]
+
+ assert RealPlatformClient._attachment_paths(attachments) == [
+ "report.pdf",
+ "report.csv",
+ "note.txt",
+ ]
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_preserves_send_file_events_in_sync_result(monkeypatch):
+ class FileEventAgentApi(AttachmentTrackingChatAgentApi):
+ async def send_message(self, text: str, attachments: list[str] | None = None):
+ self.calls.append((text, attachments))
+ yield MsgEventTextChunk(text="he")
+ yield MsgEventSendFile(path="report.pdf")
+ yield MsgEventTextChunk(text="llo")
+
+ agent_api = FakeAgentApiFactory(chat_api_cls=FileEventAgentApi)
+ client = make_real_platform_client(agent_api)
+
+ monkeypatch.setattr("sdk.real.MessageResponse", MessageResponseWithAttachments)
+
+ result = await client.send_message("@alice:example.org", "chat-7", "hello")
+
+ assert result.response == "hello"
+ assert result.tokens_used == 0
+ assert result.attachments == [
+ Attachment(
+ url="report.pdf",
+ mime_type="application/octet-stream",
+ filename="report.pdf",
+ size=None,
+ workspace_path="report.pdf",
+ )
+ ]
+
+
+@pytest.mark.parametrize(
+ ("location", "expected_workspace_path"),
+ [
+ ("/workspace/report.pdf", "report.pdf"),
+ ("/agents/7/report.pdf", "report.pdf"),
+ (
+ "surfaces/matrix/alice/room/inbox/report.pdf",
+ "surfaces/matrix/alice/room/inbox/report.pdf",
+ ),
+ ],
+)
+def test_attachment_from_send_file_event_normalizes_shared_volume_paths(
+ location: str, expected_workspace_path: str
+):
+ attachment = RealPlatformClient._attachment_from_send_file_event(
+ MsgEventSendFile(path=location)
+ )
+
+ assert attachment.url == location
+ assert attachment.workspace_path == expected_workspace_path
+ assert attachment.filename == "report.pdf"
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_uses_fresh_agent_connection_per_request():
+ agent_api = FakeAgentApiFactory()
+ client = make_real_platform_client(agent_api)
+
+ await client.send_message("@alice:example.org", "chat-1", "hello")
+ await client.send_message("@alice:example.org", "chat-1", "again")
+
+ assert agent_api.created_calls == [
+ ("matrix-bot", "http://platform-agent:8000", "chat-1"),
+ ("matrix-bot", "http://platform-agent:8000", "chat-1"),
+ ]
+ assert [instance.calls for instance in agent_api.instances_by_chat["chat-1"]] == [
+ ["hello"],
+ ["again"],
+ ]
+ assert all(instance.connect_calls == 1 for instance in agent_api.instances_by_chat["chat-1"])
+ assert all(instance.close_calls == 1 for instance in agent_api.instances_by_chat["chat-1"])
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_avoids_reuse_sensitive_second_message_loss():
+ agent_api = FakeAgentApiFactory(chat_api_cls=ReuseSensitiveChatAgentApi)
+ client = make_real_platform_client(agent_api)
+
+ first = await client.send_message("@alice:example.org", "chat-1", "first")
+ second = await client.send_message("@alice:example.org", "chat-1", "second")
+
+ assert first.response == "tool ok"
+ assert second.response == "Missing"
+ assert len(agent_api.instances_by_chat["chat-1"]) == 2
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_wraps_connection_closed_as_platform_error():
+ agent_api = FakeAgentApiFactory(chat_api_cls=FlakyChatAgentApi)
+ client = make_real_platform_client(agent_api)
+
+ with pytest.raises(PlatformError, match="Connection closed") as exc_info:
+ await client.send_message("@alice:example.org", "chat-1", "hello")
+
+ assert exc_info.value.code == "PLATFORM_CONNECTION_ERROR"
+ assert agent_api.latest("chat-1").close_calls == 1
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_uses_fresh_connection_after_failure():
+ class SometimesFlakyAgentApi(FakeChatAgentApi):
+ async def send_message(self, text: str, attachments: list[str] | None = None):
+ if text == "hello":
+ raise ConnectionError("Connection closed")
+ self.calls.append(text)
+ yield MsgEventTextChunk(text=text)
+
+ agent_api = FakeAgentApiFactory(chat_api_cls=SometimesFlakyAgentApi)
+ client = make_real_platform_client(agent_api)
+
+ with pytest.raises(PlatformError, match="Connection closed"):
+ await client.send_message("@alice:example.org", "chat-1", "hello")
+
+ result = await client.send_message("@alice:example.org", "chat-1", "again")
+
+ assert result.response == "again"
+ assert agent_api.created_calls == [
+ ("matrix-bot", "http://platform-agent:8000", "chat-1"),
+ ("matrix-bot", "http://platform-agent:8000", "chat-1"),
+ ]
+ assert agent_api.latest("chat-1").calls == ["again"]
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_serializes_same_chat_streams_across_send_paths():
+ agent_api = BlockingAgentApiFactory()
+ client = make_real_platform_client(agent_api)
+
+ async def consume_stream():
+ chunks = []
+ async for chunk in client.stream_message("@alice:example.org", "chat-1", "hello"):
+ chunks.append(chunk)
+ return chunks
+
+ stream_task = asyncio.create_task(consume_stream())
+ await asyncio.wait_for(agent_api.tracker.started.wait(), timeout=1)
+
+ send_task = asyncio.create_task(client.send_message("@alice:example.org", "chat-1", "again"))
+ await asyncio.sleep(0)
+
+ assert len(agent_api.instances_by_chat["chat-1"]) == 1
+ assert agent_api.instances_by_chat["chat-1"][0].calls == ["hello"]
+ assert agent_api.tracker.max_active_calls == 1
+
+ agent_api.tracker.release.set()
+ stream_chunks = await stream_task
+ send_result = await send_task
+
+ assert [chunk.delta for chunk in stream_chunks] == ["hello", ""]
+ assert send_result.response == "again"
+ assert [instance.calls for instance in agent_api.instances_by_chat["chat-1"]] == [
+ ["hello"],
+ ["again"],
+ ]
+ assert agent_api.tracker.max_active_calls == 1
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_creates_distinct_connections_per_chat():
+ agent_api = FakeAgentApiFactory()
+ client = make_real_platform_client(agent_api)
+
+ await client.send_message("@alice:example.org", "chat-1", "hello")
+ await client.send_message("@alice:example.org", "chat-2", "world")
+
+ assert agent_api.created_calls == [
+ ("matrix-bot", "http://platform-agent:8000", "chat-1"),
+ ("matrix-bot", "http://platform-agent:8000", "chat-2"),
+ ]
+ assert agent_api.latest("chat-1").calls == ["hello"]
+ assert agent_api.latest("chat-2").calls == ["world"]
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_stream_message_emits_final_tokens_chunk():
+ agent_api = FakeAgentApiFactory()
+ client = make_real_platform_client(agent_api)
+
+ chunks = []
+ async for chunk in client.stream_message("@alice:example.org", "chat-1", "hello"):
+ chunks.append(chunk)
+
+ assert chunks == [
+ MessageChunk(
+ message_id="@alice:example.org",
+ delta="he",
+ finished=False,
+ tokens_used=0,
+ ),
+ MessageChunk(
+ message_id="@alice:example.org",
+ delta="llo",
+ finished=False,
+ tokens_used=0,
+ ),
+ MessageChunk(
+ message_id="@alice:example.org",
+ delta="",
+ finished=True,
+ tokens_used=0,
+ ),
+ ]
+ assert agent_api.created_calls == [("matrix-bot", "http://platform-agent:8000", "chat-1")]
+ assert agent_api.latest("chat-1").calls == ["hello"]
+ assert agent_api.latest("chat-1").close_calls == 1
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_settings_are_local():
+ client = make_real_platform_client(FakeAgentApiFactory())
+
+ await client.update_settings(
+ "usr-matrix-u1",
+ SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True}),
+ )
+
+ settings = await client.get_settings("usr-matrix-u1")
+
+ assert isinstance(settings, UserSettings)
+ assert settings.skills["browser"] is True
+ assert settings.skills["web-search"] is True
diff --git a/tests/test_check_matrix_agents.py b/tests/test_check_matrix_agents.py
new file mode 100644
index 0000000..25f63bd
--- /dev/null
+++ b/tests/test_check_matrix_agents.py
@@ -0,0 +1,22 @@
+from tools.check_matrix_agents import build_agent_ws_url
+
+
+def test_build_agent_ws_url_preserves_path_prefix_without_trailing_slash():
+ assert (
+ build_agent_ws_url("http://lambda.coredump.ru:7000/agent_17", "41")
+ == "http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/41/"
+ )
+
+
+def test_build_agent_ws_url_preserves_path_prefix_with_trailing_slash():
+ assert (
+ build_agent_ws_url("http://lambda.coredump.ru:7000/agent_17/", "41")
+ == "http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/41/"
+ )
+
+
+def test_build_agent_ws_url_accepts_existing_agent_ws_url():
+ assert (
+ build_agent_ws_url("http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/0/", "41")
+ == "http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/41/"
+ )
diff --git a/tests/test_deploy_handoff.py b/tests/test_deploy_handoff.py
new file mode 100644
index 0000000..0cf2057
--- /dev/null
+++ b/tests/test_deploy_handoff.py
@@ -0,0 +1,102 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import yaml
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def _compose(path: str) -> dict:
+ return yaml.safe_load((ROOT / path).read_text(encoding="utf-8"))
+
+
+def test_prod_compose_uses_registry_image_not_local_build():
+ prod = _compose("docker-compose.prod.yml")
+ service = prod["services"]["matrix-bot"]
+
+ assert "image" in service
+ assert "build" not in service
+ assert service["image"].startswith("${SURFACES_BOT_IMAGE:?")
+
+
+def test_fullstack_compose_keeps_local_dev_build_with_agent_api_context():
+ fullstack = _compose("docker-compose.fullstack.yml")
+ service = fullstack["services"]["matrix-bot"]
+
+ assert service["build"]["target"] == "development"
+ assert service["build"]["additional_contexts"]["agent_api"] == "./external/platform-agent_api"
+ assert service["extends"]["file"] == "docker-compose.prod.yml"
+
+
+def test_dockerfile_production_build_does_not_require_local_external_tree():
+ dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8")
+
+ assert "/app/external/platform-agent_api" not in dockerfile
+ assert "external/platform-agent_api" not in dockerfile
+ assert "git+https://git.lambda.coredump.ru/platform/agent_api.git" in dockerfile
+ assert "python -m pip install --no-cache-dir --ignore-requires-python" in dockerfile
+ assert "uv pip install --system --ignore-requires-python" not in dockerfile
+
+
+def test_dockerfile_installs_agent_api_after_final_uv_sync():
+ dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8")
+ development = dockerfile.split("FROM base AS development", maxsplit=1)[1].split(
+ "FROM base AS production", maxsplit=1
+ )[0]
+ production = dockerfile.split("FROM base AS production", maxsplit=1)[1]
+
+ assert development.index("RUN uv sync --no-dev --frozen") < development.index(
+ "pip install --no-cache-dir --ignore-requires-python -e /agent_api/"
+ )
+ assert production.index("RUN uv sync --no-dev --frozen") < production.index(
+ "git+https://git.lambda.coredump.ru/platform/agent_api.git"
+ )
+
+
+def test_dockerignore_excludes_local_only_and_runtime_artifacts():
+ dockerignore = (ROOT / ".dockerignore").read_text(encoding="utf-8")
+
+ assert "external/" in dockerignore
+ assert ".planning/" in dockerignore
+ assert "config/matrix-agents.yaml" in dockerignore
+ assert ".env" in dockerignore
+
+
+def test_agent_registry_example_documents_multi_agent_volume_contract():
+ registry = yaml.safe_load(
+ (ROOT / "config" / "matrix-agents.example.yaml").read_text(encoding="utf-8")
+ )
+ agents = registry["agents"]
+
+ assert len(agents) >= 3
+ assert len({agent["id"] for agent in agents}) == len(agents)
+ assert len({agent["workspace_path"] for agent in agents}) == len(agents)
+ for index, agent in enumerate(agents):
+ assert agent["base_url"].endswith(f"/agent_{index}/")
+ assert agent["workspace_path"] == f"/agents/{index}"
+
+
+def test_smoke_compose_models_deploy_like_proxy_and_surface_checker():
+ smoke = _compose("docker-compose.smoke.yml")
+
+ assert set(smoke["services"]) >= {"surface-smoke", "agent-proxy", "agent-0", "agent-1"}
+ assert "tools.check_matrix_agents" in smoke["services"]["surface-smoke"]["command"]
+ assert smoke["services"]["agent-proxy"]["ports"] == ["${SMOKE_PROXY_PORT:-7000}:7000"]
+
+
+def test_smoke_timeout_override_routes_one_agent_to_no_status_stub():
+ smoke_timeout = _compose("docker-compose.smoke.timeout.yml")
+
+ assert set(smoke_timeout["services"]) >= {"agent-proxy", "agent-no-status"}
+
+
+def test_smoke_registry_targets_local_proxy_routes():
+ registry = yaml.safe_load(
+ (ROOT / "config" / "matrix-agents.smoke.yaml").read_text(encoding="utf-8")
+ )
+
+ assert [agent["base_url"] for agent in registry["agents"]] == [
+ "http://agent-proxy:7000/agent_0/",
+ "http://agent-proxy:7000/agent_1/",
+ ]
diff --git a/tools/__init__.py b/tools/__init__.py
new file mode 100644
index 0000000..a1d9c25
--- /dev/null
+++ b/tools/__init__.py
@@ -0,0 +1 @@
+"""Operational tools for surfaces-bot."""
diff --git a/tools/check_matrix_agents.py b/tools/check_matrix_agents.py
new file mode 100644
index 0000000..d6035aa
--- /dev/null
+++ b/tools/check_matrix_agents.py
@@ -0,0 +1,197 @@
+from __future__ import annotations
+
+import argparse
+import asyncio
+import json
+import os
+import time
+from dataclasses import asdict, dataclass
+from pathlib import Path
+from urllib.parse import urljoin
+
+import aiohttp
+
+from adapter.matrix.agent_registry import AgentDefinition, load_agent_registry
+from sdk.real import RealPlatformClient
+
+
+@dataclass
+class AgentCheckResult:
+ agent_id: str
+ label: str
+ chat_id: str
+ base_url: str
+ ws_url: str
+ ok: bool
+ stage: str
+ latency_ms: int
+ error: str = ""
+ response_type: str = ""
+
+
+def build_agent_ws_url(base_url: str, chat_id: str) -> str:
+ normalized = RealPlatformClient._normalize_agent_base_url(base_url)
+ return urljoin(normalized, f"v1/agent_ws/{chat_id}/")
+
+
+def _message_type(payload: str) -> str:
+ try:
+ data = json.loads(payload)
+ except json.JSONDecodeError:
+ return ""
+ value = data.get("type")
+ return value if isinstance(value, str) else ""
+
+
+async def _receive_text(ws: aiohttp.ClientWebSocketResponse, timeout: float) -> str:
+ msg = await asyncio.wait_for(ws.receive(), timeout=timeout)
+ if msg.type == aiohttp.WSMsgType.TEXT:
+ return str(msg.data)
+ if msg.type == aiohttp.WSMsgType.ERROR:
+ raise RuntimeError(f"websocket error: {ws.exception()}")
+ raise RuntimeError(f"unexpected websocket message type: {msg.type.name}")
+
+
+async def check_agent(
+ agent: AgentDefinition,
+ *,
+ fallback_base_url: str,
+ chat_id: str,
+ timeout: float,
+ message: str | None,
+) -> AgentCheckResult:
+ base_url = agent.base_url or fallback_base_url
+ ws_url = build_agent_ws_url(base_url, chat_id) if base_url else ""
+ started = time.perf_counter()
+
+ def result(ok: bool, stage: str, error: str = "", response_type: str = "") -> AgentCheckResult:
+ return AgentCheckResult(
+ agent_id=agent.agent_id,
+ label=agent.label,
+ chat_id=chat_id,
+ base_url=base_url,
+ ws_url=ws_url,
+ ok=ok,
+ stage=stage,
+ latency_ms=int((time.perf_counter() - started) * 1000),
+ error=error,
+ response_type=response_type,
+ )
+
+ if not base_url:
+ return result(False, "config", "missing base_url and AGENT_BASE_URL")
+
+ try:
+ client_timeout = aiohttp.ClientTimeout(
+ total=timeout,
+ connect=timeout,
+ sock_connect=timeout,
+ sock_read=timeout,
+ )
+ async with aiohttp.ClientSession(timeout=client_timeout) as session:
+ async with session.ws_connect(ws_url, heartbeat=30) as ws:
+ raw_status = await _receive_text(ws, timeout)
+ status_type = _message_type(raw_status)
+ if status_type != "STATUS":
+ return result(
+ False,
+ "status",
+ f"expected STATUS, got {raw_status[:200]}",
+ status_type,
+ )
+
+ if not message:
+ return result(True, "status", response_type=status_type)
+
+ payload = {
+ "type": "USER_MESSAGE",
+ "text": message,
+ "attachments": [],
+ }
+ await ws.send_str(json.dumps(payload))
+
+ while True:
+ raw_event = await _receive_text(ws, timeout)
+ event_type = _message_type(raw_event)
+ if event_type == "ERROR":
+ return result(False, "message", raw_event[:200], event_type)
+ if event_type == "AGENT_EVENT_END":
+ return result(True, "message", response_type=event_type)
+ if not event_type:
+ return result(False, "message", f"invalid JSON event: {raw_event[:200]}")
+ except TimeoutError:
+ return result(False, "timeout", f"no response within {timeout:g}s")
+ except Exception as exc:
+ return result(False, "connect", str(exc))
+
+
+def _select_agents(
+ agents: tuple[AgentDefinition, ...],
+ selected: set[str],
+) -> list[AgentDefinition]:
+ if not selected:
+ return list(agents)
+ return [agent for agent in agents if agent.agent_id in selected]
+
+
+async def run_checks(args: argparse.Namespace) -> list[AgentCheckResult]:
+ registry = load_agent_registry(args.config)
+ selected = _select_agents(registry.agents, set(args.agent))
+ if not selected:
+ raise SystemExit("no matching agents selected")
+
+ fallback_base_url = args.base_url or os.environ.get("AGENT_BASE_URL", "")
+ semaphore = asyncio.Semaphore(args.concurrency)
+
+ async def run_one(index: int, agent: AgentDefinition) -> AgentCheckResult:
+ chat_id = str(args.chat_id if args.chat_id is not None else args.chat_id_base + index)
+ async with semaphore:
+ return await check_agent(
+ agent,
+ fallback_base_url=fallback_base_url,
+ chat_id=chat_id,
+ timeout=args.timeout,
+ message=args.message,
+ )
+
+ return await asyncio.gather(*(run_one(index, agent) for index, agent in enumerate(selected)))
+
+
+def print_table(results: list[AgentCheckResult]) -> None:
+ for item in results:
+ status = "OK" if item.ok else "FAIL"
+ detail = item.response_type or item.error
+ print(
+ f"{status:4} {item.agent_id:20} {item.stage:8} "
+ f"{item.latency_ms:5}ms chat={item.chat_id} url={item.ws_url} {detail}"
+ )
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Smoke-check Matrix agent WebSocket endpoints from matrix-agents.yaml."
+ )
+ parser.add_argument("--config", type=Path, default=Path("config/matrix-agents.yaml"))
+ parser.add_argument("--agent", action="append", default=[], help="Agent id to check")
+ parser.add_argument("--base-url", default="", help="Fallback base URL when an agent has none")
+ parser.add_argument("--timeout", type=float, default=10.0)
+ parser.add_argument("--concurrency", type=int, default=5)
+ parser.add_argument("--chat-id", type=int, default=None, help="Use one explicit chat id")
+ parser.add_argument("--chat-id-base", type=int, default=900000)
+ parser.add_argument("--message", default=None, help="Optional test message after STATUS")
+ parser.add_argument("--json", action="store_true", help="Print machine-readable JSON")
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+ results = asyncio.run(run_checks(args))
+ if args.json:
+ print(json.dumps([asdict(result) for result in results], ensure_ascii=False, indent=2))
+ else:
+ print_table(results)
+ return 0 if all(result.ok for result in results) else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/no_status_agent.py b/tools/no_status_agent.py
new file mode 100644
index 0000000..adb563a
--- /dev/null
+++ b/tools/no_status_agent.py
@@ -0,0 +1,33 @@
+from __future__ import annotations
+
+import argparse
+import asyncio
+
+from aiohttp import web
+
+
+async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
+ ws = web.WebSocketResponse()
+ await ws.prepare(request)
+ await asyncio.sleep(3600)
+ return ws
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="WebSocket stub that accepts connections but sends no STATUS."
+ )
+ parser.add_argument("--host", default="127.0.0.1")
+ parser.add_argument("--port", type=int, default=8000)
+ return parser.parse_args()
+
+
+def main() -> None:
+ args = parse_args()
+ app = web.Application()
+ app.router.add_get("/v1/agent_ws/{chat_id}/", websocket_handler)
+ web.run_app(app, host=args.host, port=args.port)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..76a9426
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,1616 @@
+version = 1
+revision = 3
+requires-python = ">=3.11"
+
+[[package]]
+name = "aiofiles"
+version = "24.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload-time = "2024-06-24T11:02:03.584Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload-time = "2024-06-24T11:02:01.529Z" },
+]
+
+[[package]]
+name = "aiogram"
+version = "3.26.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiofiles" },
+ { name = "aiohttp" },
+ { name = "certifi" },
+ { name = "magic-filter" },
+ { name = "pydantic" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/dd/2b/11709d58a7c47a773cd47239c7b5db258f02f31d784265bddeb562f3e9f9/aiogram-3.26.0.tar.gz", hash = "sha256:12fa1bce9c8cee0f1214f5e3f91bb631586c4503854d6138dacbdd7e7dc1020c", size = 1729985, upload-time = "2026-03-02T23:29:20.843Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/48/8504168e7a3ede9577a2d8aa0ed3f13471ef2db6431f506105b94f284d05/aiogram-3.26.0-py3-none-any.whl", hash = "sha256:dd8ea7feb2409953ad1424564355926207af90bb2204e37be7373fe6de201016", size = 716388, upload-time = "2026-03-02T23:29:19.076Z" },
+]
+
+[[package]]
+name = "aiohappyeyeballs"
+version = "2.6.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" },
+]
+
+[[package]]
+name = "aiohttp"
+version = "3.13.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiohappyeyeballs" },
+ { name = "aiosignal" },
+ { name = "attrs" },
+ { name = "frozenlist" },
+ { name = "multidict" },
+ { name = "propcache" },
+ { name = "yarl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" },
+ { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" },
+ { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" },
+ { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" },
+ { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" },
+ { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" },
+ { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" },
+ { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" },
+ { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" },
+ { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" },
+ { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" },
+ { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" },
+ { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" },
+ { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" },
+ { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" },
+ { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" },
+ { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" },
+ { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" },
+ { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" },
+ { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" },
+ { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" },
+ { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" },
+ { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" },
+ { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" },
+ { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" },
+ { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" },
+ { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" },
+ { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" },
+ { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" },
+]
+
+[[package]]
+name = "aiohttp-socks"
+version = "0.11.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiohttp" },
+ { name = "python-socks" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1f/cc/e5bbd54f76bd56291522251e47267b645dac76327b2657ade9545e30522c/aiohttp_socks-0.11.0.tar.gz", hash = "sha256:0afe51638527c79077e4bd6e57052c87c4824233d6e20bb061c53766421b10f0", size = 11196, upload-time = "2025-12-09T13:35:52.564Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bf/7d/4b633d709b8901d59444d2e512b93e72fe62d2b492a040097c3f7ba017bb/aiohttp_socks-0.11.0-py3-none-any.whl", hash = "sha256:9aacce57c931b8fbf8f6d333cf3cafe4c35b971b35430309e167a35a8aab9ec1", size = 10556, upload-time = "2025-12-09T13:35:50.18Z" },
+]
+
+[[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, upload-time = "2025-07-03T22:54:43.528Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+]
+
+[[package]]
+name = "anyio"
+version = "4.13.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
+]
+
+[[package]]
+name = "attrs"
+version = "26.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.2.25"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "coverage"
+version = "7.13.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" },
+ { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" },
+ { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" },
+ { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" },
+ { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" },
+ { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" },
+ { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" },
+ { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" },
+ { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
+ { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
+ { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
+ { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
+ { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
+ { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
+ { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
+ { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
+ { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
+ { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
+ { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
+ { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
+ { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
+ { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
+ { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
+ { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
+ { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
+ { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
+ { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
+ { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
+ { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
+ { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
+ { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
+ { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
+ { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
+ { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
+]
+
+[package.optional-dependencies]
+toml = [
+ { name = "tomli", marker = "python_full_version <= '3.11'" },
+]
+
+[[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, upload-time = "2025-10-06T05:38:17.865Z" }
+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, upload-time = "2025-10-06T05:35:45.98Z" },
+ { 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, upload-time = "2025-10-06T05:35:47.009Z" },
+ { 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, upload-time = "2025-10-06T05:35:48.38Z" },
+ { 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, upload-time = "2025-10-06T05:35:49.97Z" },
+ { 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, upload-time = "2025-10-06T05:35:51.729Z" },
+ { 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, upload-time = "2025-10-06T05:35:53.246Z" },
+ { 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, upload-time = "2025-10-06T05:35:54.497Z" },
+ { 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, upload-time = "2025-10-06T05:35:55.861Z" },
+ { 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, upload-time = "2025-10-06T05:35:57.399Z" },
+ { 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, upload-time = "2025-10-06T05:35:58.563Z" },
+ { 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, upload-time = "2025-10-06T05:35:59.719Z" },
+ { 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, upload-time = "2025-10-06T05:36:00.959Z" },
+ { 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, upload-time = "2025-10-06T05:36:02.22Z" },
+ { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" },
+ { 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, upload-time = "2025-10-06T05:36:06.649Z" },
+ { 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, upload-time = "2025-10-06T05:36:07.69Z" },
+ { 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, upload-time = "2025-10-06T05:36:08.78Z" },
+ { 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, upload-time = "2025-10-06T05:36:09.801Z" },
+ { 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, upload-time = "2025-10-06T05:36:11.394Z" },
+ { 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, upload-time = "2025-10-06T05:36:12.598Z" },
+ { 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, upload-time = "2025-10-06T05:36:14.065Z" },
+ { 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, upload-time = "2025-10-06T05:36:15.39Z" },
+ { 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, upload-time = "2025-10-06T05:36:16.558Z" },
+ { 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, upload-time = "2025-10-06T05:36:17.821Z" },
+ { 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, upload-time = "2025-10-06T05:36:19.046Z" },
+ { 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, upload-time = "2025-10-06T05:36:20.763Z" },
+ { 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, upload-time = "2025-10-06T05:36:22.129Z" },
+ { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" },
+ { 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, upload-time = "2025-10-06T05:36:27.341Z" },
+ { 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, upload-time = "2025-10-06T05:36:28.855Z" },
+ { 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, upload-time = "2025-10-06T05:36:29.877Z" },
+ { 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, upload-time = "2025-10-06T05:36:31.301Z" },
+ { 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, upload-time = "2025-10-06T05:36:32.531Z" },
+ { 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, upload-time = "2025-10-06T05:36:33.706Z" },
+ { 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, upload-time = "2025-10-06T05:36:34.947Z" },
+ { 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, upload-time = "2025-10-06T05:36:36.534Z" },
+ { 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, upload-time = "2025-10-06T05:36:38.582Z" },
+ { 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, upload-time = "2025-10-06T05:36:40.152Z" },
+ { 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, upload-time = "2025-10-06T05:36:41.355Z" },
+ { 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, upload-time = "2025-10-06T05:36:42.716Z" },
+ { 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, upload-time = "2025-10-06T05:36:44.251Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" },
+ { 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, upload-time = "2025-10-06T05:36:48.78Z" },
+ { 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, upload-time = "2025-10-06T05:36:49.837Z" },
+ { 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, upload-time = "2025-10-06T05:36:50.851Z" },
+ { 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, upload-time = "2025-10-06T05:36:51.898Z" },
+ { 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, upload-time = "2025-10-06T05:36:53.101Z" },
+ { 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, upload-time = "2025-10-06T05:36:54.309Z" },
+ { 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, upload-time = "2025-10-06T05:36:55.566Z" },
+ { 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, upload-time = "2025-10-06T05:36:56.758Z" },
+ { 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, upload-time = "2025-10-06T05:36:57.965Z" },
+ { 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, upload-time = "2025-10-06T05:36:59.237Z" },
+ { 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, upload-time = "2025-10-06T05:37:00.811Z" },
+ { 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, upload-time = "2025-10-06T05:37:02.115Z" },
+ { 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, upload-time = "2025-10-06T05:37:03.711Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" },
+ { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" },
+ { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" },
+ { 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, upload-time = "2025-10-06T05:37:08.438Z" },
+ { 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, upload-time = "2025-10-06T05:37:09.48Z" },
+ { 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, upload-time = "2025-10-06T05:37:10.569Z" },
+ { 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, upload-time = "2025-10-06T05:37:11.993Z" },
+ { 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, upload-time = "2025-10-06T05:37:13.194Z" },
+ { 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, upload-time = "2025-10-06T05:37:14.577Z" },
+ { 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, upload-time = "2025-10-06T05:37:15.781Z" },
+ { 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, upload-time = "2025-10-06T05:37:17.037Z" },
+ { 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, upload-time = "2025-10-06T05:37:18.221Z" },
+ { 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, upload-time = "2025-10-06T05:37:19.771Z" },
+ { 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, upload-time = "2025-10-06T05:37:20.969Z" },
+ { 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, upload-time = "2025-10-06T05:37:22.252Z" },
+ { 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, upload-time = "2025-10-06T05:37:23.5Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" },
+ { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" },
+ { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" },
+ { 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, upload-time = "2025-10-06T05:37:29.373Z" },
+ { 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, upload-time = "2025-10-06T05:37:30.792Z" },
+ { 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, upload-time = "2025-10-06T05:37:32.127Z" },
+ { 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, upload-time = "2025-10-06T05:37:33.21Z" },
+ { 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, upload-time = "2025-10-06T05:37:36.107Z" },
+ { 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, upload-time = "2025-10-06T05:37:37.663Z" },
+ { 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, upload-time = "2025-10-06T05:37:39.261Z" },
+ { 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, upload-time = "2025-10-06T05:37:43.213Z" },
+ { 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, upload-time = "2025-10-06T05:37:45.337Z" },
+ { 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, upload-time = "2025-10-06T05:37:46.657Z" },
+ { 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, upload-time = "2025-10-06T05:37:47.946Z" },
+ { 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, upload-time = "2025-10-06T05:37:49.499Z" },
+ { 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, upload-time = "2025-10-06T05:37:50.745Z" },
+ { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" },
+ { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "h2"
+version = "4.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "hpack" },
+ { name = "hyperframe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
+]
+
+[[package]]
+name = "hpack"
+version = "4.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "hyperframe"
+version = "6.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "jsonschema"
+version = "4.26.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "jsonschema-specifications" },
+ { name = "referencing" },
+ { name = "rpds-py" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
+]
+
+[[package]]
+name = "jsonschema-specifications"
+version = "2025.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "referencing" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
+]
+
+[[package]]
+name = "librt"
+version = "0.8.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" },
+ { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" },
+ { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" },
+ { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" },
+ { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" },
+ { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" },
+ { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" },
+ { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" },
+ { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" },
+ { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" },
+ { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" },
+ { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" },
+ { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" },
+ { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" },
+ { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" },
+ { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" },
+ { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" },
+ { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" },
+ { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" },
+ { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" },
+ { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" },
+ { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" },
+]
+
+[[package]]
+name = "magic-filter"
+version = "1.0.12"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e6/08/da7c2cc7398cc0376e8da599d6330a437c01d3eace2f2365f300e0f3f758/magic_filter-1.0.12.tar.gz", hash = "sha256:4751d0b579a5045d1dc250625c4c508c18c3def5ea6afaf3957cb4530d03f7f9", size = 11071, upload-time = "2023-10-01T12:33:19.006Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cc/75/f620449f0056eff0ec7c1b1e088f71068eb4e47a46eb54f6c065c6ad7675/magic_filter-1.0.12-py3-none-any.whl", hash = "sha256:e5929e544f310c2b1f154318db8c5cdf544dd658efa998172acd2e4ba0f6c6a6", size = 11335, upload-time = "2023-10-01T12:33:17.711Z" },
+]
+
+[[package]]
+name = "matrix-nio"
+version = "0.25.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiofiles" },
+ { name = "aiohttp" },
+ { name = "aiohttp-socks" },
+ { name = "h11" },
+ { name = "h2" },
+ { name = "jsonschema" },
+ { name = "pycryptodome" },
+ { name = "unpaddedbase64" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/33/50/c20129fd6f0e1aad3510feefd3229427fc8163a111f3911ed834e414116b/matrix_nio-0.25.2.tar.gz", hash = "sha256:8ef8180c374e12368e5c83a692abfb3bab8d71efcd17c5560b5c40c9b6f2f600", size = 155480, upload-time = "2024-10-04T07:51:41.62Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7b/0f/8b958d46e23ed4f69d2cffd63b46bb097a1155524e2e7f5c4279c8691c4a/matrix_nio-0.25.2-py3-none-any.whl", hash = "sha256:9c2880004b0e475db874456c0f79b7dd2b6285073a7663bcaca29e0754a67495", size = 181982, upload-time = "2024-10-04T07:51:39.451Z" },
+]
+
+[[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, upload-time = "2026-01-26T02:46:45.979Z" }
+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, upload-time = "2026-01-26T02:43:26.485Z" },
+ { 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, upload-time = "2026-01-26T02:43:27.607Z" },
+ { 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, upload-time = "2026-01-26T02:43:28.661Z" },
+ { 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, upload-time = "2026-01-26T02:43:31.165Z" },
+ { 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, upload-time = "2026-01-26T02:43:32.581Z" },
+ { 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, upload-time = "2026-01-26T02:43:34.417Z" },
+ { 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, upload-time = "2026-01-26T02:43:35.741Z" },
+ { 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, upload-time = "2026-01-26T02:43:36.976Z" },
+ { 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, upload-time = "2026-01-26T02:43:38.258Z" },
+ { 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, upload-time = "2026-01-26T02:43:40.034Z" },
+ { 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, upload-time = "2026-01-26T02:43:41.752Z" },
+ { 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, upload-time = "2026-01-26T02:43:43.042Z" },
+ { 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, upload-time = "2026-01-26T02:43:44.371Z" },
+ { 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, upload-time = "2026-01-26T02:43:45.745Z" },
+ { 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, upload-time = "2026-01-26T02:43:47.054Z" },
+ { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" },
+ { 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, upload-time = "2026-01-26T02:43:52.754Z" },
+ { 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, upload-time = "2026-01-26T02:43:53.893Z" },
+ { 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, upload-time = "2026-01-26T02:43:55.041Z" },
+ { 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, upload-time = "2026-01-26T02:43:56.198Z" },
+ { 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, upload-time = "2026-01-26T02:43:57.499Z" },
+ { 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, upload-time = "2026-01-26T02:43:58.755Z" },
+ { 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, upload-time = "2026-01-26T02:44:00.216Z" },
+ { 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, upload-time = "2026-01-26T02:44:02.202Z" },
+ { 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, upload-time = "2026-01-26T02:44:03.56Z" },
+ { 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, upload-time = "2026-01-26T02:44:04.845Z" },
+ { 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, upload-time = "2026-01-26T02:44:06.133Z" },
+ { 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, upload-time = "2026-01-26T02:44:07.518Z" },
+ { 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, upload-time = "2026-01-26T02:44:09.382Z" },
+ { 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, upload-time = "2026-01-26T02:44:10.73Z" },
+ { 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, upload-time = "2026-01-26T02:44:12.042Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" },
+ { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" },
+ { 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, upload-time = "2026-01-26T02:44:18.509Z" },
+ { 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, upload-time = "2026-01-26T02:44:19.745Z" },
+ { 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, upload-time = "2026-01-26T02:44:21.571Z" },
+ { 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, upload-time = "2026-01-26T02:44:22.803Z" },
+ { 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, upload-time = "2026-01-26T02:44:24.306Z" },
+ { 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, upload-time = "2026-01-26T02:44:25.772Z" },
+ { 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, upload-time = "2026-01-26T02:44:27.648Z" },
+ { 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, upload-time = "2026-01-26T02:44:29.588Z" },
+ { 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, upload-time = "2026-01-26T02:44:30.902Z" },
+ { 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, upload-time = "2026-01-26T02:44:32.31Z" },
+ { 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, upload-time = "2026-01-26T02:44:33.734Z" },
+ { 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, upload-time = "2026-01-26T02:44:35.222Z" },
+ { 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, upload-time = "2026-01-26T02:44:36.604Z" },
+ { 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, upload-time = "2026-01-26T02:44:38.008Z" },
+ { 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, upload-time = "2026-01-26T02:44:39.386Z" },
+ { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" },
+ { 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, upload-time = "2026-01-26T02:44:44.894Z" },
+ { 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, upload-time = "2026-01-26T02:44:46.106Z" },
+ { 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, upload-time = "2026-01-26T02:44:47.278Z" },
+ { 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, upload-time = "2026-01-26T02:44:48.604Z" },
+ { 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, upload-time = "2026-01-26T02:44:50.544Z" },
+ { 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, upload-time = "2026-01-26T02:44:51.951Z" },
+ { 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, upload-time = "2026-01-26T02:44:53.902Z" },
+ { 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, upload-time = "2026-01-26T02:44:55.316Z" },
+ { 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, upload-time = "2026-01-26T02:44:56.783Z" },
+ { 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, upload-time = "2026-01-26T02:44:58.195Z" },
+ { 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, upload-time = "2026-01-26T02:44:59.57Z" },
+ { 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, upload-time = "2026-01-26T02:45:01.054Z" },
+ { 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, upload-time = "2026-01-26T02:45:02.484Z" },
+ { 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, upload-time = "2026-01-26T02:45:03.862Z" },
+ { 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, upload-time = "2026-01-26T02:45:05.296Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" },
+ { 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, upload-time = "2026-01-26T02:45:10.651Z" },
+ { 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, upload-time = "2026-01-26T02:45:11.938Z" },
+ { 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, upload-time = "2026-01-26T02:45:14.346Z" },
+ { 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, upload-time = "2026-01-26T02:45:15.712Z" },
+ { 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, upload-time = "2026-01-26T02:45:17.293Z" },
+ { 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, upload-time = "2026-01-26T02:45:19.356Z" },
+ { 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, upload-time = "2026-01-26T02:45:20.834Z" },
+ { 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, upload-time = "2026-01-26T02:45:22.818Z" },
+ { 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, upload-time = "2026-01-26T02:45:24.368Z" },
+ { 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, upload-time = "2026-01-26T02:45:25.982Z" },
+ { 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, upload-time = "2026-01-26T02:45:27.487Z" },
+ { 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, upload-time = "2026-01-26T02:45:28.991Z" },
+ { 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, upload-time = "2026-01-26T02:45:30.503Z" },
+ { 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, upload-time = "2026-01-26T02:45:32.603Z" },
+ { 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, upload-time = "2026-01-26T02:45:34.043Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" },
+ { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" },
+ { 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, upload-time = "2026-01-26T02:45:40.254Z" },
+ { 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, upload-time = "2026-01-26T02:45:41.635Z" },
+ { 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, upload-time = "2026-01-26T02:45:42.99Z" },
+ { 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, upload-time = "2026-01-26T02:45:44.376Z" },
+ { 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, upload-time = "2026-01-26T02:45:47.167Z" },
+ { 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, upload-time = "2026-01-26T02:45:48.698Z" },
+ { 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, upload-time = "2026-01-26T02:45:50.164Z" },
+ { 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, upload-time = "2026-01-26T02:45:51.779Z" },
+ { 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, upload-time = "2026-01-26T02:45:53.27Z" },
+ { 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, upload-time = "2026-01-26T02:45:54.919Z" },
+ { 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, upload-time = "2026-01-26T02:45:56.981Z" },
+ { 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, upload-time = "2026-01-26T02:45:58.698Z" },
+ { 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, upload-time = "2026-01-26T02:46:00.862Z" },
+ { 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, upload-time = "2026-01-26T02:46:02.824Z" },
+ { 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, upload-time = "2026-01-26T02:46:05.898Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" },
+ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
+]
+
+[[package]]
+name = "mypy"
+version = "1.19.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "librt", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "mypy-extensions" },
+ { name = "pathspec" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" },
+ { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" },
+ { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" },
+ { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" },
+ { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" },
+ { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" },
+ { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" },
+ { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" },
+ { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" },
+ { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" },
+ { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" },
+ { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" },
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
+]
+
+[[package]]
+name = "pathspec"
+version = "1.0.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "propcache"
+version = "0.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" },
+ { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" },
+ { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" },
+ { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" },
+ { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" },
+ { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" },
+ { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" },
+ { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" },
+ { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" },
+ { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" },
+ { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" },
+ { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" },
+ { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" },
+ { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" },
+ { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" },
+ { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" },
+ { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" },
+ { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" },
+ { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" },
+ { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" },
+ { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" },
+ { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" },
+ { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" },
+ { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" },
+ { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" },
+ { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" },
+ { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" },
+ { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" },
+ { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" },
+ { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" },
+ { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" },
+ { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" },
+ { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" },
+ { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" },
+ { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" },
+ { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" },
+ { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" },
+ { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" },
+ { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" },
+ { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
+]
+
+[[package]]
+name = "pycryptodome"
+version = "3.23.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
+ { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
+ { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
+ { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
+ { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
+ { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
+ { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
+ { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
+ { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.12.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.41.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
+ { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
+ { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
+ { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
+ { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
+ { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
+ { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
+ { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
+ { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
+ { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
+ { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
+ { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
+ { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
+ { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
+ { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
+ { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
+ { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
+ { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
+ { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
+ { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
+ { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
+ { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
+ { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
+ { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
+ { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
+ { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
+ { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
+ { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
+ { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
+ { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
+ { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
+ { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
+ { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
+ { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
+ { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
+ { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
+ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.19.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
+]
+
+[[package]]
+name = "pytest-aiohttp"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiohttp" },
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/72/4b/d326890c153f2c4ce1bf45d07683c08c10a1766058a22934620bc6ac6592/pytest_aiohttp-1.1.0.tar.gz", hash = "sha256:147de8cb164f3fc9d7196967f109ab3c0b93ea3463ab50631e56438eab7b5adc", size = 12842, upload-time = "2025-01-23T12:44:04.465Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ba/0f/e6af71c02e0f1098eaf7d2dbf3ffdf0a69fc1e0ef174f96af05cef161f1b/pytest_aiohttp-1.1.0-py3-none-any.whl", hash = "sha256:f39a11693a0dce08dd6c542d241e199dd8047a6e6596b2bcfa60d373f143456d", size = 8932, upload-time = "2025-01-23T12:44:03.27Z" },
+]
+
+[[package]]
+name = "pytest-asyncio"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
+]
+
+[[package]]
+name = "pytest-cov"
+version = "7.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "coverage", extra = ["toml"] },
+ { name = "pluggy" },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
+]
+
+[[package]]
+name = "python-socks"
+version = "2.8.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/36/0b/cd77011c1bc01b76404f7aba07fca18aca02a19c7626e329b40201217624/python_socks-2.8.1.tar.gz", hash = "sha256:698daa9616d46dddaffe65b87db222f2902177a2d2b2c0b9a9361df607ab3687", size = 38909, upload-time = "2026-02-16T05:24:00.745Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/15/fe/9a58cb6eec633ff6afae150ca53c16f8cc8b65862ccb3d088051efdfceb7/python_socks-2.8.1-py3-none-any.whl", hash = "sha256:28232739c4988064e725cdbcd15be194743dd23f1c910f784163365b9d7be035", size = 55087, upload-time = "2026-02-16T05:23:59.147Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
+ { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
+ { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
+ { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
+ { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
+ { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+ { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+ { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+ { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]
+
+[[package]]
+name = "referencing"
+version = "0.37.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "rpds-py" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
+]
+
+[[package]]
+name = "rpds-py"
+version = "0.30.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" },
+ { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" },
+ { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" },
+ { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" },
+ { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" },
+ { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" },
+ { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" },
+ { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" },
+ { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" },
+ { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" },
+ { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" },
+ { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" },
+ { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" },
+ { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" },
+ { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" },
+ { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" },
+ { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" },
+ { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" },
+ { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" },
+ { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" },
+ { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" },
+ { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" },
+ { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" },
+ { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" },
+ { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" },
+ { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" },
+ { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
+ { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
+ { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
+ { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
+ { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
+ { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
+ { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
+ { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
+ { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
+ { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
+ { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
+ { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" },
+ { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" },
+ { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" },
+ { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" },
+ { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" },
+ { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" },
+ { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" },
+]
+
+[[package]]
+name = "ruff"
+version = "0.15.8"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" },
+ { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" },
+ { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" },
+ { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" },
+ { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" },
+ { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" },
+ { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" },
+ { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" },
+ { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" },
+ { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" },
+]
+
+[[package]]
+name = "structlog"
+version = "25.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" },
+]
+
+[[package]]
+name = "surfaces-bot"
+version = "0.1.0"
+source = { editable = "." }
+dependencies = [
+ { name = "aiogram" },
+ { name = "aiohttp" },
+ { name = "httpx" },
+ { name = "matrix-nio" },
+ { name = "pydantic" },
+ { name = "python-dotenv" },
+ { name = "pyyaml" },
+ { name = "structlog" },
+]
+
+[package.optional-dependencies]
+dev = [
+ { name = "mypy" },
+ { name = "pytest" },
+ { name = "pytest-aiohttp" },
+ { name = "pytest-asyncio" },
+ { name = "pytest-cov" },
+ { name = "ruff" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "aiogram", specifier = ">=3.4,<4" },
+ { name = "aiohttp", specifier = ">=3.9" },
+ { name = "httpx", specifier = ">=0.27" },
+ { name = "matrix-nio", specifier = ">=0.21" },
+ { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8" },
+ { name = "pydantic", specifier = ">=2.5" },
+ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
+ { name = "pytest-aiohttp", marker = "extra == 'dev'", specifier = ">=1.0" },
+ { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
+ { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1" },
+ { name = "python-dotenv", specifier = ">=1.0" },
+ { name = "pyyaml", specifier = ">=6.0" },
+ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3" },
+ { name = "structlog", specifier = ">=24.1" },
+]
+provides-extras = ["dev"]
+
+[[package]]
+name = "tomli"
+version = "2.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
+ { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
+ { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
+ { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
+ { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
+ { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
+ { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
+ { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
+ { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
+ { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
+ { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
+ { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
+ { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
+ { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
+ { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
+ { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
+ { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
+ { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
+ { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
+ { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
+ { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
+ { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
+ { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
+ { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
+ { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
+ { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+]
+
+[[package]]
+name = "unpaddedbase64"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4d/f8/114266b21a7a9e3d09b352bb63c9d61d918bb7aa35d08c722793bfbfd28f/unpaddedbase64-2.1.0.tar.gz", hash = "sha256:7273c60c089de39d90f5d6d4a7883a79e319dc9d9b1c8924a7fab96178a5f005", size = 5621, upload-time = "2021-03-09T11:35:47.729Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4c/a7/563b2d8fb7edc07320bf69ac6a7eedcd7a1a9d663a6bb90a4d9bd2eda5f7/unpaddedbase64-2.1.0-py3-none-any.whl", hash = "sha256:485eff129c30175d2cd6f0cd8d2310dff51e666f7f36175f738d75dfdbd0b1c6", size = 6083, upload-time = "2021-03-09T11:35:46.7Z" },
+]
+
+[[package]]
+name = "yarl"
+version = "1.23.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "multidict" },
+ { name = "propcache" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a2/aa/60da938b8f0997ba3a911263c40d82b6f645a67902a490b46f3355e10fae/yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99", size = 123641, upload-time = "2026-03-01T22:04:42.841Z" },
+ { url = "https://files.pythonhosted.org/packages/24/84/e237607faf4e099dbb8a4f511cfd5efcb5f75918baad200ff7380635631b/yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c", size = 86248, upload-time = "2026-03-01T22:04:44.757Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/0d/71ceabc14c146ba8ee3804ca7b3d42b1664c8440439de5214d366fec7d3a/yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432", size = 85988, upload-time = "2026-03-01T22:04:46.365Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/6c/4a90d59c572e46b270ca132aca66954f1175abd691f74c1ef4c6711828e2/yarl-1.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a", size = 100566, upload-time = "2026-03-01T22:04:47.639Z" },
+ { url = "https://files.pythonhosted.org/packages/49/fb/c438fb5108047e629f6282a371e6e91cf3f97ee087c4fb748a1f32ceef55/yarl-1.23.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05", size = 92079, upload-time = "2026-03-01T22:04:48.925Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/13/d269aa1aed3e4f50a5a103f96327210cc5fa5dd2d50882778f13c7a14606/yarl-1.23.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83", size = 108741, upload-time = "2026-03-01T22:04:50.838Z" },
+ { url = "https://files.pythonhosted.org/packages/85/fb/115b16f22c37ea4437d323e472945bea97301c8ec6089868fa560abab590/yarl-1.23.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c", size = 108099, upload-time = "2026-03-01T22:04:52.499Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/64/c53487d9f4968045b8afa51aed7ca44f58b2589e772f32745f3744476c82/yarl-1.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598", size = 102678, upload-time = "2026-03-01T22:04:55.176Z" },
+ { url = "https://files.pythonhosted.org/packages/85/59/cd98e556fbb2bf8fab29c1a722f67ad45c5f3447cac798ab85620d1e70af/yarl-1.23.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b", size = 100803, upload-time = "2026-03-01T22:04:56.588Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/c0/b39770b56d4a9f0bb5f77e2f1763cd2d75cc2f6c0131e3b4c360348fcd65/yarl-1.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c", size = 100163, upload-time = "2026-03-01T22:04:58.492Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/64/6980f99ab00e1f0ff67cb84766c93d595b067eed07439cfccfc8fb28c1a6/yarl-1.23.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788", size = 93859, upload-time = "2026-03-01T22:05:00.268Z" },
+ { url = "https://files.pythonhosted.org/packages/38/69/912e6c5e146793e5d4b5fe39ff5b00f4d22463dfd5a162bec565ac757673/yarl-1.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222", size = 108202, upload-time = "2026-03-01T22:05:02.273Z" },
+ { url = "https://files.pythonhosted.org/packages/59/97/35ca6767524687ad64e5f5c31ad54bc76d585585a9fcb40f649e7e82ffed/yarl-1.23.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb", size = 99866, upload-time = "2026-03-01T22:05:03.597Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/1c/1a3387ee6d73589f6f2a220ae06f2984f6c20b40c734989b0a44f5987308/yarl-1.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc", size = 107852, upload-time = "2026-03-01T22:05:04.986Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/b8/35c0750fcd5a3f781058bfd954515dd4b1eab45e218cbb85cf11132215f1/yarl-1.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2", size = 102919, upload-time = "2026-03-01T22:05:06.397Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/1c/9a1979aec4a81896d597bcb2177827f2dbee3f5b7cc48b2d0dadb644b41d/yarl-1.23.0-cp311-cp311-win32.whl", hash = "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5", size = 82602, upload-time = "2026-03-01T22:05:08.444Z" },
+ { url = "https://files.pythonhosted.org/packages/93/22/b85eca6fa2ad9491af48c973e4c8cf6b103a73dbb271fe3346949449fca0/yarl-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46", size = 87461, upload-time = "2026-03-01T22:05:10.145Z" },
+ { url = "https://files.pythonhosted.org/packages/93/95/07e3553fe6f113e6864a20bdc53a78113cda3b9ced8784ee52a52c9f80d8/yarl-1.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928", size = 82336, upload-time = "2026-03-01T22:05:11.554Z" },
+ { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" },
+ { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" },
+ { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" },
+ { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" },
+ { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" },
+ { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" },
+ { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" },
+ { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" },
+ { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" },
+ { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" },
+ { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" },
+ { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" },
+ { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" },
+ { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" },
+ { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" },
+ { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" },
+ { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" },
+ { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" },
+ { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" },
+ { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" },
+ { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" },
+ { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" },
+ { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" },
+ { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" },
+ { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" },
+ { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" },
+ { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" },
+ { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" },
+ { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" },
+ { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" },
+ { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" },
+ { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" },
+ { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" },
+ { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" },
+ { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" },
+ { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" },
+ { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" },
+ { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" },
+ { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" },
+ { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" },
+ { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" },
+]