Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 211 additions & 0 deletions skillloop/adapters/claude_code.py
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]] = []
raw_lines = [raw.strip() for raw in raw_text.splitlines() if raw.strip()]
for index, raw in enumerate(raw_lines):
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
if index == len(raw_lines) - 1:
continue # tolerate a partial trailing line on a live session
raise ValueError(f"Malformed JSONL line {index + 1} in Claude Code session") from exc
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)
18 changes: 15 additions & 3 deletions skillloop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import sys
from pathlib import Path

from skillloop.adapters.claude_code import latest_claude_code_session, load_claude_code_session
from skillloop.adapters.generic_jsonl import load_generic_jsonl
from skillloop.adapters.hermes import load_hermes_export, load_hermes_state_db
from skillloop.apply.filesystem import export_approved
Expand Down Expand Up @@ -66,6 +67,14 @@ def cmd_ingest(args: argparse.Namespace) -> int:
trace = load_hermes_export(args.input)
elif args.adapter == "hermes-db":
trace = load_hermes_state_db(args.db_path, session_id=args.session_id, latest=args.latest)
elif args.adapter == "claude-code":
if args.latest:
source = latest_claude_code_session(args.projects_dir, project=args.project)
elif args.input:
source = args.input
else:
raise SystemExit("claude-code ingest requires an input .jsonl path or --latest")
trace = load_claude_code_session(source, include_sidechains=not args.no_sidechains)
else:
raise SystemExit(f"Unsupported adapter: {args.adapter}")
trace_id = store.save_trace(trace)
Expand Down Expand Up @@ -355,11 +364,14 @@ def build_parser() -> argparse.ArgumentParser:
p_init.set_defaults(func=cmd_init)

p_ingest = sub.add_parser("ingest", help="Ingest a trace")
p_ingest.add_argument("adapter", choices=["generic", "hermes", "hermes-db"])
p_ingest.add_argument("input", nargs="?", help="Input JSONL/JSON path for generic or hermes adapters")
p_ingest.add_argument("adapter", choices=["generic", "hermes", "hermes-db", "claude-code"])
p_ingest.add_argument("input", nargs="?", help="Input JSONL/JSON path for generic, hermes, or claude-code adapters")
p_ingest.add_argument("--db-path", default=str(Path.home() / ".hermes" / "state.db"), help="Hermes state.db path for hermes-db adapter")
p_ingest.add_argument("--session-id", default=None, help="Hermes session id for hermes-db adapter")
p_ingest.add_argument("--latest", action="store_true", help="Use latest Hermes session with messages for hermes-db adapter")
p_ingest.add_argument("--latest", action="store_true", help="Use the latest session (hermes-db: newest session with messages; claude-code: newest transcript)")
p_ingest.add_argument("--projects-dir", default=str(Path.home() / ".claude" / "projects"), help="Claude Code projects dir for the claude-code adapter")
p_ingest.add_argument("--project", default=None, help="Claude Code project slug to scope --latest to one project")
p_ingest.add_argument("--no-sidechains", action="store_true", help="Exclude subagent sidechain turns (claude-code adapter)")
p_ingest.set_defaults(func=cmd_ingest)

p_traces = sub.add_parser("traces", help="List/show traces")
Expand Down
Loading