diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 175285d..9f3eba8 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -50,6 +50,18 @@ Plans:
- `stream_message` работает с реальным стримингом
- Интеграционные тесты с реальным SDK (или staging)
+### Phase 4: Matrix MVP: shared agent context and context management commands
+
+**Goal:** Привести Matrix-бот к рабочему состоянию для MVP-деплоя: заменить AgentSessionClient на AgentApi, добавить !save/!load/!reset/!context команды управления контекстом агента, упаковать в Docker.
+**Requirements**: Replace AgentSessionClient with AgentApi; Wire AgentApi lifecycle; Implement !save, !load, !reset, !context commands; Dockerfile + docker-compose
+**Depends on:** Phase 1 (Matrix adapter complete)
+**Plans:** 3 plans
+
+Plans:
+- [ ] 04-01-PLAN.md — Replace AgentSessionClient with AgentApi; update sdk/real.py, bot.py, broken tests
+- [ ] 04-02-PLAN.md — !save, !load, !reset, !context handlers; PrototypeStateStore extensions; numeric interception
+- [ ] 04-03-PLAN.md — Dockerfile + docker-compose.yml + .env.example update
+
---
### Phase 3: Production Hardening
diff --git a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-01-PLAN.md b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-01-PLAN.md
new file mode 100644
index 0000000..702a3e6
--- /dev/null
+++ b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-01-PLAN.md
@@ -0,0 +1,540 @@
+---
+phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - sdk/agent_session.py
+ - sdk/real.py
+ - adapter/matrix/bot.py
+ - tests/platform/test_agent_session.py
+ - tests/platform/test_real.py
+ - tests/adapter/matrix/test_dispatcher.py
+autonomous: true
+requirements:
+ - Replace AgentSessionClient with AgentApi
+ - Wire AgentApi lifecycle into MatrixBot
+
+must_haves:
+ truths:
+ - "RealPlatformClient uses AgentApi, not AgentSessionClient"
+ - "AgentApi is connected before sync_forever and closed in finally block of main()"
+ - "build_thread_key and AgentSessionClient are gone from sdk/"
+ - "stream_message() yields MessageChunk objects including a final chunk with tokens_used from last_tokens_used"
+ - "AGENT_WS_URL is used unchanged (no thread_id query param)"
+ - "MATRIX_PLATFORM_BACKEND=real still works end-to-end without test crash"
+ - "All existing tests pass after the swap"
+ artifacts:
+ - path: "sdk/real.py"
+ provides: "RealPlatformClient wrapping AgentApi"
+ contains: "AgentApi"
+ - path: "adapter/matrix/bot.py"
+ provides: "main() awaits agent_api.connect() and agent_api.close()"
+ contains: "agent_api.connect"
+ - path: "tests/platform/test_real.py"
+ provides: "Updated tests using FakeAgentApi instead of FakeAgentSessionClient"
+ key_links:
+ - from: "adapter/matrix/bot.py main()"
+ to: "RealPlatformClient._agent_api"
+ via: "runtime.platform.agent_api property"
+ pattern: "agent_api\\.connect"
+ - from: "sdk/real.py stream_message()"
+ to: "agent_api.last_tokens_used"
+ via: "attribute read after async-for loop"
+ pattern: "last_tokens_used"
+---
+
+
+Replace the custom per-request AgentSessionClient with the persistent AgentApi from
+lambda_agent_api. Remove build_thread_key and AgentSessionClient entirely. Wire
+AgentApi connect/close into bot.py main(). Update all tests that referenced the
+old client.
+
+Purpose: The existing AgentSessionClient creates a new WebSocket per message and
+injects thread_id into the URL — both incompatible with origin/main platform-agent.
+AgentApi maintains a single persistent WS connection managed via connect()/close()
+and exposes send_message() as an AsyncIterator.
+
+Output: sdk/real.py, sdk/agent_session.py (deleted/emptied), adapter/matrix/bot.py
+updated, tests green.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.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
+
+
+
+
+
+From external/platform-agent_api/lambda_agent_api/agent_api.py:
+```python
+class AgentApi:
+ def __init__(self, agent_id: str, url: str,
+ callback=None, on_disconnect=None): ...
+ async def connect(self) -> None: ... # opens WS, awaits MsgStatus, starts _listen task
+ async def close(self) -> None: ... # cancels _listen, closes WS+session
+ async def send_message(self, text: str) -> AsyncIterator[AgentEventUnion]:
+ # yields MsgEventTextChunk only; breaks on MsgEventEnd (does NOT yield it)
+ # MsgEventEnd.tokens_used is consumed internally but NOT stored — executor
+ # MUST add self.last_tokens_used: int = 0 to AgentApi and set it at the
+ # break point, OR store it in a thin wrapper on RealPlatformClient.
+ ...
+ # AgentEventUnion = Union[MsgEventTextChunk, MsgEventEnd] per server.py
+```
+
+From external/platform-agent_api/lambda_agent_api/server.py:
+```python
+class MsgEventTextChunk(BaseModel):
+ type: Literal[EServerMessage.AGENT_EVENT_TEXT_CHUNK]
+ text: str
+
+class MsgEventEnd(BaseModel):
+ type: Literal[EServerMessage.AGENT_EVENT_END]
+ tokens_used: int
+```
+
+From sdk/interface.py (unchanged):
+```python
+class MessageChunk(BaseModel):
+ message_id: str
+ delta: str
+ finished: bool
+ tokens_used: int = 0
+
+class PlatformClient(Protocol):
+ async def send_message(self, user_id, chat_id, text, attachments=None) -> MessageResponse: ...
+ async def stream_message(self, user_id, chat_id, text, attachments=None) -> AsyncIterator[MessageChunk]: ...
+```
+
+
+
+
+
+ Task 1: Replace AgentSessionClient with AgentApi in sdk/real.py, delete sdk/agent_session.py, patch tokens_used capture
+
+
+ - sdk/real.py (full file — being replaced)
+ - sdk/agent_session.py (full file — being deleted)
+ - external/platform-agent_api/lambda_agent_api/agent_api.py (lines 134–216 — send_message generator + finally block)
+ - sdk/interface.py (MessageChunk, PlatformClient Protocol)
+
+
+ sdk/real.py, sdk/agent_session.py, external/platform-agent_api/lambda_agent_api/agent_api.py
+
+
+ - RealPlatformClient.__init__ accepts agent_api: AgentApi (not AgentSessionClient), prototype_state: PrototypeStateStore, platform: str = "matrix"
+ - RealPlatformClient exposes agent_api as property self.agent_api so bot.py main() can call connect/close
+ - stream_message() iterates agent_api.send_message(text) yielding MessageChunk per MsgEventTextChunk chunk; after loop yields final MessageChunk(finished=True, delta="", tokens_used=agent_api.last_tokens_used)
+ - send_message() collects all chunks from stream_message() and returns MessageResponse
+ - No thread_key, no build_thread_key references anywhere in sdk/real.py
+ - AgentApi.last_tokens_used: int = 0 added as instance attribute in __init__; set inside send_message() generator at the "if isinstance(chunk, MsgEventEnd): break" line — change that line to "self.last_tokens_used = chunk.tokens_used; break"
+ - sdk/agent_session.py: delete file contents and replace with single comment "# Deleted in Phase 4 — replaced by AgentApi from lambda_agent_api" (keep file to avoid import errors in test_real.py until tests are updated in Task 2)
+
+
+
+1. Edit external/platform-agent_api/lambda_agent_api/agent_api.py:
+ - In __init__: add `self.last_tokens_used: int = 0`
+ - In send_message() at line ~172 (`if isinstance(chunk, MsgEventEnd): break`):
+ replace with:
+ ```python
+ if isinstance(chunk, MsgEventEnd):
+ self.last_tokens_used = chunk.tokens_used
+ break
+ ```
+
+2. Rewrite sdk/real.py entirely:
+```python
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, AsyncIterator
+
+from sdk.interface import Attachment, MessageChunk, MessageResponse, PlatformClient, User, UserSettings
+from sdk.prototype_state import PrototypeStateStore
+
+if TYPE_CHECKING:
+ from lambda_agent_api.agent_api import AgentApi
+
+
+class RealPlatformClient(PlatformClient):
+ def __init__(
+ self,
+ agent_api: "AgentApi",
+ prototype_state: PrototypeStateStore,
+ platform: str = "matrix",
+ ) -> None:
+ self._agent_api = agent_api
+ self._prototype_state = prototype_state
+ self._platform = platform
+
+ @property
+ def agent_api(self) -> "AgentApi":
+ return self._agent_api
+
+ 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:
+ parts: list[str] = []
+ tokens_used = 0
+ async for chunk in self.stream_message(user_id, chat_id, text, attachments):
+ if chunk.delta:
+ parts.append(chunk.delta)
+ if chunk.finished:
+ tokens_used = chunk.tokens_used
+ return MessageResponse(
+ message_id=user_id,
+ response="".join(parts),
+ tokens_used=tokens_used,
+ finished=True,
+ )
+
+ async def stream_message(
+ self,
+ user_id: str,
+ chat_id: str,
+ text: str,
+ attachments: list[Attachment] | None = None,
+ ) -> AsyncIterator[MessageChunk]:
+ from lambda_agent_api.server import MsgEventTextChunk
+ async for event in self._agent_api.send_message(text):
+ if isinstance(event, MsgEventTextChunk):
+ yield MessageChunk(
+ message_id=user_id,
+ delta=event.text,
+ finished=False,
+ )
+ yield MessageChunk(
+ message_id=user_id,
+ delta="",
+ finished=True,
+ tokens_used=self._agent_api.last_tokens_used,
+ )
+
+ 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)
+```
+
+3. Replace sdk/agent_session.py content with:
+```python
+# Deleted in Phase 4 — replaced by AgentApi from lambda_agent_api
+# File kept as stub to avoid import errors during migration; remove after test_agent_session.py is updated.
+```
+
+
+
+ cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -c "from sdk.real import RealPlatformClient; print('import ok')"
+
+
+
+ - sdk/real.py imports AgentApi (not AgentSessionClient), exposes self.agent_api property
+ - sdk/real.py stream_message yields final chunk with tokens_used from agent_api.last_tokens_used
+ - agent_api.py __init__ has self.last_tokens_used = 0 and send_message sets it before break
+ - sdk/agent_session.py contains only a comment stub (no class definitions)
+ - `python -c "from sdk.real import RealPlatformClient"` exits 0
+
+
+
+
+ Task 2: Wire AgentApi lifecycle into bot.py main(); update all broken tests
+
+
+ - adapter/matrix/bot.py (full file — _build_platform_from_env and main() need changes)
+ - tests/platform/test_agent_session.py (full file — delete or rewrite)
+ - tests/platform/test_real.py (full file — FakeAgentSessionClient → FakeAgentApi)
+ - tests/adapter/matrix/test_dispatcher.py (test_build_runtime_uses_real_platform — needs update)
+
+
+ adapter/matrix/bot.py, tests/platform/test_agent_session.py, tests/platform/test_real.py, tests/adapter/matrix/test_dispatcher.py
+
+
+ - _build_platform_from_env() returns a RealPlatformClient with an unconnected AgentApi (connect() NOT called here — called in main())
+ - main() calls await runtime.platform.agent_api.connect() after build_runtime() (only when backend is "real"; mock has no agent_api); wrap in `if hasattr(runtime.platform, "agent_api")` guard
+ - main() finally block: await agent_api.close() before await client.close()
+ - AGENT_WS_URL env var is passed unchanged to AgentApi(url=ws_url) — no query param manipulation
+ - test_agent_session.py: completely rewritten — remove all build_thread_key tests, remove AgentSessionClient tests, remove process_message tests (those tested our platform-agent patch which is being discarded); replace with 2 tests: (1) import check for lambda_agent_api module, (2) stub test that documents the deletion
+ - test_real.py: FakeAgentSessionClient replaced with FakeAgentApi that has send_message(text: str) -> AsyncIterator and last_tokens_used: int = 0; tests updated to construct RealPlatformClient(agent_api=FakeAgentApi(), prototype_state=PrototypeStateStore()); test_send_message no longer checks thread_key in message_id (now uses user_id); test_stream_message checks final chunk tokens_used comes from FakeAgentApi.last_tokens_used
+ - test_dispatcher.py: test_build_runtime_uses_real_platform_when_matrix_backend_is_real must NOT call agent_api.connect() (build_runtime only constructs, does not connect); update test to mock AgentApi so it does not attempt a real WS connection; assert isinstance(runtime.platform, RealPlatformClient) still passes
+
+
+
+1. Edit adapter/matrix/bot.py:
+
+ a. Remove imports: `from sdk.agent_session import AgentSessionClient, AgentSessionConfig`
+
+ b. Add import at top: `import sys; sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "external" / "platform-agent_api"))` — NO, instead add lambda_agent_api to sys.path only in bot.py startup, or better: install the package. In _build_platform_from_env(), do a lazy import:
+ ```python
+ def _build_platform_from_env() -> PlatformClient:
+ backend = os.environ.get("MATRIX_PLATFORM_BACKEND", "mock").strip().lower()
+ if backend == "real":
+ import sys
+ _api_root = Path(__file__).resolve().parents[2] / "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
+ ws_url = os.environ["AGENT_WS_URL"]
+ agent_api = AgentApi(agent_id="matrix-bot", url=ws_url)
+ return RealPlatformClient(
+ agent_api=agent_api,
+ prototype_state=PrototypeStateStore(),
+ platform="matrix",
+ )
+ return MockPlatformClient()
+ ```
+
+ c. In main(), after `runtime = build_runtime(store=SQLiteStore(db_path), client=client)`, add:
+ ```python
+ if hasattr(runtime.platform, "agent_api"):
+ await runtime.platform.agent_api.connect()
+ ```
+
+ d. In main() finally block, add before `await client.close()`:
+ ```python
+ if hasattr(runtime.platform, "agent_api"):
+ await runtime.platform.agent_api.close()
+ ```
+
+2. Rewrite tests/platform/test_agent_session.py:
+```python
+"""
+test_agent_session.py — stub after Phase 4 migration.
+
+AgentSessionClient and build_thread_key were removed in Phase 4.
+The platform client is now AgentApi from lambda_agent_api.
+See tests/platform/test_real.py for RealPlatformClient tests.
+"""
+import sys
+from pathlib import Path
+
+_api_root = Path(__file__).resolve().parents[2] / "external" / "platform-agent_api"
+if str(_api_root) not in sys.path:
+ sys.path.insert(0, str(_api_root))
+
+
+def test_lambda_agent_api_module_importable():
+ from lambda_agent_api.agent_api import AgentApi # noqa: F401
+ from lambda_agent_api.server import MsgEventTextChunk, MsgEventEnd # noqa: F401
+ assert True
+
+
+def test_agent_session_module_is_stub():
+ """Ensure old module no longer exposes AgentSessionClient or build_thread_key."""
+ import sdk.agent_session as mod
+ assert not hasattr(mod, "AgentSessionClient"), "AgentSessionClient should be removed"
+ assert not hasattr(mod, "build_thread_key"), "build_thread_key should be removed"
+```
+
+3. Rewrite tests/platform/test_real.py:
+```python
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+from typing import AsyncIterator
+
+import pytest
+
+from core.protocol import SettingsAction
+from sdk.interface import MessageChunk, MessageResponse, UserSettings
+from sdk.prototype_state import PrototypeStateStore
+from sdk.real import RealPlatformClient
+
+_api_root = Path(__file__).resolve().parents[2] / "external" / "platform-agent_api"
+if str(_api_root) not in sys.path:
+ sys.path.insert(0, str(_api_root))
+
+from lambda_agent_api.server import MsgEventTextChunk, EServerMessage # noqa: E402
+
+
+class FakeAgentApi:
+ """Minimal fake for AgentApi — no real WebSocket."""
+ def __init__(self) -> None:
+ self.last_tokens_used: int = 0
+ self.send_calls: list[str] = []
+
+ async def send_message(self, text: str) -> AsyncIterator[MsgEventTextChunk]:
+ self.send_calls.append(text)
+ self.last_tokens_used = 7
+ yield MsgEventTextChunk(type=EServerMessage.AGENT_EVENT_TEXT_CHUNK, text=text[:2])
+ yield MsgEventTextChunk(type=EServerMessage.AGENT_EVENT_TEXT_CHUNK, text=text[2:])
+ # send_message() in real AgentApi breaks on MsgEventEnd without yielding it;
+ # FakeAgentApi mirrors this by not yielding MsgEventEnd — last_tokens_used is set directly.
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_get_or_create_user_uses_local_state():
+ client = RealPlatformClient(
+ agent_api=FakeAgentApi(),
+ prototype_state=PrototypeStateStore(),
+ )
+ 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_calls_agent_with_text():
+ fake = FakeAgentApi()
+ client = RealPlatformClient(agent_api=fake, prototype_state=PrototypeStateStore())
+
+ result = await client.send_message("@alice:example.org", "C1", "hello")
+
+ assert result.response == "hello"
+ assert result.tokens_used == 7
+ assert fake.send_calls == ["hello"]
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_stream_message_yields_chunks_and_final_with_tokens():
+ fake = FakeAgentApi()
+ client = RealPlatformClient(agent_api=fake, prototype_state=PrototypeStateStore())
+
+ chunks = []
+ async for chunk in client.stream_message("@alice:example.org", "C1", "hello"):
+ chunks.append(chunk)
+
+ assert chunks[-1].finished is True
+ assert chunks[-1].tokens_used == 7
+ assert "".join(c.delta for c in chunks) == "hello"
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_settings_are_local():
+ client = RealPlatformClient(
+ agent_api=FakeAgentApi(),
+ prototype_state=PrototypeStateStore(),
+ )
+ 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
+```
+
+4. Edit tests/adapter/matrix/test_dispatcher.py — update `test_build_runtime_uses_real_platform_when_matrix_backend_is_real`:
+ - Add sys.path setup for lambda_agent_api (same pattern as above)
+ - Mock AgentApi so it does not open a real WS:
+ ```python
+ async def test_build_runtime_uses_real_platform_when_matrix_backend_is_real(monkeypatch):
+ import sys
+ from pathlib import Path
+ _api_root = Path(__file__).resolve().parents[3] / "external" / "platform-agent_api"
+ if str(_api_root) not in sys.path:
+ sys.path.insert(0, str(_api_root))
+
+ monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real")
+ monkeypatch.setenv("AGENT_WS_URL", "ws://agent.example/agent_ws/")
+
+ # Patch AgentApi to avoid real WS connection during build_runtime
+ import lambda_agent_api.agent_api as _mod
+ class _FakeAgentApi:
+ def __init__(self, agent_id, url, **kw):
+ self.last_tokens_used = 0
+ async def connect(self): pass
+ async def close(self): pass
+ async def send_message(self, text):
+ return; yield # empty async generator
+ monkeypatch.setattr(_mod, "AgentApi", _FakeAgentApi)
+
+ from adapter.matrix.bot import build_runtime
+ from sdk.real import RealPlatformClient
+ runtime = build_runtime()
+ assert isinstance(runtime.platform, RealPlatformClient)
+ ```
+
+
+
+ cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m pytest tests/platform/test_agent_session.py tests/platform/test_real.py tests/adapter/matrix/test_dispatcher.py -v 2>&1 | tail -20
+
+
+
+ - All tests in test_agent_session.py, test_real.py, test_dispatcher.py pass
+ - main() in bot.py has agent_api.connect() call guarded by hasattr check
+ - main() finally block closes agent_api before matrix client
+ - grep confirms no "AgentSessionClient" or "build_thread_key" remain in sdk/real.py or adapter/matrix/bot.py
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| bot → platform-agent WS | Outbound WS to agent service; input is user text |
+| env vars → bot config | AGENT_WS_URL, MATRIX_PLATFORM_BACKEND read from environment |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-04-01-01 | Tampering | AgentApi.send_message() text | accept | Single-user prototype; text originates from authenticated Matrix user |
+| T-04-01-02 | Denial of Service | AgentBusyException from concurrent sends | mitigate | AgentApi._request_lock already prevents concurrent sends; bot must surface error to user instead of crashing |
+| T-04-01-03 | Information Disclosure | AGENT_WS_URL in env | accept | Internal service URL; not exposed to users |
+
+
+
+Run full test suite after both tasks complete:
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m pytest tests/ -v 2>&1 | tail -30
+```
+
+Grep checks:
+```bash
+# No old imports should remain
+grep -r "AgentSessionClient\|build_thread_key" sdk/ adapter/ tests/ --include="*.py" | grep -v "stub\|Deleted\|removed"
+
+# AgentApi wired in bot.py
+grep "agent_api.connect\|agent_api.close" adapter/matrix/bot.py
+
+# last_tokens_used set in agent_api.py
+grep "last_tokens_used" external/platform-agent_api/lambda_agent_api/agent_api.py
+```
+
+
+
+- `pytest tests/platform/ tests/adapter/matrix/test_dispatcher.py -v` exits 0 with no failures
+- `grep -r "AgentSessionClient" sdk/ adapter/` returns empty (or only the stub comment)
+- `grep -r "build_thread_key" sdk/ adapter/` returns empty
+- `grep "agent_api.connect" adapter/matrix/bot.py` returns a match
+- `grep "last_tokens_used" external/platform-agent_api/lambda_agent_api/agent_api.py` returns the assignment line
+
+
+
diff --git a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-PLAN.md b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-PLAN.md
new file mode 100644
index 0000000..1b16918
--- /dev/null
+++ b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-PLAN.md
@@ -0,0 +1,865 @@
+---
+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"
+---
+
+
+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.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.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
+
+
+
+
+
+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")`
+
+
+
+
+
+ Task 1: Extend PrototypeStateStore and Matrix store with pending state helpers
+
+
+ - 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)
+
+
+ sdk/prototype_state.py, adapter/matrix/store.py, tests/platform/test_prototype_state.py
+
+
+ - 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
+
+
+
+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
+```
+
+
+
+ cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m pytest tests/platform/test_prototype_state.py -v 2>&1 | tail -15
+
+
+
+ - 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
+
+
+
+
+ Task 2: Implement context_commands.py handlers, wire into __init__.py and bot.py, update tokens_used tracking in real.py
+
+
+ - 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)
+
+
+
+ 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
+
+
+
+ - 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
+
+
+
+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
+```
+
+
+
+ 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
+
+
+
+ - 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
+
+
+
+
+
+
+## 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 |
+
+
+
+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
+```
+
+
+
+- `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
+
+
+
diff --git a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-03-PLAN.md b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-03-PLAN.md
new file mode 100644
index 0000000..06f7f1e
--- /dev/null
+++ b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-03-PLAN.md
@@ -0,0 +1,196 @@
+---
+phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma
+plan: 03
+type: execute
+wave: 2
+depends_on:
+ - 04-01-PLAN.md
+files_modified:
+ - Dockerfile
+ - docker-compose.yml
+ - .env.example
+autonomous: true
+requirements:
+ - Dockerfile for Matrix bot
+ - docker-compose.yml with matrix-bot service
+ - .env.example updated with AGENT_BASE_URL and MATRIX_PLATFORM_BACKEND
+
+must_haves:
+ truths:
+ - "Dockerfile builds successfully with python:3.11-slim base"
+ - "lambda_agent_api installed in container despite Python version constraint"
+ - "PYTHONPATH=/app set so adapter/matrix/bot.py is runnable as module"
+ - "docker-compose.yml defines matrix-bot service with env_file: .env"
+ - ".env.example contains AGENT_BASE_URL and MATRIX_PLATFORM_BACKEND=real"
+ - "CMD runs python -m adapter.matrix.bot"
+ artifacts:
+ - path: "Dockerfile"
+ provides: "Matrix bot container image"
+ contains: "python:3.11-slim"
+ - path: "docker-compose.yml"
+ provides: "Service definition for matrix-bot"
+ contains: "matrix-bot"
+ - path: ".env.example"
+ provides: "Updated env template"
+ contains: "AGENT_BASE_URL"
+ key_links:
+ - from: "Dockerfile"
+ to: "external/platform-agent_api"
+ via: "COPY + pip install with --ignore-requires-python"
+ pattern: "ignore-requires-python"
+---
+
+
+Package the Matrix bot in a Docker container. Create Dockerfile using python:3.11-slim,
+install lambda_agent_api from the local external/ directory (bypassing the Python 3.14
+version constraint), and define a docker-compose.yml for running the matrix-bot service.
+Update .env.example with new variables.
+
+Purpose: Enable reproducible MVP deployment of the Matrix bot in a container alongside
+the separately-run platform-agent.
+
+Output: Dockerfile, docker-compose.yml, updated .env.example.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.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
+
+
+
+
+
+ Task 1: Create Dockerfile and docker-compose.yml
+
+
+ - .env.example (full file — adding new vars)
+ - external/platform-agent_api/lambda_agent_api/ (ls — verify files to copy)
+ - pyproject.toml (verify uv is the package manager used)
+
+
+ Dockerfile, docker-compose.yml, .env.example
+
+
+1. Check if pyproject.toml uses uv or pip. The project uses `uv sync` per CLAUDE.md. However, in the Docker container we can use pip for simplicity since uv's lockfile-based install may need the lockfile present. Use pip for the base install of surfaces-bot deps, and install lambda_agent_api separately.
+
+ Actually: the project uses uv. Use uv in Docker to be consistent:
+ - Install uv via pip (pip install uv)
+ - Run uv sync to install project deps
+ - Install lambda_agent_api with pip --ignore-requires-python
+
+2. Create Dockerfile:
+
+```dockerfile
+FROM python:3.11-slim
+
+WORKDIR /app
+
+# Install uv
+RUN pip install --no-cache-dir uv
+
+# Copy dependency manifests first for layer caching
+COPY pyproject.toml uv.lock* ./
+
+# Install project dependencies via uv (no project install yet, just deps)
+RUN uv sync --no-install-project --frozen 2>/dev/null || uv sync --no-install-project
+
+# Copy project source
+COPY . .
+
+# Install the project itself
+RUN uv sync --frozen 2>/dev/null || uv sync
+
+# Install lambda_agent_api, bypassing Python version constraint
+RUN pip install --no-cache-dir --ignore-requires-python /app/external/platform-agent_api
+
+ENV PYTHONPATH=/app
+ENV PYTHONUNBUFFERED=1
+
+CMD ["python", "-m", "adapter.matrix.bot"]
+```
+
+3. Create docker-compose.yml:
+
+```yaml
+services:
+ matrix-bot:
+ build: .
+ env_file: .env
+ restart: unless-stopped
+ # platform-agent runs separately — not included in this compose file
+```
+
+4. Read current .env.example, then append new variables. Current file likely has MATRIX_* vars. Add:
+ - AGENT_WS_URL=ws://127.0.0.1:8000/agent_ws/
+ - AGENT_BASE_URL=http://127.0.0.1:8000
+ - MATRIX_PLATFORM_BACKEND=real
+
+ Read .env.example first to see what's there, then write the full updated file.
+
+
+
+ - `grep "python:3.11-slim" Dockerfile` returns a match
+ - `grep "ignore-requires-python" Dockerfile` returns a match (lambda_agent_api install)
+ - `grep "PYTHONPATH=/app" Dockerfile` returns a match
+ - `grep "adapter.matrix.bot" Dockerfile` returns a match (CMD)
+ - `grep "matrix-bot" docker-compose.yml` returns a match
+ - `grep "env_file" docker-compose.yml` returns a match
+ - `grep "AGENT_BASE_URL" .env.example` returns a match
+ - `grep "MATRIX_PLATFORM_BACKEND" .env.example` returns a match
+
+
+
+ grep "python:3.11-slim" /Users/a/MAI/sem2/lambda/surfaces-bot/Dockerfile && grep "ignore-requires-python" /Users/a/MAI/sem2/lambda/surfaces-bot/Dockerfile && grep "AGENT_BASE_URL" /Users/a/MAI/sem2/lambda/surfaces-bot/.env.example && echo "All checks passed"
+
+
+
+ - Dockerfile exists with python:3.11-slim, uv install, lambda_agent_api pip install --ignore-requires-python, PYTHONPATH=/app, CMD python -m adapter.matrix.bot
+ - docker-compose.yml exists with matrix-bot service, env_file: .env, restart: unless-stopped
+ - .env.example contains AGENT_WS_URL, AGENT_BASE_URL, MATRIX_PLATFORM_BACKEND=real
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| container → host env | .env file mounts secrets into container |
+| container → platform-agent | Network call to AGENT_WS_URL / AGENT_BASE_URL |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-04-03-01 | Information Disclosure | .env file with secrets mounted in container | mitigate | .env in .gitignore; .env.example committed with placeholder values only, never real secrets |
+| T-04-03-02 | Tampering | lambda_agent_api installed from local path via --ignore-requires-python | accept | Local package under version control; no external supply chain risk |
+| T-04-03-03 | Denial of Service | restart: unless-stopped could loop on crash | accept | Expected behavior; operator can `docker compose stop` |
+
+
+
+```bash
+# Verify files exist and contain expected content
+grep "python:3.11-slim" /Users/a/MAI/sem2/lambda/surfaces-bot/Dockerfile
+grep "ignore-requires-python" /Users/a/MAI/sem2/lambda/surfaces-bot/Dockerfile
+grep "AGENT_BASE_URL" /Users/a/MAI/sem2/lambda/surfaces-bot/.env.example
+grep "matrix-bot" /Users/a/MAI/sem2/lambda/surfaces-bot/docker-compose.yml
+```
+
+
+
+- Dockerfile, docker-compose.yml, .env.example all exist in project root
+- Dockerfile builds without errors when platform-agent_api dir is present (docker build . exits 0)
+- .env.example contains AGENT_BASE_URL, AGENT_WS_URL, MATRIX_PLATFORM_BACKEND
+- docker-compose.yml service named matrix-bot uses env_file: .env
+
+
+