from __future__ import annotations import re from datetime import UTC, datetime from typing import TYPE_CHECKING import httpx import structlog from adapter.matrix.store import ( get_room_meta, next_platform_chat_id, set_load_pending, set_platform_chat_id, ) from core.protocol import IncomingCommand, OutgoingEvent, OutgoingMessage if TYPE_CHECKING: from core.store import StateStore from sdk.prototype_state import PrototypeStateStore logger = structlog.get_logger(__name__) SAVE_PROMPT = ( "Summarize our conversation and save to /workspace/contexts/{name}.md. " "Reply only with: Saved: {name}" ) LOAD_PROMPT = ( "Load context from /workspace/contexts/{name}.md and use it as background " "for our conversation. Reply: Loaded: {name}" ) _VALID_NAME = re.compile(r"^[A-Za-z0-9_-]+$") def _sanitize_session_name(raw_name: str) -> str | None: name = raw_name.strip() if not name or not _VALID_NAME.fullmatch(name): return None return name async def _resolve_room_id(event: IncomingCommand, chat_mgr) -> str: if chat_mgr is None: return event.chat_id ctx = await chat_mgr.get(event.chat_id, user_id=event.user_id) if ctx is not None and ctx.surface_ref: return ctx.surface_ref return event.chat_id async def _resolve_context_scope( event: IncomingCommand, store: StateStore, chat_mgr, ) -> tuple[str, str | None]: room_id = await _resolve_room_id(event, chat_mgr) room_meta = await get_room_meta(store, room_id) platform_chat_id = room_meta.get("platform_chat_id") if room_meta else None return room_id, platform_chat_id async def _require_platform_context( event: IncomingCommand, store: StateStore, chat_mgr, ) -> tuple[str, str]: room_id, platform_chat_id = await _resolve_context_scope(event, store, chat_mgr) if not platform_chat_id: raise RuntimeError(f"matrix room context is incomplete: {room_id}") return room_id, platform_chat_id def make_handle_save(agent_api, store: StateStore, prototype_state: PrototypeStateStore): async def handle_save( event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr ) -> list[OutgoingEvent]: if event.args: name = _sanitize_session_name(event.args[0]) if name is None: return [ OutgoingMessage( chat_id=event.chat_id, text="Имя сохранения может содержать только буквы, цифры, _ и -.", ) ] else: name = f"context-{datetime.now(UTC).strftime('%Y%m%d-%H%M%S')}" try: await platform.send_message( event.user_id, event.chat_id, SAVE_PROMPT.format(name=name), ) except Exception as exc: logger.warning("save_agent_call_failed", error=str(exc)) return [OutgoingMessage(chat_id=event.chat_id, text=f"Ошибка при сохранении: {exc}")] try: _, platform_chat_id = await _require_platform_context(event, store, chat_mgr) except RuntimeError as exc: logger.warning("save_context_incomplete", error=str(exc)) return [OutgoingMessage(chat_id=event.chat_id, text="Контекст комнаты не готов. Попробуй позже.")] await prototype_state.add_saved_session( event.user_id, name, source_context_id=platform_chat_id, ) return [ OutgoingMessage( chat_id=event.chat_id, text=f"Запрос на сохранение отправлен агенту: {name}", ) ] return handle_save def make_handle_load(store: StateStore, prototype_state: PrototypeStateStore): async def handle_load( event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr ) -> list[OutgoingEvent]: sessions = await prototype_state.list_saved_sessions(event.user_id) if not sessions: return [ OutgoingMessage( chat_id=event.chat_id, text="Нет сохранённых сессий. Используй !save [имя].", ) ] room_id, _ = await _resolve_context_scope(event, store, chat_mgr) lines = ["Сохранённые сессии:"] for index, session in enumerate(sessions, start=1): created = session.get("created_at", "")[:10] lines.append(f" {index}. {session['name']} ({created})") lines.append("") lines.append("Введи номер или 0 / !cancel для отмены.") await set_load_pending(store, event.user_id, room_id, {"saves": sessions}) return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))] return handle_load def make_handle_reset(store: StateStore, prototype_state: PrototypeStateStore): async def handle_reset( event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr ) -> list[OutgoingEvent]: try: room_id, old_chat_id = await _require_platform_context(event, store, chat_mgr) except RuntimeError as exc: logger.warning("clear_context_incomplete", error=str(exc)) return [OutgoingMessage(chat_id=event.chat_id, text="Контекст комнаты не готов. Попробуй позже.")] new_chat_id = await next_platform_chat_id(store) await set_platform_chat_id(store, room_id, new_chat_id) disconnect = getattr(platform, "disconnect_chat", None) if callable(disconnect): await disconnect(old_chat_id) await prototype_state.clear_current_session(old_chat_id) await prototype_state.clear_current_session(new_chat_id) return [ OutgoingMessage( chat_id=event.chat_id, text="Контекст сброшен. Агент не помнит предыдущий разговор.", ) ] return handle_reset async def _call_reset_endpoint(agent_base_url: str, chat_id: str) -> list[OutgoingEvent]: try: async with httpx.AsyncClient() as client: response = await client.post(f"{agent_base_url}/reset", timeout=5.0) except (httpx.ConnectError, httpx.TimeoutException) as exc: logger.warning("reset_endpoint_unreachable", error=str(exc)) return [ OutgoingMessage( chat_id=chat_id, text="Reset endpoint недоступен. Обратитесь к администратору.", ) ] if response.status_code == 404: return [ OutgoingMessage( chat_id=chat_id, text="Reset endpoint недоступен. Обратитесь к администратору.", ) ] return [OutgoingMessage(chat_id=chat_id, text="Контекст сброшен.")] def make_handle_context(store: StateStore, prototype_state: PrototypeStateStore): async def handle_context( event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr ) -> list[OutgoingEvent]: try: _, platform_chat_id = await _require_platform_context(event, store, chat_mgr) except RuntimeError as exc: logger.warning("context_scope_incomplete", error=str(exc)) return [OutgoingMessage(chat_id=event.chat_id, text="Контекст комнаты не готов. Попробуй позже.")] current_session = await prototype_state.get_current_session(platform_chat_id) tokens_used = await prototype_state.get_last_tokens_used(platform_chat_id) sessions = await prototype_state.list_saved_sessions(event.user_id) lines = [ "Контекст:", f" Контекст чата: {platform_chat_id}", f" Сессия: {current_session or 'не загружена'}", f" Токены (последний ответ): {tokens_used}", f" Сохранения ({len(sessions)}):", ] if sessions: for session in sessions: created = session.get("created_at", "")[:10] lines.append(f" - {session['name']} ({created})") else: lines.append(" (нет)") return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))] return handle_context