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, set_load_pending, set_reset_pending 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 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}")] _, platform_chat_id = await _resolve_context_scope(event, store, chat_mgr) await prototype_state.add_saved_session( event.user_id, name, source_context_id=platform_chat_id or event.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", agent_base_url: str): async def handle_reset( event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr ) -> list[OutgoingEvent]: room_id = await _resolve_room_id(event, chat_mgr) await set_reset_pending(store, event.user_id, room_id, {"active": True}) return [ OutgoingMessage( chat_id=event.chat_id, text=( "Сбросить контекст агента? Выбери:\n" " !yes - сбросить\n" " !save [имя] - сохранить и сбросить\n" " !no - отмена" ), ) ] 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]: _, platform_chat_id = await _resolve_context_scope(event, store, chat_mgr) context_key = platform_chat_id or event.chat_id current_session = await prototype_state.get_current_session(context_key) tokens_used = await prototype_state.get_last_tokens_used(context_key) if platform_chat_id is not None and event.chat_id != platform_chat_id: if current_session is None: current_session = await prototype_state.get_current_session(event.chat_id) if tokens_used == 0: tokens_used = await prototype_state.get_last_tokens_used(event.chat_id) sessions = await prototype_state.list_saved_sessions(event.user_id) lines = [ "Контекст:", f" Контекст чата: {platform_chat_id or event.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