---
phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma
plan: 02
type: execute
wave: 2
depends_on:
  - 04-01-PLAN.md
files_modified:
  - sdk/prototype_state.py
  - adapter/matrix/store.py
  - adapter/matrix/handlers/__init__.py
  - adapter/matrix/handlers/context_commands.py
  - adapter/matrix/bot.py
  - tests/adapter/matrix/test_context_commands.py
  - tests/platform/test_prototype_state.py
autonomous: true
requirements:
  - Implement !save, !load, !reset, !context commands
  - PrototypeStateStore saved sessions storage
  - !load pending state in Matrix store
  - !reset pending state in Matrix store
  - Numeric input interception for !load

must_haves:
  truths:
    - "!save sends a save prompt to the agent and records session name in PrototypeStateStore"
    - "!load shows a numbered list of saved sessions; numeric reply selects a session"
    - "!reset shows a confirmation dialog; !yes calls POST /reset; !no cancels"
    - "!context returns current session name, last tokens_used, and list of saved sessions"
    - "Numeric input intercepted in on_room_message before dispatcher.dispatch when load_pending is set"
    - "!yes in reset_pending context calls POST {AGENT_BASE_URL}/reset and reports unavailable on 404"
    - "All context command tests pass"
  artifacts:
    - path: "adapter/matrix/handlers/context_commands.py"
      provides: "make_handle_save, make_handle_load, make_handle_reset, make_handle_context"
    - path: "adapter/matrix/store.py"
      provides: "get_load_pending, set_load_pending, clear_load_pending, get_reset_pending, set_reset_pending, clear_reset_pending"
    - path: "sdk/prototype_state.py"
      provides: "add_saved_session, list_saved_sessions, get_last_tokens_used, set_last_tokens_used"
    - path: "tests/adapter/matrix/test_context_commands.py"
      provides: "tests for all four commands"
  key_links:
    - from: "adapter/matrix/bot.py on_room_message()"
      to: "adapter/matrix/store.get_load_pending()"
      via: "check before dispatcher.dispatch"
      pattern: "get_load_pending"
    - from: "adapter/matrix/handlers/context_commands.py make_handle_reset"
      to: "httpx.AsyncClient.post(AGENT_BASE_URL + '/reset')"
      via: "!yes handler inside reset_pending flow"
      pattern: "httpx"
    - from: "sdk/real.py stream_message()"
      to: "prototype_state.set_last_tokens_used()"
      via: "call after final chunk"
      pattern: "set_last_tokens_used"
---

<objective>
Add four context management commands to the Matrix bot: !save, !load, !reset, !context.
Extend PrototypeStateStore with saved sessions and last_tokens_used tracking. Add
load_pending and reset_pending state keys to Matrix store. Wire numeric input
interception in on_room_message. Register all handlers.

Purpose: Users need to save, load, and reset agent context, and inspect current context
state — essential for a shared-context MVP where one agent container persists across
Matrix sessions.

Output: context_commands.py handler module, store.py extensions, prototype_state.py
extensions, bot.py updated, full test coverage.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-CONTEXT.md
@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-RESEARCH.md
@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-01-SUMMARY.md
</context>

<interfaces>
<!-- Key contracts executor needs. Read source files before touching anything. -->

From adapter/matrix/store.py (existing pattern):
```python
PENDING_CONFIRM_PREFIX = "matrix_pending_confirm:"

def _pending_confirm_key(user_id: str, room_id: str | None = None) -> str: ...
async def get_pending_confirm(store, user_id, room_id=None) -> dict | None: ...
async def set_pending_confirm(store, user_id, room_id, meta) -> None: ...
async def clear_pending_confirm(store, user_id, room_id=None) -> None: ...
```

New store keys to add (same pattern):
```python
LOAD_PENDING_PREFIX = "matrix_load_pending:"
RESET_PENDING_PREFIX = "matrix_reset_pending:"

# Keys: f"{PREFIX}{user_id}:{room_id}"
# load_pending data: {"saves": [{"name": str, "created_at": str}, ...], "display": str}
# reset_pending data: {"active": True}
```

From adapter/matrix/handlers/__init__.py (existing registration):
```python
def register_matrix_handlers(dispatcher: EventDispatcher, client=None, store=None) -> None:
    dispatcher.register(IncomingCommand, "new", make_handle_new_chat(client, store))
    ...
```

Handler closure signature (all existing handlers follow this):
```python
async def handle_X(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list[OutgoingEvent]:
```

