mirror of
https://github.com/daymade/claude-code-skills.git
synced 2026-09-14 16:15:21 +08:00
77f83b116a
* fix(read-claude-code-history): search exact sessions globally * test(read-claude-code-history): pin selected session workspace
1289 lines
49 KiB
Python
Executable File
1289 lines
49 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Read chronological evidence from Claude Code session files.
|
|
|
|
Produces a structured Markdown briefing by fusing:
|
|
- Session index metadata (sessions-index.json)
|
|
- Every physical user/assistant record, including pre-compaction history
|
|
- Compact boundary summaries (continuation context, not human-authored turns)
|
|
- Active-home and registered-archive copy identity
|
|
- Subagent workflow state (multi-agent recovery)
|
|
- Session end reason detection
|
|
- Git workspace state
|
|
- MEMORY.md persistent context
|
|
- Interrupted tool-call detection
|
|
|
|
Usage:
|
|
# Extract context from latest session for current project
|
|
python3 read_claude_session.py
|
|
|
|
# Extract context from a specific session
|
|
python3 read_claude_session.py --session <SESSION_ID>
|
|
|
|
# Search sessions by topic
|
|
python3 read_claude_session.py --query "auth feature"
|
|
|
|
# List recent sessions
|
|
python3 read_claude_session.py --list
|
|
|
|
# Specify project path explicitly
|
|
python3 read_claude_session.py --project /path/to/project
|
|
"""
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
|
|
CLAUDE_DIR = Path.home() / ".claude"
|
|
PROJECTS_DIR = CLAUDE_DIR / "projects" # default home only; discovery below spans all homes
|
|
|
|
# Multi-home discovery lives in the bundled `_core` package (SSOT:
|
|
# daymade-claude-code/_conversation_core/, copied here by sync_core.py) so a
|
|
# project whose history lives under a per-model profile home is not missed.
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from _core.homes import discover_claude_homes # noqa: E402
|
|
from _core.claude import scan_claude_session # noqa: E402
|
|
from _core.sources import ( # noqa: E402
|
|
HistorySourceConfigError,
|
|
discover_claude_sources,
|
|
)
|
|
from analyze_sessions import SessionAnalyzer # noqa: E402
|
|
|
|
# Message types that are noise — skip when extracting context
|
|
NOISE_TYPES = {"progress", "queue-operation", "file-history-snapshot", "last-prompt"}
|
|
# System message subtypes that are noise
|
|
NOISE_SUBTYPES = {"api_error", "turn_duration", "stop_hook_summary"}
|
|
|
|
# Patterns that indicate system/internal content, not real user requests
|
|
NOISE_USER_PATTERNS = [
|
|
"This session is being continued",
|
|
"<task-notification>",
|
|
"<system-reminder>",
|
|
]
|
|
|
|
|
|
class SessionEvidenceError(RuntimeError):
|
|
"""A selected Claude Session cannot be rendered as complete evidence."""
|
|
|
|
|
|
def normalize_path(project_path: str) -> str:
|
|
"""Convert absolute path to Claude's normalized directory name."""
|
|
return project_path.replace("/", "-")
|
|
|
|
|
|
def _newest_session_mtime(project_dir: Path) -> float:
|
|
"""Most-recent real session-file mtime under a project dir (0.0 if none).
|
|
|
|
Excludes `agent-*.jsonl` sub-agent side-files so a stray sub-agent write can't
|
|
sway which config home wins the cross-home tiebreak in find_project_dir.
|
|
"""
|
|
return max(
|
|
(
|
|
f.stat().st_mtime
|
|
for f in project_dir.glob("*.jsonl")
|
|
if not f.name.startswith("agent-")
|
|
),
|
|
default=0.0,
|
|
)
|
|
|
|
|
|
def find_project_dir(project_path: str) -> Optional[Path]:
|
|
"""Find the Claude project dir for a path, searching ALL config homes.
|
|
|
|
The default home ~/.claude is only one place history can live; per-model
|
|
profiles keep theirs under ~/.claude-profiles/<name>/ etc. We look in every
|
|
home and decide exact-vs-fuzzy GLOBALLY (as the finder skill does): if the
|
|
exact encoded dir exists in any home, only exact matches count — so a
|
|
different project that merely shares the basename in another profile is
|
|
never picked; the substring fallback runs only when NO home has the exact
|
|
dir. When the same project exists under several homes, the one whose newest
|
|
session is most recent wins, so "resume my last session" lands on the
|
|
truly-latest one regardless of which profile it ran under.
|
|
"""
|
|
abs_path = os.path.abspath(project_path)
|
|
normalized = normalize_path(abs_path)
|
|
|
|
exact_hits: List[Path] = []
|
|
fuzzy_hits: List[Path] = []
|
|
for home in discover_claude_homes():
|
|
projects_dir = home / "projects"
|
|
if not projects_dir.is_dir():
|
|
continue
|
|
|
|
# Path already points inside this home's projects/.
|
|
projects_str = str(projects_dir) + "/"
|
|
if abs_path.startswith(projects_str):
|
|
candidate = Path(abs_path)
|
|
if candidate.is_dir():
|
|
exact_hits.append(candidate)
|
|
continue
|
|
top_dir = projects_dir / abs_path[len(projects_str):].split("/")[0]
|
|
if top_dir.is_dir():
|
|
exact_hits.append(top_dir)
|
|
continue
|
|
|
|
# Exact encoded-name match in this home.
|
|
candidate = projects_dir / normalized
|
|
if candidate.is_dir():
|
|
exact_hits.append(candidate)
|
|
continue
|
|
|
|
# Substring fallback — recorded separately and only used when no home
|
|
# has an exact match, to avoid cross-home basename conflation.
|
|
for d in projects_dir.iterdir():
|
|
if d.is_dir() and normalized in d.name:
|
|
fuzzy_hits.append(d)
|
|
break
|
|
|
|
hits = exact_hits or fuzzy_hits
|
|
if not hits:
|
|
return None
|
|
hits.sort(key=_newest_session_mtime, reverse=True)
|
|
return hits[0]
|
|
|
|
|
|
def discover_session_refs(
|
|
project_path: str, manifest_path: Optional[str] = None
|
|
) -> tuple[List[Dict], List[str]]:
|
|
"""Find project Sessions across active homes and registered archives."""
|
|
sources, warnings = discover_claude_sources(manifest_path=manifest_path)
|
|
analyzer = SessionAnalyzer(sources=sources, warnings=warnings)
|
|
return analyzer.find_project_sessions(project_path), warnings
|
|
|
|
|
|
def discover_exact_session_refs(
|
|
session_id: str, manifest_path: Optional[str] = None
|
|
) -> tuple[List[Dict], List[str]]:
|
|
"""Find exact Session candidates across every project and source.
|
|
|
|
An exact Session ID is evidence identity, not a project guess. Reuse the
|
|
analyzer's candidate-first byte scan so this does not fully parse every
|
|
Session in every project. Candidate files still pass the exact reader's
|
|
record-identity and copy-divergence gates before any briefing is emitted.
|
|
"""
|
|
sources, warnings = discover_claude_sources(manifest_path=manifest_path)
|
|
analyzer = SessionAnalyzer(sources=sources, warnings=warnings)
|
|
|
|
# Claude's canonical file name is <Session-ID>.jsonl. Check that exact
|
|
# shape across every project first: it is bounded by the number of project
|
|
# directories and avoids scanning a multi-gigabyte history corpus for the
|
|
# UUID. Filename identity only selects candidates; parse-time record
|
|
# identity remains authoritative below.
|
|
copies = []
|
|
matched_sources = []
|
|
for source in sources:
|
|
projects_dir = source.home / "projects"
|
|
if not projects_dir.is_dir():
|
|
continue
|
|
for project_dir in projects_dir.iterdir():
|
|
if not project_dir.is_dir():
|
|
continue
|
|
candidate = project_dir / f"{session_id}.jsonl"
|
|
if not candidate.is_file():
|
|
continue
|
|
copies.append({"path": candidate, "source": source})
|
|
if source not in matched_sources:
|
|
matched_sources.append(source)
|
|
|
|
if copies:
|
|
return (
|
|
[
|
|
{
|
|
"session_id": session_id,
|
|
"path": Path(copies[0]["path"]),
|
|
"copies": copies,
|
|
"sources": matched_sources,
|
|
"homes": [source.home for source in matched_sources],
|
|
}
|
|
],
|
|
warnings,
|
|
)
|
|
|
|
# A registered archive may preserve the right record identity under a
|
|
# renamed file. Fall back to the analyzer's byte-level candidate scan only
|
|
# when the canonical filename was absent everywhere.
|
|
refs, _total, _projects, _prefiltered = analyzer.find_search_sessions(
|
|
project_path=None,
|
|
all_projects=True,
|
|
keywords=[session_id],
|
|
case_sensitive=True,
|
|
use_prefilter=True,
|
|
exclude_sessions=set(),
|
|
)
|
|
return refs, warnings
|
|
|
|
|
|
def _resolved_path_key(path: Path) -> str:
|
|
try:
|
|
return str(path.resolve())
|
|
except (OSError, RuntimeError):
|
|
return str(path.absolute())
|
|
|
|
|
|
def _file_digest(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
try:
|
|
with path.open("rb") as handle:
|
|
for block in iter(lambda: handle.read(1 << 20), b""):
|
|
digest.update(block)
|
|
except OSError as error:
|
|
raise SessionEvidenceError(f"cannot read Claude Session copy {path}: {error}") from error
|
|
return digest.hexdigest()
|
|
|
|
|
|
def _is_byte_prefix(shorter: Path, longer: Path) -> bool:
|
|
"""Return whether ``shorter`` is exactly the initial bytes of ``longer``."""
|
|
try:
|
|
if shorter.stat().st_size > longer.stat().st_size:
|
|
return False
|
|
with shorter.open("rb") as left, longer.open("rb") as right:
|
|
while True:
|
|
block = left.read(1 << 20)
|
|
if not block:
|
|
return True
|
|
if right.read(len(block)) != block:
|
|
return False
|
|
except OSError as error:
|
|
raise SessionEvidenceError(
|
|
f"cannot compare Claude Session copies {shorter} and {longer}: {error}"
|
|
) from error
|
|
|
|
|
|
def select_session_copy(ref: Dict) -> tuple[Path, List[str], List[Path]]:
|
|
"""Choose one provably complete physical copy for a merged Session ref.
|
|
|
|
Byte-identical active/archive copies are one evidence object. An older
|
|
append-only snapshot is also safe when it is an exact byte prefix of a longer
|
|
copy. Divergent copies cannot be merged into a trustworthy chronology without
|
|
inventing ordering, so the exact reader fails closed and names every path.
|
|
"""
|
|
raw_copies = ref.get("copies") or [
|
|
{
|
|
"path": ref["path"],
|
|
"source": (ref.get("sources") or [None])[0],
|
|
}
|
|
]
|
|
by_real_path: Dict[str, Dict] = {}
|
|
all_labels: List[str] = []
|
|
for item in raw_copies:
|
|
path = Path(item["path"])
|
|
source = item.get("source")
|
|
label = getattr(source, "display_label", None)
|
|
if label and label not in all_labels:
|
|
all_labels.append(label)
|
|
key = _resolved_path_key(path)
|
|
existing = by_real_path.get(key)
|
|
if existing is None:
|
|
by_real_path[key] = {
|
|
"path": path,
|
|
"active": getattr(source, "kind", None) == "active",
|
|
}
|
|
elif getattr(source, "kind", None) == "active":
|
|
existing["active"] = True
|
|
|
|
candidates = list(by_real_path.values())
|
|
if not candidates:
|
|
raise SessionEvidenceError(
|
|
f"Session {ref.get('session_id', '?')} has no readable physical copy"
|
|
)
|
|
if len(candidates) == 1:
|
|
return candidates[0]["path"], all_labels, [candidates[0]["path"]]
|
|
|
|
digests: Dict[str, List[Dict]] = {}
|
|
for candidate in candidates:
|
|
digests.setdefault(_file_digest(candidate["path"]), []).append(candidate)
|
|
if len(digests) == 1:
|
|
chosen = max(candidates, key=lambda item: bool(item["active"]))
|
|
return chosen["path"], all_labels, [item["path"] for item in candidates]
|
|
|
|
longest_size = max(item["path"].stat().st_size for item in candidates)
|
|
longest = [
|
|
item for item in candidates if item["path"].stat().st_size == longest_size
|
|
]
|
|
for candidate in sorted(longest, key=lambda item: bool(item["active"]), reverse=True):
|
|
if all(
|
|
other is candidate
|
|
or _is_byte_prefix(other["path"], candidate["path"])
|
|
for other in candidates
|
|
):
|
|
return candidate["path"], all_labels, [item["path"] for item in candidates]
|
|
|
|
paths = ", ".join(str(item["path"]) for item in candidates)
|
|
raise SessionEvidenceError(
|
|
f"Session {ref.get('session_id', '?')} resolves to divergent physical copies; "
|
|
f"no copy is a complete append-only superset: {paths}"
|
|
)
|
|
|
|
|
|
def validate_selected_session_identity(
|
|
observed_ids: set[str], expected_session_id: str
|
|
) -> None:
|
|
"""Reject fused or mismatched Claude Session evidence."""
|
|
if len(observed_ids) > 1:
|
|
raise SessionEvidenceError(
|
|
"selected Claude Session file contains multiple Session identities: "
|
|
f"{sorted(observed_ids)!r}; requested {expected_session_id!r}. "
|
|
"Records cannot be attributed safely."
|
|
)
|
|
if not observed_ids:
|
|
raise SessionEvidenceError(
|
|
"selected Claude Session has no record-level Session identity; "
|
|
f"the filename cannot prove requested Session {expected_session_id!r}"
|
|
)
|
|
if expected_session_id not in observed_ids:
|
|
raise SessionEvidenceError(
|
|
"selected Claude Session identity mismatch: requested "
|
|
f"{expected_session_id!r}, observed {sorted(observed_ids)!r}"
|
|
)
|
|
|
|
|
|
def load_sessions_index(project_dir: Path) -> List[Dict]:
|
|
"""Load and parse sessions-index.json, sorted by modified desc."""
|
|
index_file = project_dir / "sessions-index.json"
|
|
if not index_file.exists():
|
|
return []
|
|
with open(index_file, encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
entries = data.get("entries", [])
|
|
entries.sort(key=lambda e: e.get("modified", ""), reverse=True)
|
|
return entries
|
|
|
|
|
|
def search_sessions(entries: List[Dict], query: str) -> List[Dict]:
|
|
"""Search sessions by keyword in firstPrompt and summary."""
|
|
query_lower = query.lower()
|
|
results = []
|
|
for entry in entries:
|
|
first_prompt = (entry.get("firstPrompt") or "").lower()
|
|
summary = (entry.get("summary") or "").lower()
|
|
if query_lower in first_prompt or query_lower in summary:
|
|
results.append(entry)
|
|
return results
|
|
|
|
|
|
def format_session_entry(entry: Dict, file_exists: bool = True) -> str:
|
|
"""Format a session index entry for display."""
|
|
sid = entry.get("sessionId", "?")
|
|
modified = entry.get("modified", "?")
|
|
msgs = entry.get("messageCount", "?")
|
|
branch = entry.get("gitBranch", "?")
|
|
prompt = (entry.get("firstPrompt") or "")[:80]
|
|
ghost = "" if file_exists else " [file missing]"
|
|
return f" {sid} [{branch}] {msgs} msgs {modified}{ghost}\n {prompt}"
|
|
|
|
|
|
# ── Session file parsing ────────────────────────────────────────────
|
|
|
|
|
|
def parse_session_structure(session_file: Path) -> Dict:
|
|
"""Parse every physical record in one selected Claude Session.
|
|
|
|
This reader is the evidence source for continuation. A previous resume-oriented
|
|
implementation deliberately read only a size-adaptive tail (or only records
|
|
after the last compaction boundary). That was useful for saving context, but it
|
|
could erase the original business outcome while still returning exit 0. Exact
|
|
Session evidence therefore scans the whole file; output clipping remains a
|
|
presentation concern handled by ``--full``.
|
|
|
|
Any non-empty malformed JSONL line aborts the read. Returning a polished partial
|
|
receipt would make the missing record indistinguishable from "the user never
|
|
said it," which is the failure this Skill exists to prevent.
|
|
"""
|
|
file_size = session_file.stat().st_size
|
|
total_lines = 0
|
|
|
|
# First pass: find compact boundaries and count lines
|
|
compact_boundaries = [] # (line_num, summary_text)
|
|
with open(session_file, encoding="utf-8") as f:
|
|
prev_boundary_line = None
|
|
for i, raw_line in enumerate(f):
|
|
total_lines += 1
|
|
|
|
# Detect compact summary via isCompactSummary flag (most reliable)
|
|
if '"isCompactSummary"' in raw_line:
|
|
try:
|
|
obj = json.loads(raw_line)
|
|
if obj.get("isCompactSummary"):
|
|
content = obj.get("message", {}).get("content", "")
|
|
if isinstance(content, str):
|
|
boundary_line = prev_boundary_line if prev_boundary_line is not None else max(0, i - 1)
|
|
compact_boundaries.append((boundary_line, content))
|
|
prev_boundary_line = None
|
|
continue
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
# Detect compact_boundary marker
|
|
if '"compact_boundary"' in raw_line and '"subtype"' in raw_line:
|
|
try:
|
|
obj = json.loads(raw_line)
|
|
if obj.get("subtype") == "compact_boundary":
|
|
prev_boundary_line = i
|
|
continue
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
# Fallback: if prev line was boundary and this is a user message with long string content
|
|
if prev_boundary_line is not None:
|
|
try:
|
|
obj = json.loads(raw_line)
|
|
content = obj.get("message", {}).get("content", "")
|
|
if isinstance(content, str) and len(content) > 100:
|
|
compact_boundaries.append((prev_boundary_line, content))
|
|
except (json.JSONDecodeError, AttributeError):
|
|
compact_boundaries.append((prev_boundary_line, ""))
|
|
prev_boundary_line = None
|
|
|
|
# Second pass: extract the complete physical Session chronology.
|
|
parsed_range_start = 0
|
|
messages = []
|
|
unresolved_tool_calls = {} # tool_use_id -> tool_use_info
|
|
errors = []
|
|
files_touched = set()
|
|
last_message_role = None
|
|
error_count = 0
|
|
observed_session_ids = set()
|
|
|
|
with open(session_file, encoding="utf-8") as f:
|
|
for i, raw_line in enumerate(f):
|
|
if not raw_line.strip():
|
|
continue
|
|
try:
|
|
obj = json.loads(raw_line)
|
|
except json.JSONDecodeError as error:
|
|
raise SessionEvidenceError(
|
|
f"malformed JSONL at physical line {i + 1} in {session_file}: "
|
|
f"{error}"
|
|
) from error
|
|
if not isinstance(obj, dict):
|
|
raise SessionEvidenceError(
|
|
f"JSONL line {i + 1} in {session_file} is not an object"
|
|
)
|
|
|
|
observed_id = obj.get("sessionId")
|
|
if isinstance(observed_id, str) and observed_id:
|
|
observed_session_ids.add(observed_id)
|
|
|
|
msg_type = obj.get("type", "")
|
|
if msg_type in NOISE_TYPES:
|
|
continue
|
|
if msg_type == "system":
|
|
subtype = obj.get("subtype", "")
|
|
if subtype in NOISE_SUBTYPES:
|
|
if subtype == "api_error":
|
|
error_count += 1
|
|
continue
|
|
if subtype == "compact_boundary":
|
|
continue
|
|
|
|
if msg_type == "attachment":
|
|
attachment = obj.get("attachment") or {}
|
|
origin = attachment.get("origin") or {}
|
|
if (
|
|
attachment.get("type") == "queued_command"
|
|
and origin.get("kind") == "human"
|
|
):
|
|
prompt = attachment.get("prompt")
|
|
if isinstance(prompt, list):
|
|
prompt = "\n".join(
|
|
item.get("text", "")
|
|
if isinstance(item, dict)
|
|
else str(item)
|
|
for item in prompt
|
|
)
|
|
if isinstance(prompt, str) and prompt.strip():
|
|
messages.append(
|
|
{
|
|
"message": {"role": "user", "content": prompt.strip()},
|
|
"timestamp": obj.get("timestamp"),
|
|
"_queued_human": True,
|
|
}
|
|
)
|
|
last_message_role = "user"
|
|
continue
|
|
|
|
# Track tool calls and results
|
|
msg = obj.get("message", {})
|
|
role = msg.get("role", "")
|
|
content = msg.get("content", "")
|
|
|
|
# Extract tool_use from assistant messages
|
|
if role == "assistant" and isinstance(content, list):
|
|
for block in content:
|
|
if not isinstance(block, dict) or block.get("type") != "tool_use":
|
|
continue
|
|
tool_id = block.get("id", "")
|
|
tool_name = block.get("name", "?")
|
|
inp = block.get("input", {})
|
|
unresolved_tool_calls[tool_id] = {
|
|
"name": tool_name,
|
|
"input_preview": str(inp)[:200],
|
|
}
|
|
# Track file operations
|
|
if tool_name in ("Write", "Edit", "Read"):
|
|
fp = inp.get("file_path", "")
|
|
if fp:
|
|
files_touched.add(fp)
|
|
elif tool_name == "Bash":
|
|
cmd = inp.get("command", "")
|
|
for match in re.findall(r'(?<!\w)(/[a-zA-Z][\w./\-]+)', cmd):
|
|
if not match.startswith("/dev/"):
|
|
files_touched.add(match)
|
|
|
|
# Resolve tool results
|
|
if role == "user" and isinstance(content, list):
|
|
for block in content:
|
|
if isinstance(block, dict) and block.get("type") == "tool_result":
|
|
tool_id = block.get("tool_use_id", "")
|
|
unresolved_tool_calls.pop(tool_id, None)
|
|
is_error = block.get("is_error", False)
|
|
result_content = block.get("content", "")
|
|
if is_error and isinstance(result_content, str):
|
|
errors.append(result_content[:500])
|
|
|
|
# Track last message for end-reason detection
|
|
if role in ("user", "assistant"):
|
|
last_message_role = role
|
|
messages.append(obj)
|
|
|
|
# Detect session end reason
|
|
end_reason = _detect_end_reason(
|
|
last_message_role, unresolved_tool_calls, error_count,
|
|
)
|
|
|
|
return {
|
|
"total_lines": total_lines,
|
|
"file_size": file_size,
|
|
"compact_boundaries": compact_boundaries,
|
|
"parsed_range_start": parsed_range_start,
|
|
"messages": messages,
|
|
"unresolved_tool_calls": dict(unresolved_tool_calls),
|
|
"errors": errors,
|
|
"error_count": error_count,
|
|
"files_touched": files_touched,
|
|
"end_reason": end_reason,
|
|
"observed_session_ids": observed_session_ids,
|
|
}
|
|
|
|
|
|
def _detect_end_reason(
|
|
last_role: Optional[str],
|
|
unresolved: Dict,
|
|
error_count: int,
|
|
) -> str:
|
|
"""Detect why the session ended."""
|
|
if unresolved:
|
|
return "interrupted" # Tool calls dispatched but no results — likely ctrl-c
|
|
if error_count >= 3:
|
|
return "error_cascade" # Multiple API errors suggest systemic failure
|
|
if last_role == "assistant":
|
|
return "completed" # Assistant had the last word — clean end
|
|
if last_role == "user":
|
|
return "abandoned" # User sent a message but got no response
|
|
return "unknown"
|
|
|
|
|
|
def _is_noise_user_text(text: str) -> bool:
|
|
"""Check if user text is system noise rather than a real request."""
|
|
for pattern in NOISE_USER_PATTERNS:
|
|
if text.startswith(pattern) or pattern in text[:200]:
|
|
return True
|
|
return False
|
|
|
|
|
|
def extract_user_text(messages: List[Dict], limit: int = 5) -> List[str]:
|
|
"""Extract the last N user text messages (not tool results or system noise)."""
|
|
user_texts = []
|
|
for msg_obj in reversed(messages):
|
|
if msg_obj.get("isCompactSummary"):
|
|
continue
|
|
msg = msg_obj.get("message", {})
|
|
if msg.get("role") != "user":
|
|
continue
|
|
content = msg.get("content", "")
|
|
if isinstance(content, str) and content.strip():
|
|
if _is_noise_user_text(content):
|
|
continue
|
|
user_texts.append(content.strip())
|
|
elif isinstance(content, list):
|
|
texts = [
|
|
b.get("text", "")
|
|
for b in content
|
|
if isinstance(b, dict) and b.get("type") == "text"
|
|
]
|
|
combined = "\n".join(t for t in texts if t.strip())
|
|
if combined and not _is_noise_user_text(combined):
|
|
user_texts.append(combined)
|
|
if len(user_texts) >= limit:
|
|
break
|
|
user_texts.reverse()
|
|
return user_texts
|
|
|
|
|
|
def extract_assistant_text(messages: List[Dict], limit: int = 3) -> List[str]:
|
|
"""Extract the last N assistant text responses (no thinking/tool_use)."""
|
|
assistant_texts = []
|
|
for msg_obj in reversed(messages):
|
|
msg = msg_obj.get("message", {})
|
|
if msg.get("role") != "assistant":
|
|
continue
|
|
content = msg.get("content", "")
|
|
if isinstance(content, list):
|
|
texts = [
|
|
b.get("text", "")
|
|
for b in content
|
|
if isinstance(b, dict) and b.get("type") == "text"
|
|
]
|
|
combined = "\n".join(t for t in texts if t.strip())
|
|
if combined:
|
|
assistant_texts.append(combined[:2000])
|
|
if len(assistant_texts) >= limit:
|
|
break
|
|
assistant_texts.reverse()
|
|
return assistant_texts
|
|
|
|
|
|
def _turn_text(msg_obj: Dict) -> str:
|
|
msg = msg_obj.get("message", {})
|
|
content = msg.get("content", "")
|
|
if isinstance(content, str):
|
|
return content.strip()
|
|
if not isinstance(content, list):
|
|
return ""
|
|
texts = [
|
|
block.get("text", "")
|
|
for block in content
|
|
if isinstance(block, dict) and block.get("type") == "text"
|
|
]
|
|
return "\n".join(text for text in texts if text.strip()).strip()
|
|
|
|
|
|
def extract_turn_timeline(messages: List[Dict]) -> List[Dict]:
|
|
"""Return human/assistant text in physical record order."""
|
|
turns = []
|
|
for ordinal, msg_obj in enumerate(messages):
|
|
if msg_obj.get("isCompactSummary"):
|
|
# Claude-generated continuation context is evidence, but it is not a
|
|
# human request and must not be attributed as one in the chronology.
|
|
continue
|
|
msg = msg_obj.get("message", {})
|
|
role = msg.get("role")
|
|
if role not in ("user", "assistant"):
|
|
continue
|
|
text = _turn_text(msg_obj)
|
|
if not text:
|
|
continue
|
|
if role == "user" and _is_noise_user_text(text):
|
|
continue
|
|
turns.append(
|
|
{
|
|
"ordinal": ordinal,
|
|
"role": role,
|
|
"text": text,
|
|
"queued": bool(msg_obj.get("_queued_human")),
|
|
}
|
|
)
|
|
return turns
|
|
|
|
|
|
def _handoff_segments(timeline: List[Dict]) -> List[List[Dict]]:
|
|
"""Retain every textual turn in physical record order.
|
|
|
|
First/latest assistant compression can erase the only proven asset or
|
|
successful route between two user messages. Character clipping already bounds
|
|
default output; ``--full`` removes that clipping without changing which turns
|
|
exist in the evidence receipt.
|
|
"""
|
|
return [timeline] if timeline else []
|
|
|
|
|
|
def _clip(text: str, limit: int, full: bool) -> str:
|
|
if full or len(text) <= limit:
|
|
return text
|
|
return text[:limit] + f"\n… (truncated at {limit}/{len(text)} chars — rerun with --full)"
|
|
|
|
|
|
def _append_timeline(sections: List[str], messages: List[Dict], full: bool) -> None:
|
|
timeline = extract_turn_timeline(messages)
|
|
if not timeline:
|
|
return
|
|
sections.append("\n## Chronological Handoff Timeline\n")
|
|
sections.append(
|
|
"Every retained human and assistant text turn is shown in physical record "
|
|
"order. Default mode clips long turns; `--full` changes only clipping.\n"
|
|
)
|
|
for segment in _handoff_segments(timeline):
|
|
for turn in segment:
|
|
role = turn["role"].upper()
|
|
queued = " · queued human input" if turn["queued"] else ""
|
|
sections.append(f"### Record {turn['ordinal']} · {role}{queued}\n")
|
|
limit = 1000 if role == "USER" else 1600
|
|
sections.append(_clip(turn["text"], limit, full) + "\n")
|
|
if timeline[-1]["role"] == "user":
|
|
sections.append(
|
|
f"> **Unanswered retained request**: the evidence ends on record "
|
|
f"{timeline[-1]['ordinal']}.\n"
|
|
)
|
|
|
|
|
|
# ── Subagent extraction ──────────────────────────────────────────────
|
|
|
|
|
|
def extract_subagent_context(session_file: Path) -> List[Dict]:
|
|
"""Extract subagent summaries from session subdirectories.
|
|
|
|
Returns list of {name, type, status, last_text, is_interrupted}.
|
|
"""
|
|
session_dir = session_file.parent / session_file.stem
|
|
subagents_dir = session_dir / "subagents"
|
|
if not subagents_dir.is_dir():
|
|
return []
|
|
|
|
# Group by agent ID: find meta.json and .jsonl pairs
|
|
agent_ids = set()
|
|
for f in subagents_dir.iterdir():
|
|
if f.suffix == ".jsonl":
|
|
agent_ids.add(f.stem)
|
|
|
|
results = []
|
|
for agent_id in sorted(agent_ids):
|
|
jsonl_file = subagents_dir / f"{agent_id}.jsonl"
|
|
meta_file = subagents_dir / f"{agent_id}.meta.json"
|
|
|
|
# Parse agent type from ID (format: agent-a<type>-<hash> or agent-a<hash>)
|
|
agent_type = "unknown"
|
|
if meta_file.exists():
|
|
try:
|
|
with open(meta_file, encoding="utf-8") as f:
|
|
meta = json.load(f)
|
|
agent_type = meta.get("type", meta.get("subagent_type", "unknown"))
|
|
except (json.JSONDecodeError, OSError):
|
|
pass
|
|
|
|
if agent_type == "unknown":
|
|
# Infer from ID pattern: agent-a<type>-<hash>
|
|
match = re.match(r'agent-a(compact|prompt_suggestion|[a-z_]+)-', agent_id)
|
|
if match:
|
|
agent_type = match.group(1)
|
|
|
|
# Skip compact and prompt_suggestion agents (internal, not user work)
|
|
if agent_type in ("compact", "prompt_suggestion"):
|
|
continue
|
|
|
|
# Read last few lines for final output
|
|
last_text = ""
|
|
is_interrupted = False
|
|
line_count = 0
|
|
|
|
if jsonl_file.exists():
|
|
try:
|
|
lines = jsonl_file.read_text(encoding="utf-8").strip().split("\n")
|
|
line_count = len(lines)
|
|
has_tool_use_pending = False
|
|
# Check last 10 lines for final assistant text
|
|
for raw_line in reversed(lines[-10:]):
|
|
try:
|
|
obj = json.loads(raw_line)
|
|
msg = obj.get("message", {})
|
|
role = msg.get("role", "")
|
|
content = msg.get("content", "")
|
|
|
|
if role == "assistant" and isinstance(content, list):
|
|
for block in content:
|
|
if not isinstance(block, dict):
|
|
continue
|
|
block_type = block.get("type")
|
|
if block_type == "tool_use":
|
|
has_tool_use_pending = True
|
|
elif block_type == "text":
|
|
text = block.get("text", "")
|
|
if text.strip() and not last_text:
|
|
last_text = text.strip()[:500]
|
|
|
|
if role == "user" and isinstance(content, list):
|
|
for block in content:
|
|
if isinstance(block, dict) and block.get("type") == "tool_result":
|
|
has_tool_use_pending = False
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
is_interrupted = has_tool_use_pending
|
|
except OSError:
|
|
pass
|
|
|
|
results.append({
|
|
"id": agent_id,
|
|
"type": agent_type,
|
|
"last_text": last_text,
|
|
"is_interrupted": is_interrupted,
|
|
"lines": line_count,
|
|
})
|
|
|
|
return results
|
|
|
|
|
|
# ── Context sources ──────────────────────────────────────────────────
|
|
|
|
|
|
def get_git_state(project_path: str) -> str:
|
|
"""Get current git status and recent log."""
|
|
parts = []
|
|
try:
|
|
branch = subprocess.run(
|
|
["git", "branch", "--show-current"],
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
universal_newlines=True, cwd=project_path, timeout=5,
|
|
)
|
|
if branch.stdout.strip():
|
|
parts.append(f"**Current branch**: `{branch.stdout.strip()}`")
|
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
pass
|
|
|
|
try:
|
|
status = subprocess.run(
|
|
["git", "status", "--short"],
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
universal_newlines=True, cwd=project_path, timeout=10,
|
|
)
|
|
if status.stdout.strip():
|
|
parts.append(f"### git status\n```\n{status.stdout.strip()}\n```")
|
|
else:
|
|
parts.append("### git status\nClean working tree.")
|
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
parts.append("### git status\n(unavailable)")
|
|
|
|
try:
|
|
log = subprocess.run(
|
|
["git", "log", "--oneline", "-5"],
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
universal_newlines=True, cwd=project_path, timeout=10,
|
|
)
|
|
if log.stdout.strip():
|
|
parts.append(f"### git log (last 5)\n```\n{log.stdout.strip()}\n```")
|
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
pass
|
|
|
|
return "\n\n".join(parts)
|
|
|
|
|
|
def get_memory_md(project_dir: Path) -> Optional[str]:
|
|
"""Read MEMORY.md if it exists in the project's memory directory."""
|
|
memory_dir = project_dir / "memory"
|
|
memory_file = memory_dir / "MEMORY.md"
|
|
if memory_file.exists():
|
|
content = memory_file.read_text(encoding="utf-8").strip()
|
|
if content:
|
|
return content[:3000]
|
|
return None
|
|
|
|
|
|
def get_session_memory(session_file: Path) -> Optional[str]:
|
|
"""Read session-memory/summary.md if it exists (newer CC versions)."""
|
|
session_dir = session_file.parent / session_file.stem
|
|
summary = session_dir / "session-memory" / "summary.md"
|
|
if summary.exists():
|
|
content = summary.read_text(encoding="utf-8").strip()
|
|
if content:
|
|
return content[:3000]
|
|
return None
|
|
|
|
|
|
# ── Output formatting ────────────────────────────────────────────────
|
|
|
|
|
|
END_REASON_LABELS = {
|
|
"completed": "Clean exit (assistant completed response)",
|
|
"interrupted": "Interrupted (unresolved tool calls — likely ctrl-c or timeout)",
|
|
"error_cascade": "Error cascade (multiple API errors)",
|
|
"abandoned": "Abandoned (user message with no response)",
|
|
"unknown": "Unknown",
|
|
}
|
|
|
|
|
|
def build_briefing(
|
|
session_entry: Optional[Dict],
|
|
parsed: Dict,
|
|
project_path: str,
|
|
project_dir: Path,
|
|
session_file: Path,
|
|
full: bool = False,
|
|
) -> str:
|
|
"""Build the structured Markdown briefing."""
|
|
sections = []
|
|
|
|
# Header
|
|
sections.append("# Claude Code Session Evidence Briefing\n")
|
|
|
|
# Session metadata
|
|
if session_entry:
|
|
sid = session_entry.get("sessionId", "?")
|
|
modified = session_entry.get("modified", "?")
|
|
branch = session_entry.get("gitBranch", "?")
|
|
msg_count = session_entry.get("messageCount", "?")
|
|
first_prompt = session_entry.get("firstPrompt", "")
|
|
summary = session_entry.get("summary", "")
|
|
|
|
sections.append("## Session Info\n")
|
|
sections.append(f"- **ID**: `{sid}`")
|
|
sections.append(f"- **Last active**: {modified}")
|
|
sections.append(f"- **Branch**: `{branch}`")
|
|
sections.append(f"- **Messages**: {msg_count}")
|
|
sections.append(f"- **First prompt**: {first_prompt}")
|
|
if summary:
|
|
sections.append(f"- **Summary**: {summary[:300]}")
|
|
elif parsed.get("selected_session_id"):
|
|
sections.append("## Session Info\n")
|
|
sections.append(f"- **ID**: `{parsed['selected_session_id']}`")
|
|
|
|
source_labels = parsed.get("source_labels") or []
|
|
copy_paths = parsed.get("copy_paths") or []
|
|
if source_labels:
|
|
sections.append(f"- **Sources read**: {', '.join(source_labels)}")
|
|
if copy_paths:
|
|
sections.append(f"- **Physical copies checked**: {len(copy_paths)}")
|
|
|
|
# File stats + end reason
|
|
file_mb = parsed["file_size"] / 1_000_000
|
|
end_label = END_REASON_LABELS.get(parsed["end_reason"], parsed["end_reason"])
|
|
sections.append(f"\n**Session file**: {file_mb:.1f} MB, {parsed['total_lines']} lines, "
|
|
f"{len(parsed['compact_boundaries'])} compaction(s)")
|
|
sections.append(f"**Session end reason**: {end_label}")
|
|
observed_ids = sorted(parsed.get("observed_session_ids") or [])
|
|
if observed_ids:
|
|
sections.append(f"**Observed Session identity**: `{', '.join(observed_ids)}`")
|
|
else:
|
|
sections.append("**Observed Session identity**: unavailable in parsed records")
|
|
sections.append("**Chronology coverage**: every physical JSONL record")
|
|
if parsed["error_count"] > 0:
|
|
sections.append(f"**API errors**: {parsed['error_count']}")
|
|
|
|
# Session memory (newer CC versions generate this automatically)
|
|
session_mem = get_session_memory(session_file)
|
|
if session_mem:
|
|
sections.append("\n## Session Memory (auto-generated by Claude Code)\n")
|
|
sections.append(session_mem)
|
|
|
|
# Compact summary (highest-signal context)
|
|
if parsed["compact_boundaries"]:
|
|
last_summary = parsed["compact_boundaries"][-1][1]
|
|
if last_summary:
|
|
sections.append("\n## Compact Summary (auto-generated by previous session)\n")
|
|
sections.append(_clip(last_summary, 8000, full))
|
|
|
|
_append_timeline(sections, parsed["messages"], full)
|
|
|
|
# Errors encountered
|
|
if parsed["errors"]:
|
|
sections.append("\n## Errors Encountered\n")
|
|
seen = set()
|
|
for err in parsed["errors"]:
|
|
short = err[:200]
|
|
if short not in seen:
|
|
seen.add(short)
|
|
sections.append(f"```\n{err}\n```\n")
|
|
|
|
# Unresolved tool calls (interrupted session)
|
|
if parsed["unresolved_tool_calls"]:
|
|
sections.append("\n## Unresolved Tool Calls (session was interrupted)\n")
|
|
for tool_id, info in parsed["unresolved_tool_calls"].items():
|
|
sections.append(f"- **{info['name']}**: `{tool_id}`")
|
|
sections.append(f" Input: {info['input_preview']}")
|
|
|
|
# Subagent context (the "nobody has done this" feature)
|
|
subagents = extract_subagent_context(session_file)
|
|
if subagents:
|
|
interrupted = [s for s in subagents if s["is_interrupted"]]
|
|
completed = [s for s in subagents if not s["is_interrupted"]]
|
|
sections.append(f"\n## Subagent Workflow ({len(completed)} completed, {len(interrupted)} interrupted)\n")
|
|
if interrupted:
|
|
sections.append("### Interrupted Subagents\n")
|
|
for sa in interrupted:
|
|
sections.append(f"- **{sa['type']}** (`{sa['id']}`, {sa['lines']} lines)")
|
|
if sa["last_text"]:
|
|
sections.append(f" Last output: {sa['last_text'][:300]}")
|
|
if completed:
|
|
sections.append("\n### Completed Subagents\n")
|
|
for sa in completed:
|
|
sections.append(f"- **{sa['type']}** (`{sa['id']}`, {sa['lines']} lines)")
|
|
if sa["last_text"]:
|
|
sections.append(f" Last output: {sa['last_text'][:200]}")
|
|
|
|
# Files touched in session
|
|
if parsed["files_touched"]:
|
|
sections.append("\n## Files Touched in Session\n")
|
|
for fp in sorted(parsed["files_touched"])[:30]:
|
|
sections.append(f"- `{fp}`")
|
|
|
|
# MEMORY.md
|
|
memory = get_memory_md(project_dir)
|
|
if memory:
|
|
sections.append("\n## Persistent Memory (MEMORY.md)\n")
|
|
sections.append(memory)
|
|
|
|
# Git state
|
|
sections.append("\n## Current Workspace State\n")
|
|
sections.append(get_git_state(project_path))
|
|
|
|
return "\n".join(sections)
|
|
|
|
|
|
# ── CLI ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _check_session_files(entries: List[Dict], project_dir: Path) -> Dict[str, bool]:
|
|
"""Check which index entries have actual files on disk."""
|
|
status = {}
|
|
for entry in entries:
|
|
sid = entry.get("sessionId", "")
|
|
session_file = project_dir / f"{sid}.jsonl"
|
|
if session_file.exists():
|
|
status[sid] = True
|
|
else:
|
|
full_path = entry.get("fullPath", "")
|
|
status[sid] = bool(full_path and Path(full_path).exists())
|
|
return status
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Read chronological evidence from Claude Code sessions.",
|
|
)
|
|
parser.add_argument(
|
|
"--project", "-p",
|
|
default=None,
|
|
help=(
|
|
"Project path (default: current directory; with an exact --session, "
|
|
"omitting this flag searches all projects)"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--session", "-s",
|
|
default=None,
|
|
help="Session ID to extract context from",
|
|
)
|
|
parser.add_argument(
|
|
"--query", "-q",
|
|
default=None,
|
|
help="Search sessions by keyword in firstPrompt/summary",
|
|
)
|
|
parser.add_argument(
|
|
"--list", "-l",
|
|
action="store_true",
|
|
help="List recent sessions",
|
|
)
|
|
parser.add_argument(
|
|
"--limit", "-n",
|
|
type=int,
|
|
default=10,
|
|
help="Number of sessions to list (default: 10)",
|
|
)
|
|
parser.add_argument(
|
|
"--exclude-current",
|
|
default=None,
|
|
help="Session ID to exclude (typically the currently active session)",
|
|
)
|
|
parser.add_argument(
|
|
"--full",
|
|
action="store_true",
|
|
help="Do not clip compact-summary or full-session timeline text",
|
|
)
|
|
parser.add_argument(
|
|
"--history-sources",
|
|
metavar="FILE",
|
|
help=(
|
|
"History source registry (default: ~/.claude/history-sources.json "
|
|
"when present)"
|
|
),
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
project_path = os.path.abspath(args.project or os.getcwd())
|
|
global_exact_session = bool(
|
|
args.session and args.project is None and not args.query and not args.list
|
|
)
|
|
try:
|
|
if global_exact_session:
|
|
session_refs, source_warnings = discover_exact_session_refs(
|
|
args.session, args.history_sources
|
|
)
|
|
else:
|
|
session_refs, source_warnings = discover_session_refs(
|
|
project_path, args.history_sources
|
|
)
|
|
except HistorySourceConfigError as error:
|
|
print(f"History source configuration error: {error}", file=sys.stderr)
|
|
sys.exit(2)
|
|
for warning in source_warnings:
|
|
print(f"History source warning: {warning}", file=sys.stderr)
|
|
if args.exclude_current:
|
|
session_refs = [
|
|
ref for ref in session_refs
|
|
if ref.get("session_id") != args.exclude_current
|
|
]
|
|
|
|
if not session_refs:
|
|
if global_exact_session:
|
|
print(f"Error: session file not found for {args.session}", file=sys.stderr)
|
|
else:
|
|
print(
|
|
f"Error: no Claude session data found for {project_path}",
|
|
file=sys.stderr,
|
|
)
|
|
print(
|
|
"Looked across active Claude homes and every registered archive.",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
# ── List mode ──
|
|
if args.list:
|
|
print(f"Sessions for {project_path}:\n")
|
|
for ref in session_refs[:args.limit]:
|
|
labels = ", ".join(
|
|
source.display_label for source in ref.get("sources", [])
|
|
) or "unknown source"
|
|
print(f" {ref.get('session_id', '?')} [{labels}]")
|
|
print(f" {ref.get('path')}")
|
|
print()
|
|
|
|
sys.exit(0)
|
|
|
|
# ── Query mode ──
|
|
selected_ref = None
|
|
expected_session_id = None
|
|
if args.query:
|
|
needle = args.query.casefold()
|
|
matching_ids: set[str] = set()
|
|
for ref in session_refs:
|
|
for copy in ref.get("copies") or [{"path": ref["path"]}]:
|
|
for entry in load_sessions_index(Path(copy["path"]).parent):
|
|
if entry.get("sessionId") != ref.get("session_id"):
|
|
continue
|
|
first_prompt = str(entry.get("firstPrompt") or "").casefold()
|
|
summary = str(entry.get("summary") or "").casefold()
|
|
if needle in first_prompt or needle in summary:
|
|
matching_ids.add(ref["session_id"])
|
|
results = [ref for ref in session_refs if ref["session_id"] in matching_ids]
|
|
if not results:
|
|
print(f"No sessions matching '{args.query}'.", file=sys.stderr)
|
|
sys.exit(1)
|
|
print(f"Sessions matching '{args.query}' ({len(results)} found):\n")
|
|
for ref in results[: args.limit]:
|
|
print(f" {ref['session_id']}\n {ref['path']}")
|
|
print()
|
|
if len(results) == 1:
|
|
selected_ref = results[0]
|
|
expected_session_id = selected_ref["session_id"]
|
|
else:
|
|
sys.exit(0)
|
|
|
|
# ── Extract mode ──
|
|
if selected_ref is None and args.session:
|
|
exact = [ref for ref in session_refs if ref["session_id"] == args.session]
|
|
if exact:
|
|
selected_ref = exact[0]
|
|
expected_session_id = args.session
|
|
else:
|
|
# A fused file can end with a foreign internal Session ID, causing
|
|
# metadata discovery to index it under the wrong identity. The exact
|
|
# filename is still a candidate worth parsing so the identity gate can
|
|
# report the fusion instead of disguising it as "not found."
|
|
filename_matches = [
|
|
ref
|
|
for ref in session_refs
|
|
if any(
|
|
Path(copy["path"]).stem == args.session
|
|
for copy in ref.get("copies") or [{"path": ref["path"]}]
|
|
)
|
|
]
|
|
if len(filename_matches) == 1:
|
|
selected_ref = filename_matches[0]
|
|
expected_session_id = args.session
|
|
elif len(filename_matches) > 1:
|
|
print(
|
|
f"Error: exact Session filename {args.session!r} resolves to "
|
|
"multiple evidence groups.",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
matches = [
|
|
ref for ref in session_refs if args.session in ref["session_id"]
|
|
]
|
|
if selected_ref is not None:
|
|
pass
|
|
elif len(matches) == 1:
|
|
selected_ref = matches[0]
|
|
expected_session_id = selected_ref["session_id"]
|
|
elif len(matches) > 1:
|
|
print(
|
|
f"Error: session id fragment {args.session!r} is ambiguous; "
|
|
"pass the full Session ID.",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
else:
|
|
print(f"Error: session file not found for {args.session}", file=sys.stderr)
|
|
sys.exit(1)
|
|
if selected_ref is None:
|
|
selected_ref = session_refs[0]
|
|
expected_session_id = selected_ref["session_id"]
|
|
|
|
session_id = expected_session_id or selected_ref["session_id"]
|
|
try:
|
|
session_file, source_labels, copy_paths = select_session_copy(selected_ref)
|
|
except SessionEvidenceError as error:
|
|
print(f"Error: cannot recover complete Claude Session evidence: {error}", file=sys.stderr)
|
|
sys.exit(1)
|
|
project_dir = session_file.parent
|
|
entries = load_sessions_index(project_dir)
|
|
session_entry = next(
|
|
(entry for entry in entries if entry.get("sessionId") == session_id),
|
|
None,
|
|
)
|
|
|
|
# Parse and build briefing
|
|
print(f"Reading session {session_id} ({session_file.stat().st_size / 1_000_000:.1f} MB)...",
|
|
file=sys.stderr)
|
|
|
|
try:
|
|
parsed = parse_session_structure(session_file)
|
|
validate_selected_session_identity(
|
|
parsed.get("observed_session_ids") or set(), session_id
|
|
)
|
|
except (SessionEvidenceError, OSError, UnicodeError) as error:
|
|
print(
|
|
f"Error: cannot recover complete Claude Session evidence: {error}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
parsed["selected_session_id"] = session_id
|
|
parsed["source_labels"] = source_labels
|
|
parsed["copy_paths"] = copy_paths
|
|
if global_exact_session:
|
|
# The caller may be standing in an unrelated repository. Bind the
|
|
# workspace-state readback to the selected Session's own recorded cwd;
|
|
# an explicit --project remains the caller's intentional scope.
|
|
selected_cwd = scan_claude_session(session_file).cwd
|
|
if selected_cwd:
|
|
project_path = os.path.abspath(selected_cwd)
|
|
briefing = build_briefing(
|
|
session_entry,
|
|
parsed,
|
|
project_path,
|
|
project_dir,
|
|
session_file,
|
|
full=args.full,
|
|
)
|
|
|
|
print(briefing)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|