-
Notifications
You must be signed in to change notification settings - Fork 2
Add a Claude Code session-transcript adapter #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ejentum
wants to merge
2
commits into
lamenting-hawthorn:main
Choose a base branch
from
ejentum:feat/claude-code-adapter
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 1 commit
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,211 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| from datetime import datetime | ||
| from pathlib import Path | ||
| from typing import Any | ||
|
|
||
| from skillloop.schema import AgentMessage, AgentTrace, ToolCall, sha256_text | ||
|
|
||
| ADAPTER_NAME = "claude_code" | ||
| ADAPTER_VERSION = "1.0" | ||
|
|
||
| _VALID_ROLES = {"system", "user", "assistant", "tool"} | ||
|
|
||
|
|
||
| def _message_obj(line: dict[str, Any]) -> dict[str, Any] | None: | ||
| """Return the message dict for a transcript line, or None for meta lines. | ||
|
|
||
| Claude Code session transcripts interleave message lines | ||
| (``{"message": {"role", "content": [...]}, ...}``) with non-message meta | ||
| lines (operation events, summaries) that carry no role/content. | ||
| """ | ||
| msg = line.get("message") | ||
| if isinstance(msg, dict) and msg.get("role"): | ||
| return msg | ||
| if line.get("role") and "content" in line: | ||
| return line | ||
| return None | ||
|
|
||
|
|
||
| def _text_from_blocks(blocks: list[Any]) -> str: | ||
| parts: list[str] = [] | ||
| for block in blocks: | ||
| if isinstance(block, dict) and block.get("type") == "text" and block.get("text"): | ||
| parts.append(str(block["text"])) | ||
| return "\n".join(parts) | ||
|
|
||
|
|
||
| def _tool_result_text(content: Any) -> str: | ||
| if content is None: | ||
| return "" | ||
| if isinstance(content, str): | ||
| return content | ||
| if isinstance(content, list): | ||
| parts: list[str] = [] | ||
| for block in content: | ||
| if isinstance(block, dict): | ||
| if block.get("type") == "text" and block.get("text") is not None: | ||
| parts.append(str(block["text"])) | ||
| elif block.get("content") is not None: | ||
| parts.append(str(block["content"])) | ||
| else: | ||
| parts.append(str(block)) | ||
| return "\n".join(parts) | ||
| return str(content) | ||
|
|
||
|
|
||
| def _parse_ts(value: Any) -> datetime | None: | ||
| if not value: | ||
| return None | ||
| try: | ||
| return datetime.fromisoformat(str(value).replace("Z", "+00:00")) | ||
| except ValueError: | ||
| return None | ||
|
|
||
|
|
||
| def normalize_claude_code_session(raw_text: str, *, include_sidechains: bool = True) -> tuple[list[AgentMessage], dict[str, Any]]: | ||
| lines: list[dict[str, Any]] = [] | ||
| for raw in raw_text.splitlines(): | ||
| raw = raw.strip() | ||
| if not raw: | ||
| continue | ||
| try: | ||
| parsed = json.loads(raw) | ||
| except json.JSONDecodeError: | ||
| continue # tolerate a partial trailing line on a live session | ||
| if isinstance(parsed, dict): | ||
| lines.append(parsed) | ||
| if not include_sidechains: | ||
| # Drop subagent sidechain turns before both passes so tool_use/tool_result | ||
| # matching stays internally consistent. | ||
| lines = [line for line in lines if not line.get("isSidechain")] | ||
|
|
||
| # Pass 1: index tool_result blocks by tool_use_id. In the Anthropic message | ||
| # format these arrive in a later user turn, so results are matched back to | ||
| # the assistant's originating tool_use across messages. | ||
| results: dict[str, dict[str, Any]] = {} | ||
| for line in lines: | ||
| msg = _message_obj(line) | ||
| if not msg or not isinstance(msg.get("content"), list): | ||
| continue | ||
| for block in msg["content"]: | ||
| if isinstance(block, dict) and block.get("type") == "tool_result" and block.get("tool_use_id"): | ||
| results[str(block["tool_use_id"])] = { | ||
| "result": _tool_result_text(block.get("content")), | ||
| "is_error": bool(block.get("is_error")), | ||
| "ended_at": line.get("timestamp"), | ||
| } | ||
|
|
||
| # Pass 2: build normalized messages. | ||
| messages: list[AgentMessage] = [] | ||
| session_id: Any = None | ||
| for line in lines: | ||
| if session_id is None and line.get("sessionId"): | ||
| session_id = line.get("sessionId") | ||
| msg = _message_obj(line) | ||
| if not msg: | ||
| continue | ||
| role = str(msg.get("role") or "") | ||
| if role not in _VALID_ROLES: | ||
| continue | ||
| content = msg.get("content") | ||
| ts = line.get("timestamp") | ||
|
|
||
| text = "" | ||
| thinking_parts: list[str] = [] | ||
| redacted_thinking = False | ||
| tool_calls: list[ToolCall] = [] | ||
| if isinstance(content, str): | ||
| text = content | ||
| elif isinstance(content, list): | ||
| text = _text_from_blocks(content) | ||
| for block in content: | ||
| if not isinstance(block, dict): | ||
| continue | ||
| btype = block.get("type") | ||
| if btype == "thinking": | ||
| # Forward-compatible: capture extended-thinking text when the | ||
| # provider includes it. NOTE: Claude Code persists thinking | ||
| # blocks with an empty text field plus a signature, so the | ||
| # reasoning text is stripped from the transcript and there is | ||
| # nothing to preserve from a Claude Code session. | ||
| if block.get("thinking"): | ||
| thinking_parts.append(str(block["thinking"])) | ||
| elif btype == "redacted_thinking": | ||
| redacted_thinking = True | ||
| elif btype == "tool_use": | ||
| tuid = str(block.get("id") or "") | ||
| res = results.get(tuid, {}) | ||
| raw_args = block.get("input") | ||
| arguments = raw_args if isinstance(raw_args, dict) else {} | ||
| started, ended = _parse_ts(ts), _parse_ts(res.get("ended_at")) | ||
| duration_ms = int((ended - started).total_seconds() * 1000) if (started and ended) else None | ||
| status = ("error" if res.get("is_error") else "success") if res else "unknown" | ||
| tool_calls.append( | ||
| ToolCall( | ||
| name=str(block.get("name") or "unknown"), | ||
| arguments=arguments, | ||
| result=res.get("result"), | ||
| id=tuid or None, | ||
| started_at=ts, | ||
| ended_at=res.get("ended_at"), | ||
| duration_ms=duration_ms, | ||
| status=status, | ||
| ) | ||
| ) | ||
|
|
||
| # Skip turns with no usable signal (e.g. a pure tool_result user turn). | ||
| if not text.strip() and not tool_calls and not thinking_parts: | ||
| continue | ||
|
|
||
| metadata = { | ||
| k: line[k] | ||
| for k in ("uuid", "parentUuid", "isSidechain", "timestamp") | ||
| if line.get(k) is not None | ||
| } | ||
| # Preserve extended-thinking reasoning out of band so it is not lost, | ||
| # while keeping the human-readable content field clean. | ||
| if thinking_parts: | ||
| metadata["thinking"] = "\n".join(thinking_parts) | ||
| if redacted_thinking: | ||
| metadata["thinking_redacted"] = True | ||
|
|
||
| messages.append( | ||
| AgentMessage(role=role, content=text, tool_calls=tool_calls, metadata=metadata) | ||
| ) | ||
|
|
||
| meta = {"session_id": session_id, "line_count": len(lines), "message_count": len(messages)} | ||
| return messages, meta | ||
|
|
||
|
|
||
| def load_claude_code_session(path: str | Path, *, include_sidechains: bool = True) -> AgentTrace: | ||
| source_path = Path(path).expanduser() | ||
| raw_text = source_path.read_text(encoding="utf-8") | ||
| messages, meta = normalize_claude_code_session(raw_text, include_sidechains=include_sidechains) | ||
| if not messages: | ||
| raise ValueError(f"No usable messages found in Claude Code session: {source_path}") | ||
| return AgentTrace( | ||
| source="claude_code", | ||
| messages=messages, | ||
| adapter={"name": ADAPTER_NAME, "version": ADAPTER_VERSION}, | ||
| runtime={"name": "claude_code"}, | ||
| metadata={"path": str(source_path), "project": source_path.parent.name, **meta}, | ||
| raw_artifact_ref=str(source_path), | ||
| raw_trace_sha256=sha256_text(raw_text), | ||
| ) | ||
|
|
||
|
|
||
| def latest_claude_code_session(projects_dir: str | Path | None = None, project: str | None = None) -> Path: | ||
| """Newest Claude Code session transcript, without mutating anything. | ||
|
|
||
| Session files live one level under each project: ``<projects_dir>/<slug>/<id>.jsonl``. | ||
| Subagent transcripts under ``<slug>/subagents/`` are intentionally excluded. | ||
| """ | ||
| base = Path(projects_dir).expanduser() if projects_dir else (Path.home() / ".claude" / "projects") | ||
| if not base.exists(): | ||
| raise FileNotFoundError(f"Claude Code projects dir not found: {base}") | ||
| candidates = list((base / project).glob("*.jsonl")) if project else list(base.glob("*/*.jsonl")) | ||
| if not candidates: | ||
| raise FileNotFoundError(f"No Claude Code session transcripts found under {base}") | ||
| return max(candidates, key=lambda p: p.stat().st_mtime) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import os | ||
|
|
||
| from skillloop.adapters.claude_code import latest_claude_code_session, load_claude_code_session | ||
|
|
||
|
|
||
| def _write_session(path, lines): | ||
| path.write_text("\n".join(json.dumps(line) for line in lines) + "\n", encoding="utf-8") | ||
|
|
||
|
|
||
| def test_claude_code_adapter_parses_blocks_and_tool_results(tmp_path): | ||
| lines = [ | ||
| {"type": "summary", "summary": "meta line, no message"}, | ||
| {"type": "user", "uuid": "u1", "timestamp": "2026-06-13T10:00:00Z", | ||
| "message": {"role": "user", "content": [{"type": "text", "text": "fix the bug"}]}}, | ||
| {"type": "assistant", "uuid": "a1", "timestamp": "2026-06-13T10:00:01Z", | ||
| "message": {"role": "assistant", "content": [ | ||
| {"type": "text", "text": "running tests"}, | ||
| {"type": "tool_use", "id": "toolu_1", "name": "Bash", "input": {"command": "pytest"}}, | ||
| ]}}, | ||
| {"type": "user", "uuid": "u2", "timestamp": "2026-06-13T10:00:03Z", | ||
| "message": {"role": "user", "content": [ | ||
| {"type": "tool_result", "tool_use_id": "toolu_1", "content": "2 passed", "is_error": False}, | ||
| ]}}, | ||
| {"type": "assistant", "uuid": "a2", "timestamp": "2026-06-13T10:00:04Z", | ||
| "message": {"role": "assistant", "content": [{"type": "text", "text": "done, tests pass"}]}}, | ||
| ] | ||
| session = tmp_path / "session.jsonl" | ||
| _write_session(session, lines) | ||
|
|
||
| trace = load_claude_code_session(session) | ||
|
|
||
| assert trace.source == "claude_code" | ||
| assert trace.adapter["name"] == "claude_code" | ||
| # the meta line and the pure tool_result turn carry no signal and are dropped | ||
| assert [m.role for m in trace.messages] == ["user", "assistant", "assistant"] | ||
| assert trace.messages[0].content == "fix the bug" | ||
|
|
||
| asst = trace.messages[1] | ||
| assert asst.content == "running tests" | ||
| assert len(asst.tool_calls) == 1 | ||
| call = asst.tool_calls[0] | ||
| assert call.name == "Bash" | ||
| assert call.arguments == {"command": "pytest"} | ||
| assert call.result == "2 passed" | ||
| assert call.status == "success" | ||
| assert call.success is True | ||
| assert call.duration_ms == 2000 # 10:00:01 -> 10:00:03 | ||
|
|
||
| assert trace.messages[2].content == "done, tests pass" | ||
|
|
||
|
|
||
| def test_claude_code_adapter_rejects_empty_session(tmp_path): | ||
| session = tmp_path / "empty.jsonl" | ||
| _write_session(session, [{"type": "summary", "summary": "only meta"}]) | ||
| try: | ||
| load_claude_code_session(session) | ||
| except ValueError: | ||
| return | ||
| raise AssertionError("expected ValueError for a session with no usable messages") | ||
|
|
||
|
|
||
| def test_latest_claude_code_session_picks_newest(tmp_path): | ||
| proj = tmp_path / "proj-a" | ||
| proj.mkdir() | ||
| older, newer = proj / "older.jsonl", proj / "newer.jsonl" | ||
| msg = {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "hi"}]}} | ||
| _write_session(older, [msg]) | ||
| _write_session(newer, [msg]) | ||
| os.utime(older, (1, 1)) | ||
| os.utime(newer, (10, 10)) | ||
| assert latest_claude_code_session(tmp_path).name == "newer.jsonl" | ||
|
|
||
|
|
||
| def test_claude_code_preserves_thinking_in_metadata(tmp_path): | ||
| lines = [ | ||
| {"type": "assistant", "uuid": "a1", "timestamp": "2026-06-13T10:00:00Z", | ||
| "message": {"role": "assistant", "content": [ | ||
| {"type": "thinking", "thinking": "the user wants X, so I should do Y"}, | ||
| {"type": "text", "text": "here is the answer"}, | ||
| ]}}, | ||
| ] | ||
| session = tmp_path / "thinking.jsonl" | ||
| _write_session(session, lines) | ||
| trace = load_claude_code_session(session) | ||
|
|
||
| assert len(trace.messages) == 1 | ||
| msg = trace.messages[0] | ||
| assert msg.content == "here is the answer" # content is not muddied by thinking | ||
| assert msg.metadata.get("thinking") == "the user wants X, so I should do Y" | ||
|
|
||
|
|
||
| def test_claude_code_no_sidechains_filter(tmp_path): | ||
| lines = [ | ||
| {"type": "user", "uuid": "u1", | ||
| "message": {"role": "user", "content": [{"type": "text", "text": "main turn"}]}}, | ||
| {"type": "assistant", "uuid": "s1", "isSidechain": True, | ||
| "message": {"role": "assistant", "content": [{"type": "text", "text": "subagent turn"}]}}, | ||
| ] | ||
| session = tmp_path / "sidechain.jsonl" | ||
| _write_session(session, lines) | ||
|
|
||
| full = load_claude_code_session(session, include_sidechains=True) | ||
| assert [m.content for m in full.messages] == ["main turn", "subagent turn"] | ||
|
|
||
| filtered = load_claude_code_session(session, include_sidechains=False) | ||
| assert [m.content for m in filtered.messages] == ["main turn"] | ||
|
|
||
|
|
||
| def test_claude_code_empty_thinking_text_is_not_preserved(tmp_path): | ||
| # Claude Code persists thinking blocks with empty text and only a signature, | ||
| # so there is no reasoning text to capture. | ||
| lines = [ | ||
| {"type": "assistant", "uuid": "a1", | ||
| "message": {"role": "assistant", "content": [ | ||
| {"type": "thinking", "thinking": "", "signature": "sig-abc"}, | ||
| {"type": "text", "text": "answer"}, | ||
| ]}}, | ||
| ] | ||
| session = tmp_path / "empty_think.jsonl" | ||
| _write_session(session, lines) | ||
| msg = load_claude_code_session(session).messages[0] | ||
| assert msg.content == "answer" | ||
| assert "thinking" not in msg.metadata |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.