New handlers use make_handle_X(agent_api, store, prototype_state) closures:
```python
async def _inner(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list[OutgoingEvent]:
    ...
return _inner
```

From sdk/prototype_state.py (PrototypeStateStore to extend):
```python
class PrototypeStateStore:
    def __init__(self) -> None:
        self._users: dict[str, User] = {}
        self._settings: dict[str, dict[str, Any]] = {}
    # Add:
    # self._saved_sessions: dict[str, list[dict]] = {}
    # self._last_tokens_used: dict[str, int] = {}
```

From core/protocol.py:
```python
@dataclass
class IncomingCommand:
    user_id: str; platform: str; chat_id: str; command: str; args: list[str]

@dataclass
class OutgoingMessage:
    chat_id: str; text: str

@dataclass
class OutgoingUI:
    chat_id: str; text: str; buttons: list[UIButton]
```

From sdk/real.py (after Plan 01):
```python
class RealPlatformClient:
    async def stream_message(self, user_id, chat_id, text, ...) -> AsyncIterator[MessageChunk]:
        # yields chunks; last chunk has finished=True, tokens_used=agent_api.last_tokens_used
```

SAVE_PROMPT template (Claude's Discretion):
```python
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}"
)
```

Auto-name format for !save without args: `context-{YYYYMMDD-HHMMSS}` UTC.
HTTP client for POST /reset: httpx.AsyncClient (already in pyproject.toml deps).
AGENT_BASE_URL env var: `os.environ.get("AGENT_BASE_URL", "http://127.0.0.1:8000")`
</interfaces>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Extend PrototypeStateStore and Matrix store with pending state helpers</name>

  <read_first>
    - sdk/prototype_state.py (full file — adding saved_sessions and last_tokens_used)
    - adapter/matrix/store.py (full file — adding load_pending and reset_pending helpers)
    - tests/platform/test_prototype_state.py (full file — adding new test cases)
  </read_first>

  <files>sdk/prototype_state.py, adapter/matrix/store.py, tests/platform/test_prototype_state.py</files>

  <behavior>
    - PrototypeStateStore.__init__ adds: self._saved_sessions: dict[str, list[dict]] = {} and self._last_tokens_used: dict[str, int] = {}
    - add_saved_session(user_id: str, name: str) -> None: appends {"name": name, "created_at": datetime.now(UTC).isoformat()} to _saved_sessions[user_id]
    - list_saved_sessions(user_id: str) -> list[dict]: returns copy of _saved_sessions.get(user_id, [])
    - get_last_tokens_used(user_id: str) -> int: returns _last_tokens_used.get(user_id, 0)
    - set_last_tokens_used(user_id: str, tokens: int) -> None: sets _last_tokens_used[user_id] = tokens
    - adapter/matrix/store.py adds LOAD_PENDING_PREFIX and RESET_PENDING_PREFIX constants
    - get_load_pending(store, user_id, room_id) -> dict | None
    - set_load_pending(store, user_id, room_id, data: dict) -> None
    - clear_load_pending(store, user_id, room_id) -> None
    - get_reset_pending(store, user_id, room_id) -> dict | None
    - set_reset_pending(store, user_id, room_id, data: dict) -> None
    - clear_reset_pending(store, user_id, room_id) -> None
    - test_prototype_state.py gets 4 new tests: add/list saved sessions, last_tokens_used get/set
  </behavior>

  <action>
1. Edit sdk/prototype_state.py — add to __init__ and add four new async methods:

In __init__ after existing attributes:
```python
        self._saved_sessions: dict[str, list[dict]] = {}
        self._last_tokens_used: dict[str, int] = {}
```

After update_settings() method, add:
```python
    async def add_saved_session(self, user_id: str, name: str) -> None:
        sessions = self._saved_sessions.setdefault(user_id, [])
        sessions.append({"name": name, "created_at": datetime.now(UTC).isoformat()})

    async def list_saved_sessions(self, user_id: str) -> list[dict]:
        return list(self._saved_sessions.get(user_id, []))

    async def get_last_tokens_used(self, user_id: str) -> int:
        return self._last_tokens_used.get(user_id, 0)

    async def set_last_tokens_used(self, user_id: str, tokens: int) -> None:
        self._last_tokens_used[user_id] = tokens
```

2. Edit adapter/matrix/store.py — add after existing constants and helpers:

After PENDING_CONFIRM_PREFIX line, add:
```python
LOAD_PENDING_PREFIX = "matrix_load_pending:"
RESET_PENDING_PREFIX = "matrix_reset_pending:"
```

After clear_pending_confirm(), add:
```python
def _load_pending_key(user_id: str, room_id: str) -> str:
    return f"{LOAD_PENDING_PREFIX}{user_id}:{room_id}"

async def get_load_pending(store: StateStore, user_id: str, room_id: str) -> dict | None:
    return await store.get(_load_pending_key(user_id, room_id))

async def set_load_pending(store: StateStore, user_id: str, room_id: str, data: dict) -> None:
    await store.set(_load_pending_key(user_id, room_id), data)

async def clear_load_pending(store: StateStore, user_id: str, room_id: str) -> None:
    await store.delete(_load_pending_key(user_id, room_id))


def _reset_pending_key(user_id: str, room_id: str) -> str:
    return f"{RESET_PENDING_PREFIX}{user_id}:{room_id}"

async def get_reset_pending(store: StateStore, user_id: str, room_id: str) -> dict | None:
    return await store.get(_reset_pending_key(user_id, room_id))

async def set_reset_pending(store: StateStore, user_id: str, room_id: str, data: dict) -> None:
    await store.set(_reset_pending_key(user_id, room_id), data)

async def clear_reset_pending(store: StateStore, user_id: str, room_id: str) -> None:
    await store.delete(_reset_pending_key(user_id, room_id))
```

3. Edit tests/platform/test_prototype_state.py — append four new tests:

```python
@pytest.mark.asyncio
async def test_saved_sessions_add_and_list():
    store = PrototypeStateStore()
    await store.add_saved_session("u1", "my-save")
    await store.add_saved_session("u1", "another-save")
    sessions = await store.list_saved_sessions("u1")
    assert len(sessions) == 2
    assert sessions[0]["name"] == "my-save"
    assert "created_at" in sessions[0]
    assert sessions[1]["name"] == "another-save"


@pytest.mark.asyncio
async def test_saved_sessions_list_returns_copy():
    store = PrototypeStateStore()
    await store.add_saved_session("u1", "my-save")
    sessions = await store.list_saved_sessions("u1")
    sessions.append({"name": "injected"})
    sessions2 = await store.list_saved_sessions("u1")
    assert len(sessions2) == 1


@pytest.mark.asyncio
async def test_last_tokens_used_default_zero():
    store = PrototypeStateStore()
    assert await store.get_last_tokens_used("u1") == 0


@pytest.mark.asyncio
async def test_last_tokens_used_set_and_get():
    store = PrototypeStateStore()
    await store.set_last_tokens_used("u1", 42)
    assert await store.get_last_tokens_used("u1") == 42
```
  </action>

  <verify>
    <automated>cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m pytest tests/platform/test_prototype_state.py -v 2>&1 | tail -15</automated>
  </verify>

  <done>
    - PrototypeStateStore has add_saved_session, list_saved_sessions, get_last_tokens_used, set_last_tokens_used
    - adapter/matrix/store.py has LOAD_PENDING_PREFIX, RESET_PENDING_PREFIX, and 6 new helper functions
    - All test_prototype_state.py tests pass (including 4 new ones)
    - `grep "add_saved_session\|list_saved_sessions" sdk/prototype_state.py` returns matches
    - `grep "LOAD_PENDING_PREFIX\|RESET_PENDING_PREFIX" adapter/matrix/store.py` returns matches
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Implement context_commands.py handlers, wire into __init__.py and bot.py, update tokens_used tracking in real.py</name>

  <read_first>
    - adapter/matrix/handlers/__init__.py (full file — adding registrations)
    - adapter/matrix/handlers/confirm.py (full file — example of make_handle_X closure pattern with store)
    - adapter/matrix/bot.py (full file — on_room_message and build_runtime need changes)
    - sdk/real.py (after Plan 01 — add set_last_tokens_used call after stream_message)
    - adapter/matrix/store.py (after Task 1 — load_pending/reset_pending helpers now available)
    - sdk/prototype_state.py (after Task 1 — saved_sessions methods available)
  </read_first>

  <files>
    adapter/matrix/handlers/context_commands.py,
    adapter/matrix/handlers/__init__.py,
    adapter/matrix/bot.py,
    sdk/real.py,
    tests/adapter/matrix/test_context_commands.py
  </files>

  <behavior>
    - context_commands.py exports: make_handle_save, make_handle_load, make_handle_reset, make_handle_context
    - make_handle_save(agent_api, store, prototype_state) -> handler:
        !save with no args: auto-name = f"context-{datetime.now(UTC).strftime('%Y%m%d-%H%M%S')}"
        !save [name]: use args[0] as name
        sends SAVE_PROMPT via platform.send_message (NOT stream — simple blocking send)
        calls prototype_state.add_saved_session(event.user_id, name)
        returns [OutgoingMessage(chat_id=event.chat_id, text=f"Сохранение запущено: {name}")]
    - make_handle_load(agent_api, store, prototype_state) -> handler:
        !load: fetches sessions = await prototype_state.list_saved_sessions(event.user_id)
        if empty: returns [OutgoingMessage(chat_id=..., text="Нет сохранённых сессий. Используй !save [имя].")]
        else: builds numbered display text, stores load_pending via set_load_pending(store, event.user_id, room_id, {"saves": sessions})
        room_id is in event.chat_id (in Matrix adapter, chat_id == room_id for commands)
        returns [OutgoingMessage(chat_id=..., text=display_text + "\nВведи номер или 0 / !cancel для отмены.")]
    - Numeric input interception in MatrixBot.on_room_message():
        Before dispatcher.dispatch, check load_pending = await get_load_pending(runtime.store, sender, room_id)
        If load_pending and msg text is digit: handle_load_selection(pending, selection, ...)
        handle_load_selection: if text == "0" or "!cancel" → clear_load_pending, return [OutgoingMessage("Отменено")]
        if valid index → clear_load_pending, send LOAD_PROMPT via platform.send_message, add session as current_session in prototype_state (store in dict), return [OutgoingMessage("Загрузка: {name}")]
        if invalid index → return [OutgoingMessage("Неверный номер. Введи от 1 до N или 0 для отмены.")]
    - make_handle_reset(store, agent_base_url) -> handler:
        !reset: set reset_pending, return [OutgoingMessage with text:
          "Сбросить контекст агента? Выбери:\n  !yes — сбросить\n  !save [имя] — сохранить и сбросить\n  !no — отмена")]
        !yes in reset_pending: call POST {AGENT_BASE_URL}/reset via httpx; if 404 or connection error → "Reset endpoint недоступен. Обратитесь к администратору."; else "Контекст сброшен."; clear reset_pending
        !no in reset_pending: clear reset_pending, return [OutgoingMessage("Отменено.")]
        !save имя in reset_pending: delegate to save logic, then POST /reset (same fallback)
        Reset_pending check must happen BEFORE pending_confirm in handler priority — implement by checking reset_pending in the !yes and !no handlers (make_handle_confirm must check reset_pending first)
    - make_handle_context(store, prototype_state) -> handler:
        reads current_session from prototype_state._current_session dict (keyed by user_id) if it exists
        reads tokens = await prototype_state.get_last_tokens_used(event.user_id)
        reads sessions = await prototype_state.list_saved_sessions(event.user_id)
        formats: "Контекст:\n  Сессия: {name or 'не загружена'}\n  Токены (последний ответ): {tokens}\n  Сохранения ({len}):\n  {list}"
        returns [OutgoingMessage(chat_id=..., text=formatted)]
    - sdk/real.py: after the final yield in stream_message, call await self._prototype_state.set_last_tokens_used(user_id, self._agent_api.last_tokens_used) — needs prototype_state reference already present in RealPlatformClient
    - PrototypeStateStore gets one more dict: self._current_session: dict[str, str] = {} and methods get_current_session(user_id) -> str | None, set_current_session(user_id, name) -> None
    - register_matrix_handlers() updated to accept agent_api and agent_base_url params; registers save/load/reset/context
  </behavior>

  <action>
