#!/usr/bin/env python3 # /// script # requires-python = ">=3.11" # /// """read_session_log: digest Claude Code transcripts and memlogs into a compact JSON for the from-logs approach. Sources: claude-code a session transcript, one JSON object per line, as Claude Code keeps them under ~/.claude/projects//*.jsonl (the cwd with every "/" and "." turned into "-"; $CLAUDE_CONFIG_DIR replaces ~/.claude when set). Read: user prompts, tool calls per turn, `Skill` tool calls and /command invocations, files named in tool inputs. Meta and sidechain (subagent) records are skipped. Tool results and assistant prose are never copied out. memlog a .memlog.md: entries typed direction or decision, or marked `by user`, count as requests; entries typed gap or correction, or reading like a correction, count as corrections. A correction is a user message that opens with a word of refusal or redirection (no, don't, stop, wrong, wait, actually, instead, undo, revert, ...) or a request interrupted by the user. It is a heuristic; read the digest as leads, not verdicts. Usage: read_session_log.py [ ...] [--project ] [--format auto|claude-code|memlog] [--max-items N] A path may be a file or a folder (every *.jsonl and *.memlog.md inside). --project resolves the transcript folder for that working directory. --format auto picks by file name. Output, one JSON object on stdout; every text is cut to 300 characters and every list to --max-items: {"sources": [{"path", "format", "entries"}], "user_requests": [{"text", "count"}], "tool_sequences": [{"tools": [...], "count"}], "corrections": [text, ...], "files_touched": [path, ...], "skills_invoked": [{"name", "count"}]} """ from __future__ import annotations import argparse import json import os import re import sys from collections import Counter from pathlib import Path TEXT_LIMIT = 300 CORRECTION_RE = re.compile( r"^(?:no|nope|not|don'?t|do not|stop|wrong|wait|actually|instead|undo|revert|never|hold on|" r"that'?s (?:not|wrong)|that is (?:not|wrong)|why did you|i said|i asked|not what)\b", re.IGNORECASE, ) INTERRUPTED_RE = re.compile(r"^\[Request interrupted by user") COMMAND_RE = re.compile(r"\s*/?([^<\s]+)\s*") MEMLOG_ENTRY_RE = re.compile(r"^- (?:\((?P[^)]*)\) )?(?P.*)$") FILE_INPUT_KEYS = ("file_path", "notebook_path", "path") def truncate(text: str) -> str: text = " ".join(text.split()) return text if len(text) <= TEXT_LIMIT else text[: TEXT_LIMIT - 3] + "..." def encode_cwd(cwd: str) -> str: return re.sub(r"[/.]", "-", cwd) def projects_dir() -> Path: base = os.environ.get("CLAUDE_CONFIG_DIR") return (Path(base) if base else Path.home() / ".claude") / "projects" class Digest: def __init__(self) -> None: self.sources: list[dict] = [] self.requests: Counter[str] = Counter() self.request_text: dict[str, str] = {} self.sequences: Counter[tuple[str, ...]] = Counter() self.corrections: list[str] = [] self.files: set[str] = set() self.skills: Counter[str] = Counter() def add_request(self, text: str) -> None: key = " ".join(text.lower().split()) if not key: return self.requests[key] += 1 self.request_text.setdefault(key, truncate(text)) if CORRECTION_RE.match(text.strip()) or INTERRUPTED_RE.match(text.strip()): self.add_correction(text) def add_correction(self, text: str) -> None: short = truncate(text) if short not in self.corrections: self.corrections.append(short) def add_sequence(self, tools: list[str]) -> None: collapsed: list[str] = [] for tool in tools: if not collapsed or collapsed[-1] != tool: collapsed.append(tool) if collapsed: self.sequences[tuple(collapsed)] += 1 def render(self, max_items: int) -> dict: return { "sources": self.sources, "user_requests": [ {"text": self.request_text[key], "count": count} for key, count in self.requests.most_common(max_items) ], "tool_sequences": [ {"tools": list(seq), "count": count} for seq, count in self.sequences.most_common(max_items) ], "corrections": self.corrections[:max_items], "files_touched": sorted(self.files)[:max_items], "skills_invoked": [{"name": name, "count": count} for name, count in self.skills.most_common(max_items)], } def user_text(content) -> str | None: """Return the prompt text of a user record, or None when it is only tool results.""" if isinstance(content, str): return content if isinstance(content, list): texts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"] return "\n".join(t for t in texts if t) if texts else None return None def read_claude_code(path: Path, digest: Digest) -> int: entries = 0 turn_tools: list[str] = [] for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): try: record = json.loads(line) except json.JSONDecodeError: continue if not isinstance(record, dict) or record.get("isMeta") or record.get("isSidechain"): continue message = record.get("message") if isinstance(record.get("message"), dict) else None if message is None: continue content = message.get("content") kind = record.get("type") if kind == "user": text = user_text(content) if text is None: continue entries += 1 digest.add_sequence(turn_tools) turn_tools = [] for command in COMMAND_RE.findall(text): digest.skills[command] += 1 stripped = text.strip() if INTERRUPTED_RE.match(stripped): digest.add_correction(stripped) elif stripped and not stripped.startswith("<"): digest.add_request(stripped) elif kind == "assistant" and isinstance(content, list): for block in content: if not isinstance(block, dict) or block.get("type") != "tool_use": continue entries += 1 name = str(block.get("name", "")) turn_tools.append(name) inputs = block.get("input") if isinstance(block.get("input"), dict) else {} if name == "Skill" and inputs.get("skill"): digest.skills[str(inputs["skill"])] += 1 for key in FILE_INPUT_KEYS: if isinstance(inputs.get(key), str) and inputs[key]: digest.files.add(inputs[key]) digest.add_sequence(turn_tools) return entries def read_memlog(path: Path, digest: Digest) -> int: entries = 0 for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): match = MEMLOG_ENTRY_RE.match(line) if not match: continue entries += 1 tag = (match.group("tag") or "").strip().lower() kind = tag.split(" by ")[0].strip() by_user = " by user" in f" {tag}" or tag.startswith("by user") text = match.group("text").strip() if kind in ("gap", "correction"): digest.add_correction(text) elif kind in ("direction", "decision") or by_user: digest.add_request(text) elif CORRECTION_RE.match(text): digest.add_correction(text) return entries def detect_format(path: Path, requested: str) -> str | None: if requested != "auto": return requested if path.suffix == ".jsonl": return "claude-code" if path.name.endswith(".memlog.md") or path.name == ".memlog.md": return "memlog" return None def expand(paths: list[Path]) -> list[Path]: files: list[Path] = [] for path in paths: if path.is_dir(): files.extend(sorted(path.glob("*.jsonl"), key=lambda p: p.stat().st_mtime)) files.extend(sorted(path.glob("*.memlog.md"))) else: files.append(path) return files def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument("paths", nargs="*", type=Path, help="transcript files, memlogs, or folders of them") p.add_argument("--project", help="working directory whose Claude Code transcripts to read") p.add_argument("--format", choices=("auto", "claude-code", "memlog"), default="auto") p.add_argument("--max-items", type=int, default=20, help="cap for every list (default 20)") args = p.parse_args(argv) paths = list(args.paths) if args.project: folder = projects_dir() / encode_cwd(str(Path(args.project).expanduser().resolve())) if not folder.is_dir(): p.error(f"no transcripts for {args.project} at {folder}") paths.append(folder) if not paths: p.error("give at least one path or --project") missing = [str(path) for path in paths if not path.exists()] if missing: p.error(f"not found: {', '.join(missing)}") digest = Digest() for path in expand(paths): fmt = detect_format(path, args.format) if fmt is None: print(f"read_session_log: skipping {path}, unknown format", file=sys.stderr) continue reader = read_claude_code if fmt == "claude-code" else read_memlog entries = reader(path, digest) digest.sources.append({"path": str(path), "format": fmt, "entries": entries}) print(json.dumps(digest.render(args.max_items), indent=2)) return 0 if __name__ == "__main__": sys.exit(main())