sovara-claude-code 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.
- sovara_claude_code-0.1.0/LICENSE +21 -0
- sovara_claude_code-0.1.0/PKG-INFO +27 -0
- sovara_claude_code-0.1.0/README.md +14 -0
- sovara_claude_code-0.1.0/pyproject.toml +55 -0
- sovara_claude_code-0.1.0/setup.cfg +4 -0
- sovara_claude_code-0.1.0/src/sovara_claude_code/__init__.py +9 -0
- sovara_claude_code-0.1.0/src/sovara_claude_code/__main__.py +28 -0
- sovara_claude_code-0.1.0/src/sovara_claude_code/_hooks.py +233 -0
- sovara_claude_code-0.1.0/src/sovara_claude_code/_state.py +73 -0
- sovara_claude_code-0.1.0/src/sovara_claude_code.egg-info/PKG-INFO +27 -0
- sovara_claude_code-0.1.0/src/sovara_claude_code.egg-info/SOURCES.txt +14 -0
- sovara_claude_code-0.1.0/src/sovara_claude_code.egg-info/dependency_links.txt +1 -0
- sovara_claude_code-0.1.0/src/sovara_claude_code.egg-info/entry_points.txt +2 -0
- sovara_claude_code-0.1.0/src/sovara_claude_code.egg-info/requires.txt +1 -0
- sovara_claude_code-0.1.0/src/sovara_claude_code.egg-info/top_level.txt +1 -0
- sovara_claude_code-0.1.0/tests/test_sovara_claude_code.py +223 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sovara Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sovara-claude-code
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Record Claude Code sessions in Sovara and add relevant lessons to each prompt
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Documentation, https://docs.sovara-labs.com/integrations/claude-code
|
|
7
|
+
Project-URL: Repository, https://github.com/SovaraLabs/sovara-claude-code/tree/main/python
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: sovara<0.11,>=0.10
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# sovara-claude-code
|
|
15
|
+
|
|
16
|
+
The Python hook program for [Sovara for Claude Code](https://github.com/SovaraLabs/sovara-claude-code#readme).
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
uv tool install sovara-claude-code
|
|
20
|
+
# or: pip install sovara-claude-code
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then load the `sovara-recording` plugin.
|
|
24
|
+
|
|
25
|
+
## License
|
|
26
|
+
|
|
27
|
+
MIT.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# sovara-claude-code
|
|
2
|
+
|
|
3
|
+
The Python hook program for [Sovara for Claude Code](https://github.com/SovaraLabs/sovara-claude-code#readme).
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
uv tool install sovara-claude-code
|
|
7
|
+
# or: pip install sovara-claude-code
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Then load the `sovara-recording` plugin.
|
|
11
|
+
|
|
12
|
+
## License
|
|
13
|
+
|
|
14
|
+
MIT.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sovara-claude-code"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Record Claude Code sessions in Sovara and add relevant lessons to each prompt"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = ["sovara>=0.10,<0.11"]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
sovara-claude-code = "sovara_claude_code.__main__:main"
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Documentation = "https://docs.sovara-labs.com/integrations/claude-code"
|
|
19
|
+
Repository = "https://github.com/SovaraLabs/sovara-claude-code/tree/main/python"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools]
|
|
22
|
+
package-dir = {"" = "src"}
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["src"]
|
|
26
|
+
include = ["sovara_claude_code*"]
|
|
27
|
+
|
|
28
|
+
[dependency-groups]
|
|
29
|
+
dev = [
|
|
30
|
+
"pytest",
|
|
31
|
+
"ruff==0.17.2",
|
|
32
|
+
"httpx",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[tool.ruff]
|
|
36
|
+
line-length = 100
|
|
37
|
+
target-version = "py310"
|
|
38
|
+
|
|
39
|
+
[tool.ruff.lint]
|
|
40
|
+
select = [
|
|
41
|
+
"E4", "E7", "E9", "F", "I", "UP", "B",
|
|
42
|
+
"D100", "D101", "D102", "D103", "D104", "D106", "D107",
|
|
43
|
+
"D200", "D205", "D400", "D401", "D419",
|
|
44
|
+
"ERA001", "PGH004", "RUF100",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[tool.ruff.lint.per-file-ignores]
|
|
48
|
+
"tests/**/*.py" = ["D1"]
|
|
49
|
+
|
|
50
|
+
[tool.ruff.lint.isort]
|
|
51
|
+
# Sovara's own packages sort together, as in the sovara repository.
|
|
52
|
+
known-first-party = ["sovara", "sovara_common", "sovara_claude_code", "tests"]
|
|
53
|
+
|
|
54
|
+
[tool.pytest.ini_options]
|
|
55
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Record Claude Code sessions in Sovara and add relevant lessons to each prompt.
|
|
2
|
+
|
|
3
|
+
Claude Code runs ``sovara-claude-code hook`` for each hook event (see the plugin in this
|
|
4
|
+
package's ``plugin/`` directory). ``handle(event)`` is the same entry point for tests.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from sovara_claude_code._hooks import handle
|
|
8
|
+
|
|
9
|
+
__all__ = ["handle"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Command line: ``sovara-claude-code hook`` reads one Claude Code hook event from stdin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from sovara_claude_code._hooks import handle
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(argv: list[str] | None = None) -> int:
|
|
12
|
+
"""Run a hook and print its output for Claude Code; always exits 0."""
|
|
13
|
+
args = sys.argv[1:] if argv is None else argv
|
|
14
|
+
if args != ["hook"]:
|
|
15
|
+
print("usage: sovara-claude-code hook", file=sys.stderr)
|
|
16
|
+
return 0
|
|
17
|
+
try:
|
|
18
|
+
event = json.loads(sys.stdin.read() or "{}")
|
|
19
|
+
except ValueError:
|
|
20
|
+
return 0
|
|
21
|
+
output = handle(event) if isinstance(event, dict) else None
|
|
22
|
+
if output:
|
|
23
|
+
print(output)
|
|
24
|
+
return 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if __name__ == "__main__":
|
|
28
|
+
sys.exit(main())
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Record a Claude Code session in Sovara from its hook events.
|
|
2
|
+
|
|
3
|
+
Hooks run as separate processes, so the session's recording is shared through a handle
|
|
4
|
+
(``sovara.open_recording``) kept in the session's state file. A hook never blocks Claude Code:
|
|
5
|
+
failures are swallowed and requests retry only briefly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
import sovara
|
|
20
|
+
from sovara import (
|
|
21
|
+
ConversationInput,
|
|
22
|
+
ConversationOutput,
|
|
23
|
+
ErrorInfo,
|
|
24
|
+
ErrorOutcome,
|
|
25
|
+
LlmCall,
|
|
26
|
+
Message,
|
|
27
|
+
SuccessOutcome,
|
|
28
|
+
TextBlock,
|
|
29
|
+
ToolCall,
|
|
30
|
+
)
|
|
31
|
+
from sovara.integration import flush_recordings, json_value, short_retries
|
|
32
|
+
from sovara_claude_code import _state
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger("sovara_claude_code")
|
|
35
|
+
|
|
36
|
+
# Per-request timeout, so a hook's usual three requests fit in the 15 s Claude Code allows
|
|
37
|
+
# it (hooks/hooks.json).
|
|
38
|
+
REQUEST_TIMEOUT_SECONDS = 4.0
|
|
39
|
+
# How long a hook waits, before exiting, for reports the SDK sends in the background.
|
|
40
|
+
FLUSH_SECONDS = REQUEST_TIMEOUT_SECONDS
|
|
41
|
+
|
|
42
|
+
# Errors meaning the recording no longer exists. A write token the server no longer knows
|
|
43
|
+
# is a SovaraConfigError with HTTP 403.
|
|
44
|
+
_LOST_RECORDING = {"recording_inactive", "not_found"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _recording_lost(exc: Exception) -> bool:
|
|
48
|
+
if isinstance(exc, sovara.SovaraConfigError):
|
|
49
|
+
return exc.status_code == 403
|
|
50
|
+
return isinstance(exc, sovara.RecordingError) and exc.code in _LOST_RECORDING
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _project_name(event: dict[str, Any]) -> str:
|
|
54
|
+
configured = os.environ.get("SOVARA_PROJECT", "").strip()
|
|
55
|
+
return configured or Path(str(event.get("cwd") or os.getcwd())).name or "claude-code"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _text_message(role: str, text: str) -> Message:
|
|
59
|
+
return Message(role=role, content=(TextBlock(text=text),)) # type: ignore[arg-type]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _open(event: dict[str, Any], http_client: httpx.Client | None) -> str:
|
|
63
|
+
"""Open a recording of the session's trace; the same session always gets the same trace."""
|
|
64
|
+
return sovara.open_recording(
|
|
65
|
+
project_name=_project_name(event),
|
|
66
|
+
name="Claude Code session",
|
|
67
|
+
trace_id=f"claude-code:{event['session_id']}",
|
|
68
|
+
agent_token=os.environ.get("SOVARA_AGENT_TOKEN") or None,
|
|
69
|
+
http_client=http_client,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def session_start(event: dict[str, Any], http_client: httpx.Client | None = None) -> str | None:
|
|
74
|
+
"""Open the session's recording, or keep it on resume, clear and compact."""
|
|
75
|
+
session_id = event["session_id"]
|
|
76
|
+
_state.prune()
|
|
77
|
+
if _state.load(session_id) is not None:
|
|
78
|
+
return None
|
|
79
|
+
_state.save(session_id, {"session": _open(event, http_client), "turn": None, "prompt": None})
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _start_turn(session: str, prompt: str, http_client: httpx.Client | None) -> str | None:
|
|
84
|
+
with sovara.resume_recording(session, http_client=http_client):
|
|
85
|
+
sovara.start_turn(turn_input=prompt)
|
|
86
|
+
return sovara.export_recording()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _lessons(handle: str, prompt: str, http_client: httpx.Client | None) -> Any:
|
|
90
|
+
# Retrieved, not applied: Claude Code does not show what reaches the model.
|
|
91
|
+
with sovara.resume_recording(handle, http_client=http_client):
|
|
92
|
+
return sovara.retrieve_lessons(prompt)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def user_prompt_submit(
|
|
96
|
+
event: dict[str, Any], http_client: httpx.Client | None = None
|
|
97
|
+
) -> str | None:
|
|
98
|
+
"""Start a turn for the prompt and return matching lessons as extra context."""
|
|
99
|
+
session_id = event["session_id"]
|
|
100
|
+
state = _state.load(session_id)
|
|
101
|
+
if state is None:
|
|
102
|
+
# SessionStart could not open the recording; the trace key is stable, so open it now.
|
|
103
|
+
state = {"session": _open(event, http_client), "turn": None, "prompt": None}
|
|
104
|
+
_state.save(session_id, state)
|
|
105
|
+
prompt = str(event.get("prompt") or "")
|
|
106
|
+
try:
|
|
107
|
+
turn = _start_turn(state["session"], prompt, http_client)
|
|
108
|
+
except (sovara.RecordingError, sovara.SovaraConfigError) as exc:
|
|
109
|
+
# Only a recording the exec server lost (e.g. after a restart) is reopened; a passing
|
|
110
|
+
# failure must not open a second recording of the session.
|
|
111
|
+
if not _recording_lost(exc):
|
|
112
|
+
raise
|
|
113
|
+
state = {**state, "session": _open(event, http_client)}
|
|
114
|
+
turn = _start_turn(state["session"], prompt, http_client)
|
|
115
|
+
# Saved before retrieving lessons, so later steps join this turn even if retrieval fails.
|
|
116
|
+
_state.save(session_id, {**state, "turn": turn, "prompt": prompt})
|
|
117
|
+
try:
|
|
118
|
+
lessons = _lessons(turn or state["session"], prompt, http_client) if prompt else None
|
|
119
|
+
except sovara.RecordingError:
|
|
120
|
+
lessons = None
|
|
121
|
+
if lessons is None:
|
|
122
|
+
return None
|
|
123
|
+
return json.dumps(
|
|
124
|
+
{
|
|
125
|
+
"hookSpecificOutput": {
|
|
126
|
+
"hookEventName": "UserPromptSubmit",
|
|
127
|
+
"additionalContext": lessons.text,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _record(
|
|
134
|
+
state: dict[str, Any], call: Any, outcome: Any, http_client: httpx.Client | None
|
|
135
|
+
) -> None:
|
|
136
|
+
handle = state.get("turn") or state["session"]
|
|
137
|
+
with sovara.resume_recording(handle, http_client=http_client):
|
|
138
|
+
sovara.record_step(call, outcome)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _tool_call(event: dict[str, Any]) -> ToolCall:
|
|
142
|
+
arguments = json_value(event.get("tool_input"))
|
|
143
|
+
return ToolCall(
|
|
144
|
+
name=str(event.get("tool_name") or "tool"),
|
|
145
|
+
arguments=arguments if isinstance(arguments, dict) else {"input": arguments},
|
|
146
|
+
call_id=str(event.get("tool_use_id") or "").strip() or None,
|
|
147
|
+
metadata={"source": "claude-code"},
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def post_tool_use(event: dict[str, Any], http_client: httpx.Client | None = None) -> str | None:
|
|
152
|
+
"""Record a tool call that succeeded."""
|
|
153
|
+
state = _state.load(event["session_id"])
|
|
154
|
+
if state is not None:
|
|
155
|
+
outcome = SuccessOutcome(output=json_value(event.get("tool_response")))
|
|
156
|
+
_record(state, _tool_call(event), outcome, http_client)
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def post_tool_use_failure(
|
|
161
|
+
event: dict[str, Any], http_client: httpx.Client | None = None
|
|
162
|
+
) -> str | None:
|
|
163
|
+
"""Record a tool call that failed or was interrupted."""
|
|
164
|
+
state = _state.load(event["session_id"])
|
|
165
|
+
if state is not None:
|
|
166
|
+
error = ErrorInfo(type="ToolError", message=str(event.get("error") or ""))
|
|
167
|
+
_record(state, _tool_call(event), ErrorOutcome(error=error), http_client)
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def stop(event: dict[str, Any], http_client: httpx.Client | None = None) -> str | None:
|
|
172
|
+
"""Record Claude's reply to the turn's prompt as a model step."""
|
|
173
|
+
state = _state.load(event["session_id"])
|
|
174
|
+
reply = str(event.get("last_assistant_message") or "")
|
|
175
|
+
if state is None or not reply or event.get("stop_hook_active"):
|
|
176
|
+
return None
|
|
177
|
+
prompt = str(state.get("prompt") or "")
|
|
178
|
+
call = LlmCall(
|
|
179
|
+
provider="anthropic",
|
|
180
|
+
input=ConversationInput(messages=(_text_message("user", prompt),) if prompt else ()),
|
|
181
|
+
metadata={"source": "claude-code"},
|
|
182
|
+
)
|
|
183
|
+
outcome = SuccessOutcome(output=ConversationOutput(message=_text_message("assistant", reply)))
|
|
184
|
+
_record(state, call, outcome, http_client)
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def session_end(event: dict[str, Any], http_client: httpx.Client | None = None) -> str | None:
|
|
189
|
+
"""Close the session's recording and forget its state."""
|
|
190
|
+
session_id = event["session_id"]
|
|
191
|
+
state = _state.load(session_id)
|
|
192
|
+
if state is None:
|
|
193
|
+
return None
|
|
194
|
+
try:
|
|
195
|
+
sovara.close_recording(state["session"], http_client=http_client)
|
|
196
|
+
finally:
|
|
197
|
+
_state.delete(session_id)
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
HANDLERS = {
|
|
202
|
+
"SessionStart": session_start,
|
|
203
|
+
"UserPromptSubmit": user_prompt_submit,
|
|
204
|
+
"PostToolUse": post_tool_use,
|
|
205
|
+
"PostToolUseFailure": post_tool_use_failure,
|
|
206
|
+
"Stop": stop,
|
|
207
|
+
"SessionEnd": session_end,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def handle(event: dict[str, Any], http_client: httpx.Client | None = None) -> str | None:
|
|
212
|
+
"""Run the handler for one hook event; return text for Claude Code, or None.
|
|
213
|
+
|
|
214
|
+
Never raises: a failure is logged by type only and the session continues unrecorded.
|
|
215
|
+
"""
|
|
216
|
+
handler = HANDLERS.get(str(event.get("hook_event_name")))
|
|
217
|
+
if handler is None or not event.get("session_id"):
|
|
218
|
+
return None
|
|
219
|
+
try:
|
|
220
|
+
with short_retries(), contextlib.ExitStack() as stack:
|
|
221
|
+
if http_client is None:
|
|
222
|
+
http_client = stack.enter_context(httpx.Client(timeout=REQUEST_TIMEOUT_SECONDS))
|
|
223
|
+
output = handler(event, http_client)
|
|
224
|
+
# Reports queued in the background use http_client, which closes on return.
|
|
225
|
+
flush_recordings(FLUSH_SECONDS)
|
|
226
|
+
return output
|
|
227
|
+
except Exception as exc:
|
|
228
|
+
logger.warning(
|
|
229
|
+
"Sovara Claude Code hook failed event=%s error_type=%s",
|
|
230
|
+
event.get("hook_event_name"),
|
|
231
|
+
type(exc).__name__,
|
|
232
|
+
)
|
|
233
|
+
return None
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Per-session state shared by the hook processes of one Claude Code session.
|
|
2
|
+
|
|
3
|
+
Each session has one JSON file holding its recording handles, which carry a write token: the
|
|
4
|
+
file is private (0600), written atomically, and never logged. The format is shared with the
|
|
5
|
+
TypeScript integration (``@sovara/claude-code``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
# A session whose SessionEnd never ran (a crash) is forgotten after this long.
|
|
19
|
+
STALE_AFTER_SECONDS = 7 * 24 * 3600
|
|
20
|
+
_SAFE_ID = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def state_dir() -> Path:
|
|
24
|
+
"""Return the state directory: the plugin's data directory, else ``~/.sovara/claude-code``."""
|
|
25
|
+
plugin_data = os.environ.get("CLAUDE_PLUGIN_DATA", "").strip()
|
|
26
|
+
if plugin_data:
|
|
27
|
+
return Path(plugin_data)
|
|
28
|
+
return Path.home() / ".sovara" / "claude-code"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _path(session_id: str) -> Path:
|
|
32
|
+
if not _SAFE_ID.match(session_id):
|
|
33
|
+
raise ValueError("Unsupported Claude Code session ID.")
|
|
34
|
+
return state_dir() / f"{session_id}.json"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load(session_id: str) -> dict[str, Any] | None:
|
|
38
|
+
"""Return the session's state, or None when it has none."""
|
|
39
|
+
try:
|
|
40
|
+
return json.loads(_path(session_id).read_text(encoding="utf-8"))
|
|
41
|
+
except FileNotFoundError:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def save(session_id: str, state: dict[str, Any]) -> None:
|
|
46
|
+
"""Replace the session's state atomically, readable only by the current user."""
|
|
47
|
+
path = _path(session_id)
|
|
48
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
49
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".tmp-")
|
|
50
|
+
try:
|
|
51
|
+
with os.fdopen(fd, "w", encoding="utf-8") as file:
|
|
52
|
+
json.dump(state, file)
|
|
53
|
+
os.chmod(tmp, 0o600)
|
|
54
|
+
os.replace(tmp, path)
|
|
55
|
+
except BaseException:
|
|
56
|
+
Path(tmp).unlink(missing_ok=True)
|
|
57
|
+
raise
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def delete(session_id: str) -> None:
|
|
61
|
+
"""Forget the session's state."""
|
|
62
|
+
_path(session_id).unlink(missing_ok=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def prune() -> None:
|
|
66
|
+
"""Delete state files of sessions that ended without SessionEnd."""
|
|
67
|
+
cutoff = time.time() - STALE_AFTER_SECONDS
|
|
68
|
+
for path in state_dir().glob("*.json"):
|
|
69
|
+
try:
|
|
70
|
+
if path.stat().st_mtime < cutoff:
|
|
71
|
+
path.unlink()
|
|
72
|
+
except OSError:
|
|
73
|
+
continue
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sovara-claude-code
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Record Claude Code sessions in Sovara and add relevant lessons to each prompt
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Documentation, https://docs.sovara-labs.com/integrations/claude-code
|
|
7
|
+
Project-URL: Repository, https://github.com/SovaraLabs/sovara-claude-code/tree/main/python
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: sovara<0.11,>=0.10
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# sovara-claude-code
|
|
15
|
+
|
|
16
|
+
The Python hook program for [Sovara for Claude Code](https://github.com/SovaraLabs/sovara-claude-code#readme).
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
uv tool install sovara-claude-code
|
|
20
|
+
# or: pip install sovara-claude-code
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then load the `sovara-recording` plugin.
|
|
24
|
+
|
|
25
|
+
## License
|
|
26
|
+
|
|
27
|
+
MIT.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/sovara_claude_code/__init__.py
|
|
5
|
+
src/sovara_claude_code/__main__.py
|
|
6
|
+
src/sovara_claude_code/_hooks.py
|
|
7
|
+
src/sovara_claude_code/_state.py
|
|
8
|
+
src/sovara_claude_code.egg-info/PKG-INFO
|
|
9
|
+
src/sovara_claude_code.egg-info/SOURCES.txt
|
|
10
|
+
src/sovara_claude_code.egg-info/dependency_links.txt
|
|
11
|
+
src/sovara_claude_code.egg-info/entry_points.txt
|
|
12
|
+
src/sovara_claude_code.egg-info/requires.txt
|
|
13
|
+
src/sovara_claude_code.egg-info/top_level.txt
|
|
14
|
+
tests/test_sovara_claude_code.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sovara<0.11,>=0.10
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sovara_claude_code
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""A Claude Code session recorded through its hooks, against a fake exec server."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from sovara_claude_code import _state, handle
|
|
9
|
+
from sovara_claude_code._hooks import REQUEST_TIMEOUT_SECONDS
|
|
10
|
+
|
|
11
|
+
TOKEN = "write-token-secret"
|
|
12
|
+
HEALTH = {
|
|
13
|
+
"status": "ok",
|
|
14
|
+
"service": "sovara-exec-server",
|
|
15
|
+
"compatibility": {"component": "exec-server", "contracts": {"runner-api": [6]}},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FakeExecServer:
|
|
20
|
+
"""Answer the runner-api 6 routes the hooks use and keep the requests."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, lessons: str | None = "Run the tests first.") -> None:
|
|
23
|
+
self.lessons = lessons
|
|
24
|
+
self.lost_recordings = 0
|
|
25
|
+
self.failing_turns = 0
|
|
26
|
+
self.failing_status = 500
|
|
27
|
+
self.failing_lessons = False
|
|
28
|
+
self.failing_opens = 0
|
|
29
|
+
self.requests: list[tuple[str, dict]] = []
|
|
30
|
+
|
|
31
|
+
def __call__(self, request: httpx.Request) -> httpx.Response:
|
|
32
|
+
path = request.url.path
|
|
33
|
+
if path == "/exec/health":
|
|
34
|
+
return httpx.Response(200, json=HEALTH)
|
|
35
|
+
body = json.loads(request.content or b"{}")
|
|
36
|
+
self.requests.append((path, body))
|
|
37
|
+
if path == "/exec/traces" and self.failing_opens:
|
|
38
|
+
self.failing_opens -= 1
|
|
39
|
+
return httpx.Response(500, json={"error": "internal_error", "message": "x"})
|
|
40
|
+
if path == "/exec/traces":
|
|
41
|
+
return httpx.Response(
|
|
42
|
+
200,
|
|
43
|
+
json={
|
|
44
|
+
"trace_id": "trace-1",
|
|
45
|
+
"recording_id": "rec-1",
|
|
46
|
+
"project_id": "project-1",
|
|
47
|
+
"appended": False,
|
|
48
|
+
"trace_write_token": TOKEN,
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
if path.endswith("/turns"):
|
|
52
|
+
if self.failing_turns:
|
|
53
|
+
self.failing_turns -= 1
|
|
54
|
+
return httpx.Response(self.failing_status, json={"error": "x", "message": "x"})
|
|
55
|
+
if self.lost_recordings:
|
|
56
|
+
self.lost_recordings -= 1
|
|
57
|
+
return httpx.Response(
|
|
58
|
+
409, json={"error": "recording_inactive", "message": "Not active."}
|
|
59
|
+
)
|
|
60
|
+
return httpx.Response(200, json={"turn_id": "turn-1", "recording_id": "rec-1"})
|
|
61
|
+
if path.endswith("/lesson-retrievals") and self.failing_lessons:
|
|
62
|
+
return httpx.Response(500, json={"error": "internal_error", "message": "x"})
|
|
63
|
+
if path.endswith("/lesson-retrievals"):
|
|
64
|
+
context = (
|
|
65
|
+
{"text": self.lessons, "retrieval_id": "retrieval-1"} if self.lessons else None
|
|
66
|
+
)
|
|
67
|
+
return httpx.Response(200, json={"ok": True, "lesson_context": context})
|
|
68
|
+
if path.endswith("/steps/record"):
|
|
69
|
+
return httpx.Response(200, json={"ok": True, "step_uuid": body["step_uuid"]})
|
|
70
|
+
return httpx.Response(200, json={"ok": True})
|
|
71
|
+
|
|
72
|
+
def paths(self) -> list[str]:
|
|
73
|
+
return [path.removeprefix("/exec/traces/trace-1") or path for path, _ in self.requests]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.fixture
|
|
77
|
+
def server(tmp_path, monkeypatch):
|
|
78
|
+
monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path))
|
|
79
|
+
monkeypatch.setenv("SOVARA_EXEC_SERVER_URL", "http://exec.test")
|
|
80
|
+
fake = FakeExecServer()
|
|
81
|
+
with httpx.Client(transport=httpx.MockTransport(fake)) as client:
|
|
82
|
+
yield fake, client
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _event(name: str, **fields) -> dict:
|
|
86
|
+
return {"hook_event_name": name, "session_id": "s1", "cwd": "/work/my-repo", **fields}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_a_session_is_recorded_with_lessons_tools_and_the_reply(server, tmp_path, caplog):
|
|
90
|
+
fake, client = server
|
|
91
|
+
assert handle(_event("SessionStart", source="startup"), client) is None
|
|
92
|
+
output = handle(_event("UserPromptSubmit", prompt="Fix the build"), client)
|
|
93
|
+
handle(
|
|
94
|
+
_event(
|
|
95
|
+
"PostToolUse",
|
|
96
|
+
tool_name="Bash",
|
|
97
|
+
tool_input={"command": "make"},
|
|
98
|
+
tool_response={"stdout": "ok"},
|
|
99
|
+
tool_use_id="t1",
|
|
100
|
+
),
|
|
101
|
+
client,
|
|
102
|
+
)
|
|
103
|
+
handle(
|
|
104
|
+
_event(
|
|
105
|
+
"PostToolUseFailure",
|
|
106
|
+
tool_name="Bash",
|
|
107
|
+
tool_input={"command": "x"},
|
|
108
|
+
error="Exit code 1",
|
|
109
|
+
tool_use_id="t2",
|
|
110
|
+
),
|
|
111
|
+
client,
|
|
112
|
+
)
|
|
113
|
+
handle(_event("Stop", last_assistant_message="Fixed it."), client)
|
|
114
|
+
handle(_event("SessionEnd", reason="other"), client)
|
|
115
|
+
|
|
116
|
+
assert json.loads(output or "")["hookSpecificOutput"]["additionalContext"] == (
|
|
117
|
+
"Run the tests first."
|
|
118
|
+
)
|
|
119
|
+
assert fake.paths() == [
|
|
120
|
+
"/exec/traces",
|
|
121
|
+
"/turns",
|
|
122
|
+
"/lesson-retrievals",
|
|
123
|
+
"/steps/record",
|
|
124
|
+
"/steps/record",
|
|
125
|
+
"/steps/record",
|
|
126
|
+
"/complete",
|
|
127
|
+
]
|
|
128
|
+
opened = fake.requests[0][1]
|
|
129
|
+
assert (opened["project_name"], opened["trace_id"]) == ("my-repo", "claude-code:s1")
|
|
130
|
+
tool, failed, reply = (body for path, body in fake.requests if path.endswith("/record"))
|
|
131
|
+
assert tool["call"]["value"]["name"] == "Bash" and tool["turn_id"] == "turn-1"
|
|
132
|
+
assert failed["outcome"]["value"]["status"] == "error"
|
|
133
|
+
assert reply["call"]["value"]["kind"] == "llm"
|
|
134
|
+
assert reply["outcome"]["value"]["output"]["message"]["content"][0]["text"] == "Fixed it."
|
|
135
|
+
assert list(tmp_path.glob("*.json")) == []
|
|
136
|
+
assert TOKEN not in caplog.text
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_a_resumed_session_keeps_its_recording(server):
|
|
140
|
+
fake, client = server
|
|
141
|
+
handle(_event("SessionStart", source="startup"), client)
|
|
142
|
+
handle(_event("SessionStart", source="resume"), client)
|
|
143
|
+
assert fake.paths() == ["/exec/traces"]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_state_is_private_and_failures_never_reach_claude_code(server, tmp_path, monkeypatch):
|
|
147
|
+
fake, client = server
|
|
148
|
+
handle(_event("SessionStart", source="startup"), client)
|
|
149
|
+
assert oct((tmp_path / "s1.json").stat().st_mode & 0o777) == "0o600"
|
|
150
|
+
|
|
151
|
+
def down(request):
|
|
152
|
+
raise httpx.ConnectError("down", request=request)
|
|
153
|
+
|
|
154
|
+
with httpx.Client(transport=httpx.MockTransport(down)) as broken:
|
|
155
|
+
assert handle(_event("UserPromptSubmit", prompt="hi"), broken) is None
|
|
156
|
+
assert handle({"hook_event_name": "Unknown"}, client) is None
|
|
157
|
+
with pytest.raises(ValueError):
|
|
158
|
+
_state.load("../escape")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_a_recording_lost_by_the_server_is_reopened_at_the_next_prompt(server):
|
|
162
|
+
fake, client = server
|
|
163
|
+
handle(_event("SessionStart", source="startup"), client)
|
|
164
|
+
fake.lost_recordings = 1 # e.g. the exec server restarted
|
|
165
|
+
|
|
166
|
+
output = handle(_event("UserPromptSubmit", prompt="Fix the build"), client)
|
|
167
|
+
|
|
168
|
+
assert output is not None
|
|
169
|
+
assert fake.paths() == [
|
|
170
|
+
"/exec/traces",
|
|
171
|
+
"/turns",
|
|
172
|
+
"/exec/traces",
|
|
173
|
+
"/turns",
|
|
174
|
+
"/lesson-retrievals",
|
|
175
|
+
]
|
|
176
|
+
assert fake.requests[2][1]["trace_id"] == "claude-code:s1"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@pytest.mark.parametrize("status", [500, 422])
|
|
180
|
+
def test_a_passing_failure_does_not_open_a_second_recording(server, status):
|
|
181
|
+
fake, client = server
|
|
182
|
+
handle(_event("SessionStart", source="startup"), client)
|
|
183
|
+
fake.failing_turns, fake.failing_status = 1, status
|
|
184
|
+
|
|
185
|
+
assert handle(_event("UserPromptSubmit", prompt="hi"), client) is None
|
|
186
|
+
assert fake.paths() == ["/exec/traces", "/turns"]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_a_failed_lesson_retrieval_still_starts_the_turn(server):
|
|
190
|
+
fake, client = server
|
|
191
|
+
handle(_event("SessionStart", source="startup"), client)
|
|
192
|
+
fake.failing_lessons = True
|
|
193
|
+
|
|
194
|
+
assert handle(_event("UserPromptSubmit", prompt="second"), client) is None
|
|
195
|
+
handle(_event("Stop", last_assistant_message="Done."), client)
|
|
196
|
+
|
|
197
|
+
reply = fake.requests[-1][1]
|
|
198
|
+
assert reply["turn_id"] == "turn-1"
|
|
199
|
+
assert reply["call"]["value"]["input"]["messages"][0]["content"][0]["text"] == "second"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_a_session_that_could_not_start_is_recorded_from_the_next_prompt(server):
|
|
203
|
+
fake, client = server
|
|
204
|
+
fake.failing_opens = 1
|
|
205
|
+
handle(_event("SessionStart", source="startup"), client)
|
|
206
|
+
|
|
207
|
+
assert handle(_event("UserPromptSubmit", prompt="hi"), client) is not None
|
|
208
|
+
assert fake.paths() == ["/exec/traces", "/exec/traces", "/turns", "/lesson-retrievals"]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_hooks_without_a_client_time_out_within_the_hook_limit(server, monkeypatch):
|
|
212
|
+
fake, _ = server
|
|
213
|
+
timeouts = []
|
|
214
|
+
real_client = httpx.Client
|
|
215
|
+
|
|
216
|
+
def client(**kwargs):
|
|
217
|
+
timeouts.append(kwargs["timeout"])
|
|
218
|
+
return real_client(transport=httpx.MockTransport(fake), **kwargs)
|
|
219
|
+
|
|
220
|
+
monkeypatch.setattr("sovara_claude_code._hooks.httpx.Client", client)
|
|
221
|
+
handle(_event("SessionStart", source="startup"))
|
|
222
|
+
assert handle(_event("UserPromptSubmit", prompt="hi")) is not None
|
|
223
|
+
assert timeouts == [REQUEST_TIMEOUT_SECONDS] * 2
|