1. Add to sdk/prototype_state.py __init__: `self._current_session: dict[str, str] = {}`
   Add methods:
   ```python
   async def get_current_session(self, user_id: str) -> str | None:
       return self._current_session.get(user_id)

   async def set_current_session(self, user_id: str, name: str) -> None:
       self._current_session[user_id] = name
   ```

2. Create adapter/matrix/handlers/context_commands.py:

```python
from __future__ import annotations

import os
from datetime import UTC, datetime
from typing import TYPE_CHECKING

import httpx
import structlog

from core.protocol import IncomingCommand, OutgoingEvent, OutgoingMessage

if TYPE_CHECKING:
    from lambda_agent_api.agent_api import AgentApi
    from sdk.prototype_state import PrototypeStateStore
    from core.store import StateStore

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}"
)


def make_handle_save(agent_api: "AgentApi", store: "StateStore", prototype_state: "PrototypeStateStore"):
    async def handle_save(
        event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
    ) -> list[OutgoingEvent]:
        if event.args:
            name = event.args[0]
        else:
            name = f"context-{datetime.now(UTC).strftime('%Y%m%d-%H%M%S')}"

        prompt = SAVE_PROMPT.format(name=name)
        try:
            await platform.send_message(event.user_id, event.chat_id, prompt)
        except Exception as exc:
            logger.warning("save_agent_call_failed", error=str(exc))
            return [OutgoingMessage(chat_id=event.chat_id, text=f"Ошибка при сохранении: {exc}")]

        await prototype_state.add_saved_session(event.user_id, name)
        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]:
        from adapter.matrix.store import set_load_pending

        sessions = await prototype_state.list_saved_sessions(event.user_id)
        if not sessions:
            return [OutgoingMessage(
                chat_id=event.chat_id,
                text="Нет сохранённых сессий. Используй !save [имя].",
            )]

        lines = ["Сохранённые сессии:"]
        for i, s in enumerate(sessions, start=1):
            created = s.get("created_at", "")[:10]
            lines.append(f"  {i}. {s['name']} ({created})")
        lines.append("\nВведи номер или 0 / !cancel для отмены.")
        display = "\n".join(lines)

        await set_load_pending(store, event.user_id, event.chat_id, {"saves": sessions})
        return [OutgoingMessage(chat_id=event.chat_id, text=display)]

    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]:
        from adapter.matrix.store import set_reset_pending

        await set_reset_pending(store, event.user_id, event.chat_id, {"active": True})
        text = (
            "Сбросить контекст агента? Выбери:\n"
            "  !yes — сбросить\n"
            "  !save [имя] — сохранить и сбросить\n"
            "  !no — отмена"
        )
        return [OutgoingMessage(chat_id=event.chat_id, text=text)]

    return handle_reset


async def _call_reset_endpoint(agent_base_url: str, chat_id: str) -> list[OutgoingEvent]:
    try:
        async with httpx.AsyncClient() as http:
            resp = await http.post(f"{agent_base_url}/reset", timeout=5.0)
        if resp.status_code == 404:
            return [OutgoingMessage(chat_id=chat_id, text="Reset endpoint недоступен. Обратитесь к администратору.")]
        return [OutgoingMessage(chat_id=chat_id, text="Контекст сброшен.")]
    except (httpx.ConnectError, httpx.TimeoutException) as exc:
        logger.warning("reset_endpoint_unreachable", error=str(exc))
        return [OutgoingMessage(chat_id=chat_id, text="Reset endpoint недоступен. Обратитесь к администратору.")]


def make_handle_context(store: "StateStore", prototype_state: "PrototypeStateStore"):
    async def handle_context(
        event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
    ) -> list[OutgoingEvent]:
        session_name = await prototype_state.get_current_session(event.user_id) or "не загружена"
        tokens = await prototype_state.get_last_tokens_used(event.user_id)
        sessions = await prototype_state.list_saved_sessions(event.user_id)

        lines = [
            "Контекст:",
            f"  Сессия: {session_name}",
            f"  Токены (последний ответ): {tokens}",
            f"  Сохранения ({len(sessions)}):",
        ]
        for s in sessions:
            created = s.get("created_at", "")[:10]
            lines.append(f"    • {s['name']} ({created})")
        if not sessions:
            lines.append("    (нет)")

        return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))]

    return handle_context
```

