basic BMAD setup
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
#!/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/<encoded-cwd>/*.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 [<path> ...] [--project <cwd>] [--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"<command-name>\s*/?([^<\s]+)\s*</command-name>")
|
||||
MEMLOG_ENTRY_RE = re.compile(r"^- (?:\((?P<tag>[^)]*)\) )?(?P<text>.*)$")
|
||||
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())
|
||||
Reference in New Issue
Block a user