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.
- guddion_sessions-0.1.0/PKG-INFO +8 -0
- guddion_sessions-0.1.0/README.md +50 -0
- guddion_sessions-0.1.0/pyproject.toml +24 -0
- guddion_sessions-0.1.0/setup.cfg +4 -0
- guddion_sessions-0.1.0/src/guddion_sessions/__init__.py +0 -0
- guddion_sessions-0.1.0/src/guddion_sessions/__main__.py +4 -0
- guddion_sessions-0.1.0/src/guddion_sessions/auth.py +33 -0
- guddion_sessions-0.1.0/src/guddion_sessions/hooks/__init__.py +0 -0
- guddion_sessions-0.1.0/src/guddion_sessions/hooks/session_start.py +45 -0
- guddion_sessions-0.1.0/src/guddion_sessions/hooks/user_prompt_submit.py +79 -0
- guddion_sessions-0.1.0/src/guddion_sessions/server.py +69 -0
- guddion_sessions-0.1.0/src/guddion_sessions/settings.py +81 -0
- guddion_sessions-0.1.0/src/guddion_sessions/storage.py +107 -0
- guddion_sessions-0.1.0/src/guddion_sessions.egg-info/PKG-INFO +8 -0
- guddion_sessions-0.1.0/src/guddion_sessions.egg-info/SOURCES.txt +22 -0
- guddion_sessions-0.1.0/src/guddion_sessions.egg-info/dependency_links.txt +1 -0
- guddion_sessions-0.1.0/src/guddion_sessions.egg-info/requires.txt +5 -0
- guddion_sessions-0.1.0/src/guddion_sessions.egg-info/top_level.txt +1 -0
- guddion_sessions-0.1.0/tests/test_auth.py +55 -0
- guddion_sessions-0.1.0/tests/test_hooks_session_start.py +46 -0
- guddion_sessions-0.1.0/tests/test_hooks_user_prompt_submit.py +86 -0
- guddion_sessions-0.1.0/tests/test_server.py +83 -0
- guddion_sessions-0.1.0/tests/test_settings.py +64 -0
- guddion_sessions-0.1.0/tests/test_storage.py +166 -0
|
@@ -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"]
|
|
File without changes
|
|
@@ -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
|
|
File without changes
|
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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
|