3. Edit adapter/matrix/handlers/__init__.py:
   - Add import at top: `from adapter.matrix.handlers.context_commands import make_handle_save, make_handle_load, make_handle_reset, make_handle_context`
   - Change signature: `def register_matrix_handlers(dispatcher, client=None, store=None, agent_api=None, prototype_state=None, agent_base_url="http://127.0.0.1:8000") -> None:`
   - Add at bottom of function before the last line:
     ```python
     if agent_api is not None and prototype_state is not None:
         dispatcher.register(IncomingCommand, "save",    make_handle_save(agent_api, store, prototype_state))
         dispatcher.register(IncomingCommand, "load",    make_handle_load(store, prototype_state))
         dispatcher.register(IncomingCommand, "reset",   make_handle_reset(store, agent_base_url))
         dispatcher.register(IncomingCommand, "context", make_handle_context(store, prototype_state))
     ```

4. Edit adapter/matrix/bot.py:
   a. Add imports: `from adapter.matrix.store import get_load_pending, clear_load_pending, get_reset_pending, clear_reset_pending`
   b. In build_event_dispatcher() and build_runtime(), extract prototype_state from platform if RealPlatformClient, otherwise create new one:
      In build_runtime() after creating platform:
      ```python
      prototype_state = getattr(platform, "_prototype_state", None)
      agent_api = getattr(platform, "_agent_api", None)
      agent_base_url = os.environ.get("AGENT_BASE_URL", "http://127.0.0.1:8000")
      ```
      Pass these to register_matrix_handlers:
      ```python
      register_matrix_handlers(dispatcher, client=client, store=store,
                               agent_api=agent_api, prototype_state=prototype_state,
                               agent_base_url=agent_base_url)
      ```
   c. In MatrixBot.on_room_message(), before `incoming = from_room_event(...)`:
      ```python
      sender = getattr(event, "sender", None)
      # !load numeric interception
      load_pending = await get_load_pending(self.runtime.store, sender, room.room_id)
      if load_pending is not None:
          text = getattr(event, "body", "").strip()
          if text.isdigit() or text == "0" or text == "!cancel":
              outgoing = await self._handle_load_selection(
                  sender, room.room_id, text, load_pending
              )
              await self._send_all(room.room_id, outgoing)
              return
      ```
   d. Add _handle_load_selection method to MatrixBot:
      ```python
      async def _handle_load_selection(
          self, user_id: str, room_id: str, text: str, pending: dict
      ) -> list[OutgoingEvent]:
          from adapter.matrix.store import clear_load_pending
          saves = pending.get("saves", [])
          if text == "0" or text == "!cancel":
              await clear_load_pending(self.runtime.store, user_id, room_id)
              return [OutgoingMessage(chat_id=room_id, text="Отменено.")]
          idx = int(text) - 1
          if idx < 0 or idx >= len(saves):
              return [OutgoingMessage(chat_id=room_id, text=f"Неверный номер. Введи от 1 до {len(saves)} или 0 для отмены.")]
          name = saves[idx]["name"]
          await clear_load_pending(self.runtime.store, user_id, room_id)
          prototype_state = getattr(self.runtime.platform, "_prototype_state", None)
          if prototype_state is not None:
              await prototype_state.set_current_session(user_id, name)
          prompt = f"Load context from /workspace/contexts/{name}.md and use it as background for our conversation. Reply: Loaded: {name}"
          try:
              await self.runtime.platform.send_message(user_id, room_id, prompt)
          except Exception as exc:
              logger.warning("load_agent_call_failed", error=str(exc))
              return [OutgoingMessage(chat_id=room_id, text=f"Ошибка при загрузке: {exc}")]
          return [OutgoingMessage(chat_id=room_id, text=f"Загрузка: {name}")]
      ```
   e. In MatrixBot.on_room_message(), also add reset_pending check for !yes/!no/!save commands:
      In the block after load_pending check, before calling dispatcher.dispatch:
      ```python
      # !reset pending interception for !yes, !no, !save commands
      reset_pending = await get_reset_pending(self.runtime.store, sender, room.room_id)
      if reset_pending is not None:
          body = getattr(event, "body", "").strip()
          if body == "!yes" or body.startswith("!save ") or body == "!no":
              outgoing = await self._handle_reset_selection(sender, room.room_id, body)
              await self._send_all(room.room_id, outgoing)
              return
      ```
   f. Add _handle_reset_selection method to MatrixBot:
      ```python
      async def _handle_reset_selection(
          self, user_id: str, room_id: str, text: str
      ) -> list[OutgoingEvent]:
          from adapter.matrix.store import clear_reset_pending
          from adapter.matrix.handlers.context_commands import _call_reset_endpoint
          agent_base_url = os.environ.get("AGENT_BASE_URL", "http://127.0.0.1:8000")
          await clear_reset_pending(self.runtime.store, user_id, room_id)
          if text == "!no":
              return [OutgoingMessage(chat_id=room_id, text="Отменено.")]
          if text.startswith("!save "):
              name = text[len("!save "):].strip()
              prototype_state = getattr(self.runtime.platform, "_prototype_state", None)
              prompt = f"Summarize our conversation and save to /workspace/contexts/{name}.md. Reply only with: Saved: {name}"
              try:
                  await self.runtime.platform.send_message(user_id, room_id, prompt)
                  if prototype_state:
                      await prototype_state.add_saved_session(user_id, name)
              except Exception as exc:
                  logger.warning("save_before_reset_failed", error=str(exc))
          return await _call_reset_endpoint(agent_base_url, room_id)
      ```

