sovara-claude-code 0.1.0__py3-none-any.whl

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,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,10 @@
1
+ sovara_claude_code/__init__.py,sha256=hVA9WA0aYotV80afQep2KvRWtMhWiSAOQSA9pqxjnio,331
2
+ sovara_claude_code/__main__.py,sha256=ZmABKZCT2PSVbZbs5NFxATvXwasVbWseLZkMU8E9o9Y,747
3
+ sovara_claude_code/_hooks.py,sha256=sXown-DTDdxUbL9ljnldOrRyau7RsNeUphDe_4WW50A,8737
4
+ sovara_claude_code/_state.py,sha256=CjxbmPGnCJ8PkAvCxRAmQ4QBsM2QJHfHsyXqe2IcXmI,2350
5
+ sovara_claude_code-0.1.0.dist-info/licenses/LICENSE,sha256=pHqKd1w5Do29Q1QUMye-4Tz6olJxRgwzNYqcjjfN524,1068
6
+ sovara_claude_code-0.1.0.dist-info/METADATA,sha256=ei8tu6TJCSpHKqar_v0Q2QPDnIwuaw9N0LUIPXM9CfM,765
7
+ sovara_claude_code-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
8
+ sovara_claude_code-0.1.0.dist-info/entry_points.txt,sha256=6b7vUYeWpouL4cpRZhrNnBMxO49XH8B1fpcopXfY1o8,72
9
+ sovara_claude_code-0.1.0.dist-info/top_level.txt,sha256=mcRSgbLP5u5910l7XJHYHzeILjXPrsba_1jfjvjfKTE,19
10
+ sovara_claude_code-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sovara-claude-code = sovara_claude_code.__main__:main
@@ -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 @@
1
+ sovara_claude_code