guddion-sessions 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: guddion-sessions
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.10
5
+ Requires-Dist: fastmcp>=2.0
6
+ Provides-Extra: dev
7
+ Requires-Dist: pytest>=8.0; extra == "dev"
8
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
@@ -0,0 +1,50 @@
1
+ # guddion-sessions
2
+
3
+ Локальный MCP-сервер, который экономит токены при переключении между
4
+ несколькими сессиями Claude Code по одному и тому же проекту.
5
+
6
+ ## Зачем
7
+
8
+ Длинные сессии (особенно с сабагентами) быстро съедают контекст и лимиты.
9
+ При переключении между параллельными сессиями по одному проекту приходится
10
+ либо пересказывать контекст заново, либо заставлять новую сессию вычитывать
11
+ файлы/историю с нуля.
12
+
13
+ `guddion-sessions` раз в N сообщений мягко напоминает занести краткий итог
14
+ по темам (архитектура, бэкенд, фронтенд, решения, следующие шаги) в
15
+ rolling-статус проекта. При старте новой сессии в той же рабочей директории
16
+ этот статус автоматически подтягивается в контекст — новая сессия сразу "в
17
+ курсе", без пересказа и без повторного чтения файлов.
18
+
19
+ ## Из чего состоит
20
+
21
+ - **MCP-сервер** (`src/guddion_sessions/server.py`) — инструменты
22
+ `save_summary`, `get_status`, `get_history`, `list_topics`,
23
+ `get_settings`, `update_settings`.
24
+ - **Хранилище** (`src/guddion_sessions/storage.py`) — JSON-файлы вне
25
+ репозиториев проектов (`~/.claude/session-mcp/storage/`), ключ — путь
26
+ к рабочей директории. Rolling-статус по темам + отдельный append-лог
27
+ истории (не подтягивается в контекст автоматически).
28
+ - **Настройки** (`src/guddion_sessions/settings.py`) — интервал
29
+ напоминания, язык, дефолтные темы, вкл/выкл каждого хука. Два слоя:
30
+ глобальные (на все проекты) и по-проектные (переопределяют глобальные
31
+ для конкретного каталога).
32
+ - **Хуки Claude Code** (`src/guddion_sessions/hooks/`) —
33
+ `UserPromptSubmit` (мягкое напоминание раз в N сообщений) и
34
+ `SessionStart` (подгрузка статуса в начале новой сессии).
35
+ - **Авторизация** (`src/guddion_sessions/auth.py`) — все инструменты
36
+ MCP-сервера требуют `GUDDION_TOKEN`, проверяется один раз за время
37
+ жизни процесса через `mcp.guddion.ru/api/verify` (часть экосистемы
38
+ Guddion MCP, по образцу `guddion-web`).
39
+
40
+ ## Запуск
41
+
42
+ ```bash
43
+ python -m venv .venv
44
+ .venv/Scripts/python.exe -m pip install -e ".[dev]"
45
+ .venv/Scripts/python.exe -m pytest
46
+ ```
47
+
48
+ Подключается в `settings.json` Claude Code через `mcpServers` +
49
+ `hooks.UserPromptSubmit` + `hooks.SessionStart`, с обязательной
50
+ переменной окружения `GUDDION_TOKEN`.
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "guddion-sessions"
3
+ version = "0.1.0"
4
+ requires-python = ">=3.10"
5
+ dependencies = [
6
+ "fastmcp>=2.0",
7
+ ]
8
+
9
+ [project.optional-dependencies]
10
+ dev = [
11
+ "pytest>=8.0",
12
+ "pytest-asyncio>=0.23",
13
+ ]
14
+
15
+ [tool.pytest.ini_options]
16
+ asyncio_mode = "auto"
17
+ testpaths = ["tests"]
18
+
19
+ [build-system]
20
+ requires = ["setuptools>=68"]
21
+ build-backend = "setuptools.build_meta"
22
+
23
+ [tool.setuptools.packages.find]
24
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ from .server import mcp
2
+
3
+ if __name__ == "__main__":
4
+ mcp.run()
@@ -0,0 +1,33 @@
1
+ import os
2
+ from urllib.error import HTTPError
3
+ from urllib.request import Request, urlopen
4
+
5
+ VERIFY_URL = "https://mcp.guddion.ru/api/verify"
6
+
7
+ _verified = False
8
+
9
+
10
+ def verify_token() -> None:
11
+ global _verified
12
+ if _verified:
13
+ return
14
+
15
+ token = os.environ.get("GUDDION_TOKEN")
16
+ if not token:
17
+ raise RuntimeError("GUDDION_TOKEN is not set. Get one at https://mcp.guddion.ru.")
18
+
19
+ req = Request(VERIFY_URL, headers={"Authorization": f"Bearer {token}"})
20
+ try:
21
+ with urlopen(req):
22
+ pass
23
+ except HTTPError as exc:
24
+ raise RuntimeError(
25
+ f"GUDDION_TOKEN was rejected (status {exc.code}). Check your token at https://mcp.guddion.ru."
26
+ ) from exc
27
+
28
+ _verified = True
29
+
30
+
31
+ def reset_auth_cache_for_tests() -> None:
32
+ global _verified
33
+ _verified = False
@@ -0,0 +1,45 @@
1
+ import json
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from .. import settings as settings_module
6
+ from .. import storage
7
+
8
+ STATUS_HEADER = {
9
+ "ru": "[session-mcp] Статус проекта из прошлых сессий:",
10
+ "en": "[session-mcp] Project status from previous sessions:",
11
+ }
12
+
13
+
14
+ def process(payload: dict, storage_root: Path) -> dict | None:
15
+ cwd = payload["cwd"]
16
+ effective = settings_module.get_effective_settings(storage_root, cwd)
17
+ if not effective["enabled_hooks"].get("session_start", True):
18
+ return None
19
+
20
+ status = storage.get_status(storage_root, cwd)
21
+ if not status:
22
+ return None
23
+
24
+ language = effective.get("language", "ru")
25
+ header = STATUS_HEADER.get(language, STATUS_HEADER["ru"])
26
+ lines = [header]
27
+ for topic in sorted(status.keys()):
28
+ lines.append(f"- {topic}: {status[topic]['content']}")
29
+ return {
30
+ "hookSpecificOutput": {
31
+ "hookEventName": "SessionStart",
32
+ "additionalContext": "\n".join(lines),
33
+ }
34
+ }
35
+
36
+
37
+ def main() -> None:
38
+ payload = json.load(sys.stdin)
39
+ output = process(payload, storage.default_storage_root())
40
+ if output is not None:
41
+ print(json.dumps(output))
42
+
43
+
44
+ if __name__ == "__main__":
45
+ main()
@@ -0,0 +1,79 @@
1
+ import json
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from .. import settings as settings_module
6
+ from .. import storage
7
+
8
+ REMINDER_TEXT = {
9
+ "ru": (
10
+ "[session-mcp] Прошло {n} сообщений. Если появилось что-то важное "
11
+ "(архитектурное решение, договорённость, прогресс) — занеси кратко "
12
+ "через save_summary(topic, content). Если нечего заносить, просто "
13
+ "продолжай."
14
+ ),
15
+ "en": (
16
+ "[session-mcp] {n} messages have passed. If something important "
17
+ "happened (an architectural decision, an agreement, progress) — "
18
+ "log it briefly via save_summary(topic, content). If there's "
19
+ "nothing to log, just continue."
20
+ ),
21
+ }
22
+
23
+
24
+ def _state_path(state_dir: Path, session_id: str) -> Path:
25
+ state_dir = Path(state_dir)
26
+ state_dir.mkdir(parents=True, exist_ok=True)
27
+ return state_dir / f"{session_id}.json"
28
+
29
+
30
+ def _load_count(state_path: Path) -> int:
31
+ if not state_path.exists():
32
+ return 0
33
+ try:
34
+ return json.loads(state_path.read_text(encoding="utf-8")).get("count", 0)
35
+ except json.JSONDecodeError:
36
+ return 0
37
+
38
+
39
+ def _save_count(state_path: Path, count: int) -> None:
40
+ state_path.write_text(json.dumps({"count": count}), encoding="utf-8")
41
+
42
+
43
+ def process(payload: dict, state_dir: Path, storage_root: Path) -> dict | None:
44
+ cwd = payload["cwd"]
45
+ effective = settings_module.get_effective_settings(storage_root, cwd)
46
+ if not effective["enabled_hooks"].get("user_prompt_submit", True):
47
+ return None
48
+
49
+ session_id = payload["session_id"]
50
+ state_path = _state_path(state_dir, session_id)
51
+ interval = effective["reminder_interval"]
52
+ count = _load_count(state_path) + 1
53
+ if count >= interval:
54
+ _save_count(state_path, 0)
55
+ language = effective.get("language", "ru")
56
+ template = REMINDER_TEXT.get(language, REMINDER_TEXT["ru"])
57
+ return {
58
+ "hookSpecificOutput": {
59
+ "hookEventName": "UserPromptSubmit",
60
+ "additionalContext": template.format(n=interval),
61
+ }
62
+ }
63
+ _save_count(state_path, count)
64
+ return None
65
+
66
+
67
+ def _default_state_dir() -> Path:
68
+ return Path.home() / ".claude" / "session-mcp" / "hook-state"
69
+
70
+
71
+ def main() -> None:
72
+ payload = json.load(sys.stdin)
73
+ output = process(payload, _default_state_dir(), storage.default_storage_root())
74
+ if output is not None:
75
+ print(json.dumps(output))
76
+
77
+
78
+ if __name__ == "__main__":
79
+ main()
@@ -0,0 +1,69 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ from fastmcp import FastMCP
5
+
6
+ from . import auth, settings, storage
7
+
8
+ mcp = FastMCP("guddion-sessions")
9
+
10
+
11
+ def _cwd() -> str:
12
+ return os.getcwd()
13
+
14
+
15
+ def _storage_root() -> Path:
16
+ return storage.default_storage_root()
17
+
18
+
19
+ @mcp.tool
20
+ def save_summary(topic: str, content: str) -> dict:
21
+ """Replace the rolling status for a topic in the current project's
22
+ working directory, and append it to the history log. Never store
23
+ secrets, tokens, or passwords here."""
24
+ auth.verify_token()
25
+ return storage.save_summary(_storage_root(), _cwd(), topic, content)
26
+
27
+
28
+ @mcp.tool
29
+ def get_status(topics: list[str] | None = None) -> dict:
30
+ """Return the current rolling status for the project in the current
31
+ working directory, optionally filtered to specific topics."""
32
+ auth.verify_token()
33
+ return storage.get_status(_storage_root(), _cwd(), topics)
34
+
35
+
36
+ @mcp.tool
37
+ def get_history(topic: str | None = None, limit: int | None = None) -> list[dict]:
38
+ """Return history log entries for the project in the current working
39
+ directory, optionally filtered by topic and limited in count."""
40
+ auth.verify_token()
41
+ return storage.get_history(_storage_root(), _cwd(), topic, limit)
42
+
43
+
44
+ @mcp.tool
45
+ def list_topics() -> list[str]:
46
+ """Return the topics that currently have a status entry for the
47
+ project in the current working directory."""
48
+ auth.verify_token()
49
+ return storage.list_topics(_storage_root(), _cwd())
50
+
51
+
52
+ @mcp.tool
53
+ def get_settings(scope: str = "effective") -> dict:
54
+ """Return this user's guddion-sessions settings. scope is
55
+ 'effective' (global with project overrides applied, the default),
56
+ 'global', or 'project'."""
57
+ auth.verify_token()
58
+ return settings.get_settings(_storage_root(), _cwd(), scope)
59
+
60
+
61
+ @mcp.tool
62
+ def update_settings(updates: dict, scope: str = "global") -> dict:
63
+ """Update this user's guddion-sessions settings and return the
64
+ resulting effective settings. scope is 'global' (applies to all of
65
+ this user's projects) or 'project' (applies only to the current
66
+ working directory). Ask the user which scope they want before
67
+ calling this."""
68
+ auth.verify_token()
69
+ return settings.update_settings(_storage_root(), _cwd(), updates, scope)
@@ -0,0 +1,81 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ from . import storage
6
+
7
+ DEFAULT_SETTINGS: dict[str, Any] = {
8
+ "reminder_interval": 10,
9
+ "language": "ru",
10
+ "default_topics": ["architecture", "backend", "frontend", "decisions", "next-steps"],
11
+ "enabled_hooks": {"user_prompt_submit": True, "session_start": True},
12
+ }
13
+
14
+ VALID_SCOPES = ("global", "project")
15
+
16
+
17
+ def _global_settings_path(storage_root: Path) -> Path:
18
+ global_dir = Path(storage_root) / "_global"
19
+ global_dir.mkdir(parents=True, exist_ok=True)
20
+ return global_dir / "settings.json"
21
+
22
+
23
+ def _project_settings_path(storage_root: Path, cwd: str) -> Path:
24
+ project_dir = storage.get_project_dir(storage_root, cwd)
25
+ return project_dir / "settings.json"
26
+
27
+
28
+ def _read_settings_file(path: Path) -> dict:
29
+ if not path.exists():
30
+ return {}
31
+ try:
32
+ return json.loads(path.read_text(encoding="utf-8"))
33
+ except json.JSONDecodeError as exc:
34
+ raise ValueError(f"Corrupted settings file: {path}") from exc
35
+
36
+
37
+ def _merge(base: dict, override: dict) -> dict:
38
+ merged = dict(base)
39
+ for key, value in override.items():
40
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
41
+ merged[key] = {**merged[key], **value}
42
+ else:
43
+ merged[key] = value
44
+ return merged
45
+
46
+
47
+ def get_effective_settings(storage_root: Path, cwd: str) -> dict:
48
+ effective = {
49
+ "reminder_interval": DEFAULT_SETTINGS["reminder_interval"],
50
+ "language": DEFAULT_SETTINGS["language"],
51
+ "default_topics": list(DEFAULT_SETTINGS["default_topics"]),
52
+ "enabled_hooks": dict(DEFAULT_SETTINGS["enabled_hooks"]),
53
+ }
54
+ effective = _merge(effective, _read_settings_file(_global_settings_path(storage_root)))
55
+ effective = _merge(effective, _read_settings_file(_project_settings_path(storage_root, cwd)))
56
+ return effective
57
+
58
+
59
+ def get_settings(storage_root: Path, cwd: str, scope: str = "effective") -> dict:
60
+ if scope == "effective":
61
+ return get_effective_settings(storage_root, cwd)
62
+ if scope == "global":
63
+ return _read_settings_file(_global_settings_path(storage_root))
64
+ if scope == "project":
65
+ return _read_settings_file(_project_settings_path(storage_root, cwd))
66
+ raise ValueError(f"scope must be 'effective', 'global', or 'project', got {scope!r}")
67
+
68
+
69
+ def update_settings(storage_root: Path, cwd: str, updates: dict, scope: str) -> dict:
70
+ if scope not in VALID_SCOPES:
71
+ raise ValueError(f"scope must be one of {VALID_SCOPES}, got {scope!r}")
72
+
73
+ path = (
74
+ _global_settings_path(storage_root)
75
+ if scope == "global"
76
+ else _project_settings_path(storage_root, cwd)
77
+ )
78
+ current = _read_settings_file(path)
79
+ merged = _merge(current, updates)
80
+ storage.atomic_write_json(path, merged)
81
+ return get_effective_settings(storage_root, cwd)
@@ -0,0 +1,107 @@
1
+ import hashlib
2
+ import json
3
+ import os
4
+ import time
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+
9
+ def normalize_project_key(cwd: str) -> str:
10
+ """Stable, filesystem-safe key for a working directory path."""
11
+ resolved = str(Path(cwd).resolve())
12
+ normalized = resolved.replace("\\", "/").lower()
13
+ return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]
14
+
15
+
16
+ def get_project_dir(storage_root: Path, cwd: str) -> Path:
17
+ key = normalize_project_key(cwd)
18
+ project_dir = Path(storage_root) / key
19
+ project_dir.mkdir(parents=True, exist_ok=True)
20
+ return project_dir
21
+
22
+
23
+ def atomic_write_json(path: Path, data: dict) -> None:
24
+ path = Path(path)
25
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
26
+ tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
27
+ last_exc = None
28
+ for _ in range(5):
29
+ try:
30
+ os.replace(tmp_path, path)
31
+ return
32
+ except PermissionError as exc:
33
+ last_exc = exc
34
+ time.sleep(0.05)
35
+ raise last_exc
36
+
37
+
38
+ def _now_iso() -> str:
39
+ return datetime.now(timezone.utc).isoformat()
40
+
41
+
42
+ def read_status(project_dir: Path) -> dict:
43
+ status_path = Path(project_dir) / "status.json"
44
+ if not status_path.exists():
45
+ return {}
46
+ try:
47
+ return json.loads(status_path.read_text(encoding="utf-8"))
48
+ except json.JSONDecodeError as exc:
49
+ raise ValueError(f"Corrupted status file: {status_path}") from exc
50
+
51
+
52
+ def append_log(project_dir: Path, topic: str, content: str) -> None:
53
+ log_path = Path(project_dir) / "log.jsonl"
54
+ entry = {"ts": _now_iso(), "topic": topic, "content": content}
55
+ with log_path.open("a", encoding="utf-8") as f:
56
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
57
+
58
+
59
+ def get_history(
60
+ storage_root: Path, cwd: str, topic: str | None = None, limit: int | None = None
61
+ ) -> list[dict]:
62
+ project_dir = get_project_dir(storage_root, cwd)
63
+ log_path = project_dir / "log.jsonl"
64
+ if not log_path.exists():
65
+ return []
66
+ entries = []
67
+ with log_path.open("r", encoding="utf-8") as f:
68
+ for line in f:
69
+ line = line.strip()
70
+ if not line:
71
+ continue
72
+ entry = json.loads(line)
73
+ if topic is None or entry["topic"] == topic:
74
+ entries.append(entry)
75
+ if limit is not None:
76
+ entries = entries[-limit:]
77
+ return entries
78
+
79
+
80
+ def default_storage_root() -> Path:
81
+ override = os.environ.get("SESSION_MCP_STORAGE_DIR")
82
+ if override:
83
+ return Path(override)
84
+ return Path.home() / ".claude" / "session-mcp" / "storage"
85
+
86
+
87
+ def save_summary(storage_root: Path, cwd: str, topic: str, content: str) -> dict:
88
+ project_dir = get_project_dir(storage_root, cwd)
89
+ status = read_status(project_dir)
90
+ entry = {"content": content, "updated_at": _now_iso()}
91
+ status[topic] = entry
92
+ atomic_write_json(project_dir / "status.json", status)
93
+ append_log(project_dir, topic, content)
94
+ return entry
95
+
96
+
97
+ def get_status(storage_root: Path, cwd: str, topics: list[str] | None = None) -> dict:
98
+ project_dir = get_project_dir(storage_root, cwd)
99
+ status = read_status(project_dir)
100
+ if topics is None:
101
+ return status
102
+ return {t: status[t] for t in topics if t in status}
103
+
104
+
105
+ def list_topics(storage_root: Path, cwd: str) -> list[str]:
106
+ project_dir = get_project_dir(storage_root, cwd)
107
+ return sorted(read_status(project_dir).keys())
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: guddion-sessions
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.10
5
+ Requires-Dist: fastmcp>=2.0
6
+ Provides-Extra: dev
7
+ Requires-Dist: pytest>=8.0; extra == "dev"
8
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
@@ -0,0 +1,22 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/guddion_sessions/__init__.py
4
+ src/guddion_sessions/__main__.py
5
+ src/guddion_sessions/auth.py
6
+ src/guddion_sessions/server.py
7
+ src/guddion_sessions/settings.py
8
+ src/guddion_sessions/storage.py
9
+ src/guddion_sessions.egg-info/PKG-INFO
10
+ src/guddion_sessions.egg-info/SOURCES.txt
11
+ src/guddion_sessions.egg-info/dependency_links.txt
12
+ src/guddion_sessions.egg-info/requires.txt
13
+ src/guddion_sessions.egg-info/top_level.txt
14
+ src/guddion_sessions/hooks/__init__.py
15
+ src/guddion_sessions/hooks/session_start.py
16
+ src/guddion_sessions/hooks/user_prompt_submit.py
17
+ tests/test_auth.py
18
+ tests/test_hooks_session_start.py
19
+ tests/test_hooks_user_prompt_submit.py
20
+ tests/test_server.py
21
+ tests/test_settings.py
22
+ tests/test_storage.py
@@ -0,0 +1,5 @@
1
+ fastmcp>=2.0
2
+
3
+ [dev]
4
+ pytest>=8.0
5
+ pytest-asyncio>=0.23
@@ -0,0 +1 @@
1
+ guddion_sessions
@@ -0,0 +1,55 @@
1
+ import pytest
2
+
3
+ from guddion_sessions import auth
4
+
5
+
6
+ @pytest.fixture(autouse=True)
7
+ def _reset_auth_cache():
8
+ auth.reset_auth_cache_for_tests()
9
+ yield
10
+ auth.reset_auth_cache_for_tests()
11
+
12
+
13
+ def test_verify_token_raises_when_token_not_set(monkeypatch):
14
+ monkeypatch.delenv("GUDDION_TOKEN", raising=False)
15
+ with pytest.raises(RuntimeError, match="GUDDION_TOKEN"):
16
+ auth.verify_token()
17
+
18
+
19
+ def test_verify_token_raises_when_backend_rejects(monkeypatch):
20
+ monkeypatch.setenv("GUDDION_TOKEN", "bad-token")
21
+
22
+ def fake_urlopen(req):
23
+ raise auth.HTTPError(req.full_url, 401, "Unauthorized", None, None)
24
+
25
+ monkeypatch.setattr(auth, "urlopen", fake_urlopen)
26
+
27
+ with pytest.raises(RuntimeError, match="401"):
28
+ auth.verify_token()
29
+
30
+
31
+ def test_verify_token_caches_successful_verification(monkeypatch):
32
+ monkeypatch.setenv("GUDDION_TOKEN", "good-token")
33
+ calls = []
34
+
35
+ class FakeResponse:
36
+ status = 200
37
+
38
+ def __enter__(self):
39
+ return self
40
+
41
+ def __exit__(self, *args):
42
+ return False
43
+
44
+ def fake_urlopen(req):
45
+ calls.append(req)
46
+ return FakeResponse()
47
+
48
+ monkeypatch.setattr(auth, "urlopen", fake_urlopen)
49
+
50
+ auth.verify_token()
51
+ auth.verify_token()
52
+
53
+ assert len(calls) == 1
54
+ assert calls[0].full_url == "https://mcp.guddion.ru/api/verify"
55
+ assert calls[0].get_header("Authorization") == "Bearer good-token"
@@ -0,0 +1,46 @@
1
+ from guddion_sessions import storage
2
+ from guddion_sessions import settings as settings_module
3
+ from guddion_sessions.hooks import session_start as ss
4
+
5
+
6
+ def test_process_returns_none_when_no_status(tmp_path):
7
+ result = ss.process({"cwd": str(tmp_path / "proj")}, tmp_path)
8
+ assert result is None
9
+
10
+
11
+ def test_process_returns_status_summary(tmp_path):
12
+ cwd = str(tmp_path / "proj")
13
+ storage.save_summary(tmp_path, cwd, "backend", "API готов")
14
+ result = ss.process({"cwd": cwd}, tmp_path)
15
+ context = result["hookSpecificOutput"]["additionalContext"]
16
+ assert "backend" in context
17
+ assert "API готов" in context
18
+
19
+
20
+ def test_process_includes_all_topics(tmp_path):
21
+ cwd = str(tmp_path / "proj")
22
+ storage.save_summary(tmp_path, cwd, "backend", "x")
23
+ storage.save_summary(tmp_path, cwd, "frontend", "y")
24
+ result = ss.process({"cwd": cwd}, tmp_path)
25
+ context = result["hookSpecificOutput"]["additionalContext"]
26
+ assert "backend" in context
27
+ assert "frontend" in context
28
+
29
+
30
+ def test_process_returns_none_when_hook_disabled(tmp_path):
31
+ cwd = str(tmp_path / "proj")
32
+ storage.save_summary(tmp_path, cwd, "backend", "x")
33
+ settings_module.update_settings(
34
+ tmp_path, cwd, {"enabled_hooks": {"session_start": False}}, scope="project"
35
+ )
36
+ assert ss.process({"cwd": cwd}, tmp_path) is None
37
+
38
+
39
+ def test_process_uses_english_header_when_configured(tmp_path):
40
+ cwd = str(tmp_path / "proj")
41
+ storage.save_summary(tmp_path, cwd, "backend", "x")
42
+ settings_module.update_settings(tmp_path, cwd, {"language": "en"}, scope="project")
43
+ result = ss.process({"cwd": cwd}, tmp_path)
44
+ context = result["hookSpecificOutput"]["additionalContext"]
45
+ assert "previous sessions" in context
46
+ assert "прошлых сессий" not in context
@@ -0,0 +1,86 @@
1
+ from guddion_sessions import settings as settings_module
2
+ from guddion_sessions.hooks import user_prompt_submit as ups
3
+
4
+
5
+ def _payload(session_id, cwd):
6
+ return {"session_id": session_id, "cwd": cwd}
7
+
8
+
9
+ def test_process_returns_none_before_threshold(tmp_path):
10
+ state_dir = tmp_path / "state"
11
+ storage_root = tmp_path / "storage"
12
+ payload = _payload("s1", str(tmp_path / "proj"))
13
+ for _ in range(9):
14
+ result = ups.process(payload, state_dir, storage_root)
15
+ assert result is None
16
+
17
+
18
+ def test_process_returns_reminder_on_tenth_call(tmp_path):
19
+ state_dir = tmp_path / "state"
20
+ storage_root = tmp_path / "storage"
21
+ payload = _payload("s1", str(tmp_path / "proj"))
22
+ for _ in range(9):
23
+ ups.process(payload, state_dir, storage_root)
24
+ result = ups.process(payload, state_dir, storage_root)
25
+ assert result["hookSpecificOutput"]["hookEventName"] == "UserPromptSubmit"
26
+ assert "save_summary" in result["hookSpecificOutput"]["additionalContext"]
27
+
28
+
29
+ def test_process_resets_after_reminder(tmp_path):
30
+ state_dir = tmp_path / "state"
31
+ storage_root = tmp_path / "storage"
32
+ payload = _payload("s1", str(tmp_path / "proj"))
33
+ for _ in range(10):
34
+ ups.process(payload, state_dir, storage_root)
35
+ for _ in range(9):
36
+ result = ups.process(payload, state_dir, storage_root)
37
+ assert result is None
38
+ result = ups.process(payload, state_dir, storage_root)
39
+ assert result is not None
40
+
41
+
42
+ def test_process_tracks_sessions_independently(tmp_path):
43
+ state_dir = tmp_path / "state"
44
+ storage_root = tmp_path / "storage"
45
+ cwd = str(tmp_path / "proj")
46
+ for _ in range(5):
47
+ ups.process(_payload("s1", cwd), state_dir, storage_root)
48
+ result = ups.process(_payload("s2", cwd), state_dir, storage_root)
49
+ assert result is None
50
+
51
+
52
+ def test_process_respects_custom_reminder_interval(tmp_path):
53
+ state_dir = tmp_path / "state"
54
+ storage_root = tmp_path / "storage"
55
+ cwd = str(tmp_path / "proj")
56
+ settings_module.update_settings(storage_root, cwd, {"reminder_interval": 3}, scope="project")
57
+ payload = _payload("s1", cwd)
58
+ assert ups.process(payload, state_dir, storage_root) is None
59
+ assert ups.process(payload, state_dir, storage_root) is None
60
+ result = ups.process(payload, state_dir, storage_root)
61
+ assert result is not None
62
+
63
+
64
+ def test_process_returns_none_when_hook_disabled(tmp_path):
65
+ state_dir = tmp_path / "state"
66
+ storage_root = tmp_path / "storage"
67
+ cwd = str(tmp_path / "proj")
68
+ settings_module.update_settings(
69
+ storage_root, cwd, {"enabled_hooks": {"user_prompt_submit": False}}, scope="project"
70
+ )
71
+ payload = _payload("s1", cwd)
72
+ for _ in range(15):
73
+ assert ups.process(payload, state_dir, storage_root) is None
74
+
75
+
76
+ def test_process_uses_english_reminder_text_when_configured(tmp_path):
77
+ state_dir = tmp_path / "state"
78
+ storage_root = tmp_path / "storage"
79
+ cwd = str(tmp_path / "proj")
80
+ settings_module.update_settings(
81
+ storage_root, cwd, {"language": "en", "reminder_interval": 1}, scope="project"
82
+ )
83
+ payload = _payload("s1", cwd)
84
+ result = ups.process(payload, state_dir, storage_root)
85
+ assert "save_summary" in result["hookSpecificOutput"]["additionalContext"]
86
+ assert "Прошло" not in result["hookSpecificOutput"]["additionalContext"]
@@ -0,0 +1,83 @@
1
+ import pytest
2
+ from fastmcp import Client
3
+
4
+ from guddion_sessions import auth
5
+ from guddion_sessions.server import mcp
6
+
7
+
8
+ @pytest.fixture
9
+ def bypass_auth(monkeypatch):
10
+ monkeypatch.setattr(auth, "verify_token", lambda: None)
11
+
12
+
13
+ async def test_save_summary_tool_roundtrip(tmp_path, monkeypatch, bypass_auth):
14
+ monkeypatch.setenv("SESSION_MCP_STORAGE_DIR", str(tmp_path))
15
+ monkeypatch.chdir(tmp_path)
16
+ client = Client(mcp)
17
+ async with client:
18
+ result = await client.call_tool("save_summary", {"topic": "backend", "content": "готово"})
19
+ assert result.data["content"] == "готово"
20
+
21
+
22
+ async def test_get_status_tool_returns_saved_topic(tmp_path, monkeypatch, bypass_auth):
23
+ monkeypatch.setenv("SESSION_MCP_STORAGE_DIR", str(tmp_path))
24
+ monkeypatch.chdir(tmp_path)
25
+ client = Client(mcp)
26
+ async with client:
27
+ await client.call_tool("save_summary", {"topic": "backend", "content": "готово"})
28
+ result = await client.call_tool("get_status", {})
29
+ assert result.data["backend"]["content"] == "готово"
30
+
31
+
32
+ async def test_list_topics_tool(tmp_path, monkeypatch, bypass_auth):
33
+ monkeypatch.setenv("SESSION_MCP_STORAGE_DIR", str(tmp_path))
34
+ monkeypatch.chdir(tmp_path)
35
+ client = Client(mcp)
36
+ async with client:
37
+ await client.call_tool("save_summary", {"topic": "backend", "content": "x"})
38
+ result = await client.call_tool("list_topics", {})
39
+ assert result.data == ["backend"]
40
+
41
+
42
+ async def test_get_history_tool(tmp_path, monkeypatch, bypass_auth):
43
+ monkeypatch.setenv("SESSION_MCP_STORAGE_DIR", str(tmp_path))
44
+ monkeypatch.chdir(tmp_path)
45
+ client = Client(mcp)
46
+ async with client:
47
+ await client.call_tool("save_summary", {"topic": "backend", "content": "x"})
48
+ result = await client.call_tool("get_history", {})
49
+ assert len(result.data) == 1
50
+ assert result.data[0]["topic"] == "backend"
51
+
52
+
53
+ async def test_get_settings_tool_returns_defaults(tmp_path, monkeypatch, bypass_auth):
54
+ monkeypatch.setenv("SESSION_MCP_STORAGE_DIR", str(tmp_path))
55
+ monkeypatch.chdir(tmp_path)
56
+ client = Client(mcp)
57
+ async with client:
58
+ result = await client.call_tool("get_settings", {})
59
+ assert result.data["reminder_interval"] == 10
60
+
61
+
62
+ async def test_update_settings_tool_persists_change(tmp_path, monkeypatch, bypass_auth):
63
+ monkeypatch.setenv("SESSION_MCP_STORAGE_DIR", str(tmp_path))
64
+ monkeypatch.chdir(tmp_path)
65
+ client = Client(mcp)
66
+ async with client:
67
+ result = await client.call_tool(
68
+ "update_settings", {"updates": {"reminder_interval": 5}, "scope": "global"}
69
+ )
70
+ assert result.data["reminder_interval"] == 5
71
+ again = await client.call_tool("get_settings", {})
72
+ assert again.data["reminder_interval"] == 5
73
+
74
+
75
+ async def test_tool_call_fails_without_valid_token(tmp_path, monkeypatch):
76
+ monkeypatch.setenv("SESSION_MCP_STORAGE_DIR", str(tmp_path))
77
+ monkeypatch.chdir(tmp_path)
78
+ monkeypatch.delenv("GUDDION_TOKEN", raising=False)
79
+ auth.reset_auth_cache_for_tests()
80
+ client = Client(mcp)
81
+ async with client:
82
+ with pytest.raises(Exception, match="GUDDION_TOKEN"):
83
+ await client.call_tool("get_status", {})
@@ -0,0 +1,64 @@
1
+ import pytest
2
+
3
+ from guddion_sessions import settings as settings_module
4
+
5
+
6
+ def test_effective_settings_default_when_nothing_set(tmp_path):
7
+ cwd = str(tmp_path / "proj")
8
+ effective = settings_module.get_effective_settings(tmp_path, cwd)
9
+ assert effective["reminder_interval"] == 10
10
+ assert effective["language"] == "ru"
11
+ assert effective["enabled_hooks"] == {"user_prompt_submit": True, "session_start": True}
12
+ assert "architecture" in effective["default_topics"]
13
+
14
+
15
+ def test_update_settings_global_scope_applies_to_all_projects(tmp_path):
16
+ cwd_a = str(tmp_path / "a")
17
+ cwd_b = str(tmp_path / "b")
18
+ settings_module.update_settings(tmp_path, cwd_a, {"reminder_interval": 5}, scope="global")
19
+ assert settings_module.get_effective_settings(tmp_path, cwd_a)["reminder_interval"] == 5
20
+ assert settings_module.get_effective_settings(tmp_path, cwd_b)["reminder_interval"] == 5
21
+
22
+
23
+ def test_update_settings_project_scope_only_affects_that_project(tmp_path):
24
+ cwd_a = str(tmp_path / "a")
25
+ cwd_b = str(tmp_path / "b")
26
+ settings_module.update_settings(tmp_path, cwd_a, {"reminder_interval": 3}, scope="project")
27
+ assert settings_module.get_effective_settings(tmp_path, cwd_a)["reminder_interval"] == 3
28
+ assert settings_module.get_effective_settings(tmp_path, cwd_b)["reminder_interval"] == 10
29
+
30
+
31
+ def test_project_scope_overrides_global_scope(tmp_path):
32
+ cwd = str(tmp_path / "a")
33
+ settings_module.update_settings(tmp_path, cwd, {"reminder_interval": 5}, scope="global")
34
+ settings_module.update_settings(tmp_path, cwd, {"reminder_interval": 3}, scope="project")
35
+ assert settings_module.get_effective_settings(tmp_path, cwd)["reminder_interval"] == 3
36
+
37
+
38
+ def test_update_settings_merges_nested_enabled_hooks(tmp_path):
39
+ cwd = str(tmp_path / "a")
40
+ settings_module.update_settings(
41
+ tmp_path, cwd, {"enabled_hooks": {"session_start": False}}, scope="project"
42
+ )
43
+ effective = settings_module.get_effective_settings(tmp_path, cwd)
44
+ assert effective["enabled_hooks"] == {"user_prompt_submit": True, "session_start": False}
45
+
46
+
47
+ def test_update_settings_invalid_scope_raises(tmp_path):
48
+ cwd = str(tmp_path / "a")
49
+ with pytest.raises(ValueError):
50
+ settings_module.update_settings(tmp_path, cwd, {"reminder_interval": 5}, scope="bogus")
51
+
52
+
53
+ def test_get_settings_scope_global_and_project_are_isolated(tmp_path):
54
+ cwd = str(tmp_path / "a")
55
+ settings_module.update_settings(tmp_path, cwd, {"reminder_interval": 5}, scope="global")
56
+ settings_module.update_settings(tmp_path, cwd, {"reminder_interval": 3}, scope="project")
57
+ assert settings_module.get_settings(tmp_path, cwd, scope="global")["reminder_interval"] == 5
58
+ assert settings_module.get_settings(tmp_path, cwd, scope="project")["reminder_interval"] == 3
59
+
60
+
61
+ def test_get_settings_invalid_scope_raises(tmp_path):
62
+ cwd = str(tmp_path / "a")
63
+ with pytest.raises(ValueError):
64
+ settings_module.get_settings(tmp_path, cwd, scope="bogus")
@@ -0,0 +1,166 @@
1
+ from pathlib import Path
2
+
3
+ from guddion_sessions import storage
4
+
5
+
6
+ def test_normalize_project_key_same_path_different_case():
7
+ key_lower = storage.normalize_project_key("d:/guddion/projects/session-mcp")
8
+ key_upper = storage.normalize_project_key("D:\\Guddion\\Projects\\session-mcp")
9
+ assert key_lower == key_upper
10
+
11
+
12
+ def test_normalize_project_key_different_paths_differ():
13
+ key_a = storage.normalize_project_key("D:/a")
14
+ key_b = storage.normalize_project_key("D:/b")
15
+ assert key_a != key_b
16
+
17
+
18
+ def test_get_project_dir_creates_directory(tmp_path):
19
+ project_dir = storage.get_project_dir(tmp_path, str(tmp_path / "myproject"))
20
+ assert project_dir.exists()
21
+ assert project_dir.parent == tmp_path
22
+
23
+
24
+ def test_get_project_dir_stable_across_calls(tmp_path):
25
+ cwd = str(tmp_path / "myproject")
26
+ first = storage.get_project_dir(tmp_path, cwd)
27
+ second = storage.get_project_dir(tmp_path, cwd)
28
+ assert first == second
29
+
30
+
31
+ import json
32
+
33
+
34
+ def test_atomic_write_json_writes_valid_file(tmp_path):
35
+ target = tmp_path / "status.json"
36
+ storage.atomic_write_json(target, {"a": 1})
37
+ assert json.loads(target.read_text(encoding="utf-8")) == {"a": 1}
38
+
39
+
40
+ def test_atomic_write_json_no_leftover_tmp_file(tmp_path):
41
+ target = tmp_path / "status.json"
42
+ storage.atomic_write_json(target, {"a": 1})
43
+ assert list(tmp_path.iterdir()) == [target]
44
+
45
+
46
+ def test_atomic_write_json_retries_on_permission_error(tmp_path, monkeypatch):
47
+ target = tmp_path / "status.json"
48
+ real_replace = storage.os.replace
49
+ calls = {"count": 0}
50
+
51
+ def flaky_replace(src, dst):
52
+ calls["count"] += 1
53
+ if calls["count"] < 3:
54
+ raise PermissionError("locked")
55
+ real_replace(src, dst)
56
+
57
+ monkeypatch.setattr(storage.os, "replace", flaky_replace)
58
+ monkeypatch.setattr(storage.time, "sleep", lambda seconds: None)
59
+
60
+ storage.atomic_write_json(target, {"a": 1})
61
+
62
+ assert calls["count"] == 3
63
+ assert json.loads(target.read_text(encoding="utf-8")) == {"a": 1}
64
+
65
+
66
+ def test_atomic_write_json_raises_after_exhausting_retries(tmp_path, monkeypatch):
67
+ target = tmp_path / "status.json"
68
+
69
+ def always_fails(src, dst):
70
+ raise PermissionError("locked")
71
+
72
+ monkeypatch.setattr(storage.os, "replace", always_fails)
73
+ monkeypatch.setattr(storage.time, "sleep", lambda seconds: None)
74
+
75
+ import pytest
76
+ with pytest.raises(PermissionError):
77
+ storage.atomic_write_json(target, {"a": 1})
78
+
79
+
80
+ import pytest as _pytest
81
+
82
+
83
+ def test_read_status_missing_file_returns_empty_dict(tmp_path):
84
+ assert storage.read_status(tmp_path) == {}
85
+
86
+
87
+ def test_read_status_corrupted_file_raises(tmp_path):
88
+ (tmp_path / "status.json").write_text("{not valid json", encoding="utf-8")
89
+ with _pytest.raises(ValueError):
90
+ storage.read_status(tmp_path)
91
+
92
+
93
+ def test_save_summary_creates_topic_with_timestamp(tmp_path):
94
+ entry = storage.save_summary(tmp_path, str(tmp_path / "proj"), "backend", "API готов")
95
+ assert entry["content"] == "API готов"
96
+ assert "updated_at" in entry
97
+
98
+
99
+ def test_save_summary_replaces_not_appends(tmp_path):
100
+ cwd = str(tmp_path / "proj")
101
+ storage.save_summary(tmp_path, cwd, "backend", "первая версия")
102
+ storage.save_summary(tmp_path, cwd, "backend", "вторая версия")
103
+ status = storage.get_status(tmp_path, cwd)
104
+ assert status["backend"]["content"] == "вторая версия"
105
+
106
+
107
+ def test_get_status_filters_by_topics(tmp_path):
108
+ cwd = str(tmp_path / "proj")
109
+ storage.save_summary(tmp_path, cwd, "backend", "x")
110
+ storage.save_summary(tmp_path, cwd, "frontend", "y")
111
+ status = storage.get_status(tmp_path, cwd, topics=["backend"])
112
+ assert list(status.keys()) == ["backend"]
113
+
114
+
115
+ def test_get_status_missing_project_returns_empty(tmp_path):
116
+ assert storage.get_status(tmp_path, str(tmp_path / "unknown")) == {}
117
+
118
+
119
+ def test_list_topics_sorted(tmp_path):
120
+ cwd = str(tmp_path / "proj")
121
+ storage.save_summary(tmp_path, cwd, "frontend", "y")
122
+ storage.save_summary(tmp_path, cwd, "backend", "x")
123
+ assert storage.list_topics(tmp_path, cwd) == ["backend", "frontend"]
124
+
125
+
126
+ def test_save_summary_appends_log_entry(tmp_path):
127
+ cwd = str(tmp_path / "proj")
128
+ storage.save_summary(tmp_path, cwd, "backend", "x")
129
+ history = storage.get_history(tmp_path, cwd)
130
+ assert len(history) == 1
131
+ assert history[0]["topic"] == "backend"
132
+ assert history[0]["content"] == "x"
133
+ assert "ts" in history[0]
134
+
135
+
136
+ def test_get_history_filters_by_topic(tmp_path):
137
+ cwd = str(tmp_path / "proj")
138
+ storage.save_summary(tmp_path, cwd, "backend", "x")
139
+ storage.save_summary(tmp_path, cwd, "frontend", "y")
140
+ history = storage.get_history(tmp_path, cwd, topic="frontend")
141
+ assert len(history) == 1
142
+ assert history[0]["topic"] == "frontend"
143
+
144
+
145
+ def test_get_history_respects_limit(tmp_path):
146
+ cwd = str(tmp_path / "proj")
147
+ for i in range(5):
148
+ storage.save_summary(tmp_path, cwd, "backend", f"version {i}")
149
+ history = storage.get_history(tmp_path, cwd, limit=2)
150
+ assert len(history) == 2
151
+ assert history[-1]["content"] == "version 4"
152
+
153
+
154
+ def test_get_history_missing_log_returns_empty(tmp_path):
155
+ assert storage.get_history(tmp_path, str(tmp_path / "unknown")) == []
156
+
157
+
158
+ def test_default_storage_root_uses_env_override(monkeypatch, tmp_path):
159
+ monkeypatch.setenv("SESSION_MCP_STORAGE_DIR", str(tmp_path))
160
+ assert storage.default_storage_root() == tmp_path
161
+
162
+
163
+ def test_default_storage_root_falls_back_to_home(monkeypatch):
164
+ monkeypatch.delenv("SESSION_MCP_STORAGE_DIR", raising=False)
165
+ expected = Path.home() / ".claude" / "session-mcp" / "storage"
166
+ assert storage.default_storage_root() == expected