5. Edit sdk/real.py — in stream_message(), after the final yield, add:
   ```python
   await self._prototype_state.set_last_tokens_used(user_id, self._agent_api.last_tokens_used)
   ```
   (This must come after `yield MessageChunk(finished=True, ...)` — use a local variable to store tokens_used before yield, then call set_last_tokens_used after the generator resumes.)
   Actually: put it before the final yield:
   ```python
   await self._prototype_state.set_last_tokens_used(user_id, self._agent_api.last_tokens_used)
   yield MessageChunk(
       message_id=user_id,
       delta="",
       finished=True,
       tokens_used=self._agent_api.last_tokens_used,
   )
   ```

6. Create tests/adapter/matrix/test_context_commands.py:

```python
from __future__ import annotations

from typing import AsyncIterator
from unittest.mock import AsyncMock, patch

import pytest

from adapter.matrix.bot import MatrixBot, build_runtime
from core.protocol import IncomingCommand, OutgoingMessage
from sdk.mock import MockPlatformClient
from sdk.prototype_state import PrototypeStateStore


def make_runtime_with_prototype_state():
    proto = PrototypeStateStore()
    platform = MockPlatformClient()
    # Inject prototype_state into platform so handlers can find it
    platform._prototype_state = proto
    runtime = build_runtime(platform=platform)
    return runtime, proto


@pytest.mark.asyncio
async def test_save_command_auto_name_records_session():
    proto = PrototypeStateStore()
    platform = MockPlatformClient()
    platform._prototype_state = proto

    from adapter.matrix.handlers.context_commands import make_handle_save
    from core.store import InMemoryStore

    store = InMemoryStore()
    handler = make_handle_save(agent_api=None, store=store, prototype_state=proto)

    event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!room:example", command="save", args=[])

    class FakePlatform:
        async def send_message(self, *a, **kw): pass

    result = await handler(event, None, FakePlatform(), None, None)
    assert any(isinstance(r, OutgoingMessage) and "Сохранение запущено" in r.text for r in result)
    sessions = await proto.list_saved_sessions("u1")
    assert len(sessions) == 1
    assert sessions[0]["name"].startswith("context-")


@pytest.mark.asyncio
async def test_save_command_with_name_uses_given_name():
    proto = PrototypeStateStore()
    from adapter.matrix.handlers.context_commands import make_handle_save
    from core.store import InMemoryStore

    store = InMemoryStore()
    handler = make_handle_save(agent_api=None, store=store, prototype_state=proto)

    event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!r:e", command="save", args=["my-session"])

    class FakePlatform:
        async def send_message(self, *a, **kw): pass

    await handler(event, None, FakePlatform(), None, None)
    sessions = await proto.list_saved_sessions("u1")
    assert sessions[0]["name"] == "my-session"


@pytest.mark.asyncio
async def test_load_command_shows_numbered_list():
    proto = PrototypeStateStore()
    await proto.add_saved_session("u1", "session-A")
    await proto.add_saved_session("u1", "session-B")

    from adapter.matrix.handlers.context_commands import make_handle_load
    from core.store import InMemoryStore

    store = InMemoryStore()
    handler = make_handle_load(store=store, prototype_state=proto)
    event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!r:e", command="load", args=[])

    result = await handler(event, None, None, None, None)
    assert len(result) == 1
    text = result[0].text
    assert "1." in text and "session-A" in text
    assert "2." in text and "session-B" in text
    assert "0" in text


@pytest.mark.asyncio
async def test_load_command_empty_sessions():
    proto = PrototypeStateStore()
    from adapter.matrix.handlers.context_commands import make_handle_load
    from core.store import InMemoryStore

    store = InMemoryStore()
    handler = make_handle_load(store=store, prototype_state=proto)
    event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!r:e", command="load", args=[])

    result = await handler(event, None, None, None, None)
    assert "Нет сохранённых сессий" in result[0].text


@pytest.mark.asyncio
async def test_reset_command_shows_dialog():
    proto = PrototypeStateStore()
    from adapter.matrix.handlers.context_commands import make_handle_reset
    from core.store import InMemoryStore

    store = InMemoryStore()
    handler = make_handle_reset(store=store, agent_base_url="http://127.0.0.1:8000")
    event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!r:e", command="reset", args=[])

    result = await handler(event, None, None, None, None)
    text = result[0].text
    assert "!yes" in text
    assert "!save" in text
    assert "!no" in text


@pytest.mark.asyncio
async def test_reset_yes_reports_unavailable_when_endpoint_missing():
    from adapter.matrix.handlers.context_commands import _call_reset_endpoint

    with patch("httpx.AsyncClient") as MockClient:
        import httpx
        MockClient.return_value.__aenter__ = AsyncMock(return_value=MockClient.return_value)
        MockClient.return_value.__aexit__ = AsyncMock(return_value=False)
        MockClient.return_value.post = AsyncMock(side_effect=httpx.ConnectError("refused"))

        result = await _call_reset_endpoint("http://127.0.0.1:8000", "!r:e")
        assert "недоступен" in result[0].text


@pytest.mark.asyncio
async def test_context_command_shows_snapshot():
    proto = PrototypeStateStore()
    await proto.set_last_tokens_used("u1", 99)
    await proto.add_saved_session("u1", "my-save")

    from adapter.matrix.handlers.context_commands import make_handle_context
    from core.store import InMemoryStore

    store = InMemoryStore()
    handler = make_handle_context(store=store, prototype_state=proto)
    event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!r:e", command="context", args=[])

    result = await handler(event, None, None, None, None)
    text = result[0].text
    assert "99" in text
    assert "my-save" in text
    assert "не загружена" in text
```
  </action>

  <verify>
    <automated>cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m pytest tests/adapter/matrix/test_context_commands.py tests/platform/test_prototype_state.py -v 2>&1 | tail -20</automated>
  </verify>

  <done>
    - adapter/matrix/handlers/context_commands.py exists with make_handle_save, make_handle_load, make_handle_reset, make_handle_context, _call_reset_endpoint
    - register_matrix_handlers() signature accepts agent_api, prototype_state, agent_base_url; registers save/load/reset/context handlers when agent_api is not None
    - MatrixBot.on_room_message() checks load_pending and reset_pending before dispatcher.dispatch
    - sdk/real.py calls set_last_tokens_used before final yield
    - All tests in test_context_commands.py pass
    - Full test suite still passes: `pytest tests/ -v` exits 0
  </done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Matrix user → command args | !save [name] arg is user-controlled; used in file paths |
| bot → agent (save/load prompts) | Prompt text contains user-supplied name |
| bot → POST /reset endpoint | HTTP call to AGENT_BASE_URL (internal service) |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-02-01 | Tampering | !save name arg used in /workspace/contexts/{name}.md path | mitigate | Sanitize name: only allow [a-zA-Z0-9_-] characters; reject path traversal attempts (names containing "/" or "..") |
| T-04-02-02 | Information Disclosure | !context exposes tokens_used and session names | accept | Single-user prototype; data is the user's own |
| T-04-02-03 | Denial of Service | !load numeric interception could loop if load_pending never clears | mitigate | clear_load_pending on selection (any) or disconnect; pending data is volatile in-memory |
| T-04-02-04 | Spoofing | !yes intercepted by reset_pending could conflict with pending_confirm | mitigate | Reset_pending check in on_room_message before dispatcher — takes priority; documented in code comment |
| T-04-02-05 | Tampering | POST /reset to AGENT_BASE_URL | accept | Internal service URL from env; timeout=5.0 prevents hanging |
</threat_model>

<verification>
Run full suite after both tasks:

```bash
cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m pytest tests/ -v 2>&1 | tail -30
```

Grep checks:
```bash
# Handlers registered
grep "save\|load\|reset\|context" adapter/matrix/handlers/__init__.py

# Numeric interception in bot
grep "get_load_pending\|_handle_load_selection" adapter/matrix/bot.py

# tokens tracking in real.py
grep "set_last_tokens_used" sdk/real.py

# context_commands module
ls adapter/matrix/handlers/context_commands.py
```
</verification>

<success_criteria>
- `pytest tests/adapter/matrix/test_context_commands.py -v` exits 0 with 7+ tests passing
- `pytest tests/platform/test_prototype_state.py -v` exits 0 (including 4 new tests)
- `pytest tests/ -v` exits 0
- !save, !load, !reset, !context all registered in register_matrix_handlers
- load_pending and reset_pending helpers exist in adapter/matrix/store.py
- MatrixBot.on_room_message contains numeric interception for !load
</success_criteria>

<output>
After completion, create `.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-SUMMARY.md`
</output>
