mirror of
https://github.com/daymade/claude-code-skills.git
synced 2026-09-14 16:15:21 +08:00
3f7aaa858f
* feat(history): split read and continue skills by provider * fix(history): fail closed on incomplete session evidence * fix(history): retain every textual session state * fix(history): require record-level Claude identity
1277 lines
48 KiB
Python
Executable File
1277 lines
48 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Recover files from Claude Code session history.
|
|
|
|
The highest-fidelity source is a ``file-history-snapshot`` record paired with
|
|
``<claude-home>/file-history/<session-id>/``. Those backups preserve exact
|
|
bytes captured after Write, Edit, and shell-driven changes. When no snapshot
|
|
metadata exists for a path, the script retains the older Write-tool recovery
|
|
mode and labels it as a lower-fidelity checkpoint.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
from collections import deque
|
|
from datetime import datetime
|
|
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
from typing import Any, Dict, Iterable, List, Optional
|
|
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from _core.sources import ( # noqa: E402
|
|
HistorySource,
|
|
HistorySourceConfigError,
|
|
discover_claude_sources,
|
|
)
|
|
|
|
|
|
BACKUP_VERSION_RE = re.compile(r"@v(\d+)$")
|
|
|
|
|
|
class RecoveryError(RuntimeError):
|
|
"""Raised when exact recovery cannot be completed safely."""
|
|
|
|
|
|
def _timestamp_rank(value: object) -> float:
|
|
if not isinstance(value, str) or not value:
|
|
return float("-inf")
|
|
try:
|
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
return float("-inf")
|
|
if parsed.tzinfo is None:
|
|
return float("-inf")
|
|
return parsed.timestamp()
|
|
|
|
|
|
def _backup_version(metadata: Dict[str, Any]) -> int:
|
|
value = metadata.get("version")
|
|
metadata_version: Optional[int] = None
|
|
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
|
|
metadata_version = value
|
|
elif isinstance(value, str) and value.isdigit():
|
|
metadata_version = int(value)
|
|
backup_name = metadata.get("backupFileName")
|
|
name_version: Optional[int] = None
|
|
if isinstance(backup_name, str):
|
|
match = BACKUP_VERSION_RE.search(backup_name)
|
|
if match:
|
|
name_version = int(match.group(1))
|
|
if (
|
|
metadata_version is not None
|
|
and name_version is not None
|
|
and metadata_version != name_version
|
|
):
|
|
raise ValueError(
|
|
f"version {metadata_version} conflicts with backup name {backup_name!r}"
|
|
)
|
|
version = metadata_version if metadata_version is not None else name_version
|
|
if version is None:
|
|
raise ValueError("snapshot entry has no valid non-negative version")
|
|
return version
|
|
|
|
|
|
def _entry_rank(entry: Dict[str, Any]) -> tuple[int, float, float]:
|
|
return (
|
|
entry["version"],
|
|
_timestamp_rank(entry.get("backup_time")),
|
|
_timestamp_rank(entry.get("snapshot_time")),
|
|
)
|
|
|
|
|
|
def _metadata_version_hint(metadata: Dict[str, Any]) -> Optional[int]:
|
|
"""Return a conservative version hint for malformed snapshot metadata."""
|
|
values: List[int] = []
|
|
raw_version = metadata.get("version")
|
|
if (
|
|
isinstance(raw_version, int)
|
|
and not isinstance(raw_version, bool)
|
|
and raw_version >= 0
|
|
):
|
|
values.append(raw_version)
|
|
elif isinstance(raw_version, str) and raw_version.isdigit():
|
|
values.append(int(raw_version))
|
|
backup_name = metadata.get("backupFileName")
|
|
if isinstance(backup_name, str):
|
|
match = BACKUP_VERSION_RE.search(backup_name)
|
|
if match:
|
|
values.append(int(match.group(1)))
|
|
return max(values) if values else None
|
|
|
|
|
|
def _inspect_file(path: Path) -> tuple[str, int, int]:
|
|
digest = hashlib.sha256()
|
|
size = 0
|
|
newlines = 0
|
|
with path.open("rb") as handle:
|
|
while chunk := handle.read(1024 * 1024):
|
|
digest.update(chunk)
|
|
size += len(chunk)
|
|
newlines += chunk.count(b"\n")
|
|
lines = newlines + (1 if size else 0)
|
|
return digest.hexdigest(), size, lines
|
|
|
|
|
|
def _atomic_write(path: Path, content: bytes) -> None:
|
|
"""Atomically replace ``path`` with ``content``."""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
descriptor, temporary_name = tempfile.mkstemp(
|
|
dir=path.parent,
|
|
prefix=f".{path.name}.",
|
|
suffix=".tmp",
|
|
)
|
|
temporary_path = Path(temporary_name)
|
|
try:
|
|
with os.fdopen(descriptor, "wb") as handle:
|
|
handle.write(content)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.replace(temporary_path, path)
|
|
finally:
|
|
if temporary_path.exists():
|
|
temporary_path.unlink()
|
|
|
|
|
|
def _atomic_copy(path: Path, source: Path, expected_sha256: str) -> None:
|
|
"""Copy one checkpoint in chunks and verify it before publishing it."""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
descriptor, temporary_name = tempfile.mkstemp(
|
|
dir=path.parent,
|
|
prefix=f".{path.name}.",
|
|
suffix=".tmp",
|
|
)
|
|
temporary_path = Path(temporary_name)
|
|
digest = hashlib.sha256()
|
|
try:
|
|
with (
|
|
source.open("rb") as input_handle,
|
|
os.fdopen(descriptor, "wb") as output_handle,
|
|
):
|
|
while chunk := input_handle.read(1024 * 1024):
|
|
digest.update(chunk)
|
|
output_handle.write(chunk)
|
|
output_handle.flush()
|
|
os.fsync(output_handle.fileno())
|
|
if digest.hexdigest() != expected_sha256:
|
|
raise RecoveryError(
|
|
f"Checkpoint changed while it was being copied: {source}"
|
|
)
|
|
os.replace(temporary_path, path)
|
|
finally:
|
|
if temporary_path.exists():
|
|
temporary_path.unlink()
|
|
|
|
|
|
class SessionContentRecovery:
|
|
"""Extract and recover files from all known copies of one Claude session."""
|
|
|
|
CODEX_RECORD_TYPES = {
|
|
"session_meta",
|
|
"response_item",
|
|
"event_msg",
|
|
"turn_context",
|
|
"world_state",
|
|
}
|
|
CLAUDE_RECORD_TYPES = {
|
|
"assistant",
|
|
"user",
|
|
"queue-operation",
|
|
"attachment",
|
|
"file-history-snapshot",
|
|
}
|
|
|
|
def __init__(
|
|
self,
|
|
session_file: Path,
|
|
output_dir: Optional[Path] = None,
|
|
file_history_roots: Optional[Iterable[Path]] = None,
|
|
write_only: bool = False,
|
|
) -> None:
|
|
self.session_file = Path(session_file).expanduser()
|
|
self.output_dir = (
|
|
Path(output_dir).expanduser()
|
|
if output_dir
|
|
else Path.cwd() / "recovered_content"
|
|
)
|
|
self.explicit_file_history_roots = [
|
|
Path(root).expanduser() for root in (file_history_roots or [])
|
|
]
|
|
self.write_only = write_only
|
|
self.warnings: List[str] = []
|
|
self._scan_result: Optional[Dict[str, Any]] = None
|
|
self._source_cache: Optional[List[HistorySource]] = None
|
|
self._session_file_cache: Optional[List[Path]] = None
|
|
|
|
self.stats = {
|
|
"total_lines": 0,
|
|
"duplicate_records_skipped": 0,
|
|
"session_copies": 0,
|
|
"write_calls": 0,
|
|
"failed_write_calls_skipped": 0,
|
|
"edit_calls": 0,
|
|
"snapshot_records": 0,
|
|
"snapshot_paths": 0,
|
|
"tombstone_paths": 0,
|
|
"files_recovered": 0,
|
|
"exact_recoveries": 0,
|
|
"write_recoveries": 0,
|
|
}
|
|
|
|
def _history_sources(self) -> List[HistorySource]:
|
|
if self._source_cache is not None:
|
|
return self._source_cache
|
|
try:
|
|
sources, warnings = discover_claude_sources()
|
|
except HistorySourceConfigError as error:
|
|
raise RecoveryError(str(error)) from error
|
|
self.warnings.extend(warnings)
|
|
self._source_cache = sources
|
|
return sources
|
|
|
|
def _discover_session_files(self) -> List[Path]:
|
|
"""Find same-id JSONL copies in active homes and registered archives."""
|
|
if self._session_file_cache is not None:
|
|
return self._session_file_cache
|
|
|
|
files: List[Path] = []
|
|
seen: set[str] = set()
|
|
|
|
def add(candidate: Path) -> None:
|
|
if not candidate.is_file():
|
|
return
|
|
try:
|
|
key = str(candidate.resolve())
|
|
except OSError:
|
|
key = str(candidate.absolute())
|
|
if key not in seen:
|
|
seen.add(key)
|
|
files.append(candidate)
|
|
|
|
add(self.session_file)
|
|
filename = self.session_file.name
|
|
for source in self._history_sources():
|
|
projects_dir = source.home / "projects"
|
|
if not projects_dir.is_dir():
|
|
continue
|
|
for project_dir in sorted(projects_dir.iterdir()):
|
|
if project_dir.is_dir():
|
|
add(project_dir / filename)
|
|
|
|
if not files:
|
|
raise RecoveryError(f"Session file not found: {self.session_file}")
|
|
self._session_file_cache = files
|
|
self.stats["session_copies"] = len(files)
|
|
return files
|
|
|
|
@staticmethod
|
|
def _record_error(
|
|
errors: Dict[str, List[Dict[str, Any]]],
|
|
original_path: str,
|
|
message: str,
|
|
metadata: Optional[Dict[str, Any]],
|
|
snapshot_time: object,
|
|
source_file: Path,
|
|
line_num: int,
|
|
) -> None:
|
|
version = _metadata_version_hint(metadata or {})
|
|
rank = (
|
|
(
|
|
version,
|
|
_timestamp_rank((metadata or {}).get("backupTime")),
|
|
_timestamp_rank(snapshot_time),
|
|
)
|
|
if version is not None
|
|
else None
|
|
)
|
|
errors.setdefault(original_path, []).append(
|
|
{
|
|
"message": f"{source_file}:{line_num}: {message}",
|
|
"rank": rank,
|
|
}
|
|
)
|
|
|
|
def _consider_snapshot_entry(
|
|
self,
|
|
mapping: Dict[str, Dict[str, Any]],
|
|
candidate: Dict[str, Any],
|
|
errors: Dict[str, List[Dict[str, Any]]],
|
|
) -> None:
|
|
original_path = candidate["file_path"]
|
|
previous = mapping.get(original_path)
|
|
if previous is None or _entry_rank(candidate) > _entry_rank(previous):
|
|
mapping[original_path] = candidate
|
|
return
|
|
if _entry_rank(candidate) != _entry_rank(previous):
|
|
return
|
|
previous_name = previous.get("backup_file_name")
|
|
candidate_name = candidate.get("backup_file_name")
|
|
if previous_name != candidate_name:
|
|
self._record_error(
|
|
errors,
|
|
original_path,
|
|
"conflicting snapshot states have the same version and timestamp",
|
|
{
|
|
"version": candidate["version"],
|
|
"backupFileName": candidate_name,
|
|
"backupTime": candidate.get("backup_time"),
|
|
},
|
|
candidate.get("snapshot_time"),
|
|
candidate["source_file"],
|
|
candidate["line"],
|
|
)
|
|
|
|
def _consume_snapshot(
|
|
self,
|
|
data: Dict[str, Any],
|
|
source_file: Path,
|
|
line_num: int,
|
|
snapshots: Dict[str, Dict[str, Any]],
|
|
tombstones: Dict[str, Dict[str, Any]],
|
|
errors: Dict[str, List[Dict[str, Any]]],
|
|
) -> None:
|
|
self.stats["snapshot_records"] += 1
|
|
snapshot = data.get("snapshot")
|
|
if not isinstance(snapshot, dict):
|
|
self.warnings.append(
|
|
f"{source_file}:{line_num}: snapshot payload is not an object"
|
|
)
|
|
return
|
|
tracked = snapshot.get("trackedFileBackups")
|
|
if tracked is None:
|
|
return
|
|
if not isinstance(tracked, dict):
|
|
self.warnings.append(
|
|
f"{source_file}:{line_num}: trackedFileBackups is not an object"
|
|
)
|
|
return
|
|
|
|
snapshot_time = snapshot.get("timestamp", "")
|
|
for original_path, metadata in tracked.items():
|
|
if not isinstance(original_path, str) or not original_path:
|
|
self.warnings.append(
|
|
f"{source_file}:{line_num}: ignored snapshot entry with an invalid path"
|
|
)
|
|
continue
|
|
if not isinstance(metadata, dict):
|
|
self._record_error(
|
|
errors,
|
|
original_path,
|
|
"snapshot metadata is not an object",
|
|
None,
|
|
snapshot_time,
|
|
source_file,
|
|
line_num,
|
|
)
|
|
continue
|
|
if "backupFileName" not in metadata:
|
|
self._record_error(
|
|
errors,
|
|
original_path,
|
|
"snapshot metadata is missing backupFileName",
|
|
metadata,
|
|
snapshot_time,
|
|
source_file,
|
|
line_num,
|
|
)
|
|
continue
|
|
|
|
backup_name = metadata.get("backupFileName")
|
|
if backup_name is not None and (
|
|
not isinstance(backup_name, str) or not backup_name
|
|
):
|
|
self._record_error(
|
|
errors,
|
|
original_path,
|
|
"snapshot backupFileName must be a non-empty string or null",
|
|
metadata,
|
|
snapshot_time,
|
|
source_file,
|
|
line_num,
|
|
)
|
|
continue
|
|
try:
|
|
version = _backup_version(metadata)
|
|
except ValueError as error:
|
|
self._record_error(
|
|
errors,
|
|
original_path,
|
|
str(error),
|
|
metadata,
|
|
snapshot_time,
|
|
source_file,
|
|
line_num,
|
|
)
|
|
continue
|
|
|
|
candidate = {
|
|
"line": line_num,
|
|
"source_file": source_file,
|
|
"file_path": original_path,
|
|
"backup_file_name": backup_name,
|
|
"version": version,
|
|
"backup_time": metadata.get("backupTime", ""),
|
|
"snapshot_time": snapshot_time,
|
|
}
|
|
destination = tombstones if backup_name is None else snapshots
|
|
self._consider_snapshot_entry(destination, candidate, errors)
|
|
|
|
def _scan_session(self) -> Dict[str, Any]:
|
|
if self._scan_result is not None:
|
|
return self._scan_result
|
|
|
|
writes: List[Dict[str, Any]] = []
|
|
failed_tool_use_ids: set[str] = set()
|
|
edit_summaries: deque[Dict[str, Any]] = deque(maxlen=5)
|
|
snapshots: Dict[str, Dict[str, Any]] = {}
|
|
tombstones: Dict[str, Dict[str, Any]] = {}
|
|
snapshot_error_candidates: Dict[str, List[Dict[str, Any]]] = {}
|
|
session_ids: List[str] = []
|
|
record_hashes_from_prior_copies: set[str] = set()
|
|
saw_claude_signature = False
|
|
saw_codex_signature = False
|
|
session_files = self._discover_session_files()
|
|
|
|
for session_file in session_files:
|
|
copy_record_hashes: set[str] = set()
|
|
stem = session_file.stem
|
|
if stem and stem not in session_ids:
|
|
session_ids.append(stem)
|
|
try:
|
|
handle = session_file.open("r", encoding="utf-8", errors="replace")
|
|
except OSError as error:
|
|
raise RecoveryError(
|
|
f"Cannot read session copy {session_file}: {error}"
|
|
) from error
|
|
with handle:
|
|
for line_num, line in enumerate(handle, 1):
|
|
self.stats["total_lines"] += 1
|
|
try:
|
|
data = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if not isinstance(data, dict):
|
|
continue
|
|
|
|
canonical = json.dumps(
|
|
data,
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
).encode("utf-8")
|
|
record_hash = hashlib.sha256(canonical).hexdigest()
|
|
if record_hash in record_hashes_from_prior_copies:
|
|
self.stats["duplicate_records_skipped"] += 1
|
|
continue
|
|
copy_record_hashes.add(record_hash)
|
|
|
|
record_type = data.get("type")
|
|
if record_type in self.CODEX_RECORD_TYPES:
|
|
saw_codex_signature = True
|
|
if record_type in self.CLAUDE_RECORD_TYPES or isinstance(
|
|
data.get("sessionId"), str
|
|
):
|
|
saw_claude_signature = True
|
|
|
|
session_id = data.get("sessionId")
|
|
if (
|
|
isinstance(session_id, str)
|
|
and session_id
|
|
and session_id not in session_ids
|
|
):
|
|
session_ids.append(session_id)
|
|
|
|
if record_type == "file-history-snapshot":
|
|
self._consume_snapshot(
|
|
data,
|
|
session_file,
|
|
line_num,
|
|
snapshots,
|
|
tombstones,
|
|
snapshot_error_candidates,
|
|
)
|
|
|
|
message = data.get("message")
|
|
nested_role = (
|
|
message.get("role") if isinstance(message, dict) else None
|
|
)
|
|
role = data.get("role") or nested_role
|
|
content = data.get("content")
|
|
if content is None and isinstance(message, dict):
|
|
content = message.get("content", [])
|
|
if not isinstance(content, list):
|
|
continue
|
|
|
|
for item in content:
|
|
if (
|
|
isinstance(item, dict)
|
|
and item.get("type") == "tool_result"
|
|
and item.get("is_error") is True
|
|
):
|
|
tool_use_id = item.get("tool_use_id")
|
|
if isinstance(tool_use_id, str) and tool_use_id:
|
|
failed_tool_use_ids.add(tool_use_id)
|
|
|
|
if role != "assistant":
|
|
continue
|
|
|
|
for item in content:
|
|
if not isinstance(item, dict) or item.get("type") != "tool_use":
|
|
continue
|
|
tool_input = item.get("input")
|
|
if not isinstance(tool_input, dict):
|
|
continue
|
|
if item.get("name") == "Write":
|
|
file_path = tool_input.get("file_path", "")
|
|
file_content = tool_input.get("content", "")
|
|
if isinstance(file_path, str) and isinstance(
|
|
file_content, str
|
|
):
|
|
writes.append(
|
|
{
|
|
"line": line_num,
|
|
"source_file": session_file,
|
|
"file_path": file_path,
|
|
"content": file_content,
|
|
"timestamp": data.get("timestamp", ""),
|
|
"tool_use_id": (
|
|
item.get("id")
|
|
if isinstance(item.get("id"), str)
|
|
else None
|
|
),
|
|
}
|
|
)
|
|
self.stats["write_calls"] += 1
|
|
elif item.get("name") == "Edit":
|
|
edit_summaries.append(
|
|
{
|
|
"line": line_num,
|
|
"source_file": session_file,
|
|
"file_path": tool_input.get("file_path", ""),
|
|
"timestamp": data.get("timestamp", ""),
|
|
}
|
|
)
|
|
self.stats["edit_calls"] += 1
|
|
|
|
record_hashes_from_prior_copies.update(copy_record_hashes)
|
|
|
|
usable_writes: List[Dict[str, Any]] = []
|
|
for write in writes:
|
|
tool_use_id = write.get("tool_use_id")
|
|
if tool_use_id and tool_use_id in failed_tool_use_ids:
|
|
self.stats["failed_write_calls_skipped"] += 1
|
|
self.warnings.append(
|
|
"Skipped Write checkpoint with an explicit failed tool result: "
|
|
f"{write['file_path']} ({write['source_file']}:{write['line']})"
|
|
)
|
|
continue
|
|
usable_writes.append(write)
|
|
writes = usable_writes
|
|
|
|
if saw_codex_signature and not saw_claude_signature:
|
|
raise RecoveryError(
|
|
"This is a Codex rollout. Keyword search supports Codex with "
|
|
"analyze_sessions.py --codex, but file recovery currently supports "
|
|
"Claude Code JSONL sessions only."
|
|
)
|
|
|
|
all_snapshot_paths = set(snapshots) | set(tombstones)
|
|
blocking_errors: Dict[str, str] = {}
|
|
for original_path, error_entries in snapshot_error_candidates.items():
|
|
valid_ranks = [
|
|
_entry_rank(entry)
|
|
for entry in (
|
|
snapshots.get(original_path),
|
|
tombstones.get(original_path),
|
|
)
|
|
if entry is not None
|
|
]
|
|
best_valid_rank = max(valid_ranks) if valid_ranks else None
|
|
for error_entry in error_entries:
|
|
error_rank = error_entry["rank"]
|
|
if (
|
|
error_rank is None
|
|
or best_valid_rank is None
|
|
or error_rank >= best_valid_rank
|
|
):
|
|
blocking_errors[original_path] = error_entry["message"]
|
|
else:
|
|
self.warnings.append(
|
|
"Ignored older malformed snapshot metadata for "
|
|
f"{original_path}: {error_entry['message']}"
|
|
)
|
|
|
|
for original_path in set(snapshots) & set(tombstones):
|
|
if _entry_rank(snapshots[original_path]) == _entry_rank(
|
|
tombstones[original_path]
|
|
):
|
|
blocking_errors[original_path] = (
|
|
"the same file-history version is recorded as both a backup "
|
|
"and a deletion tombstone"
|
|
)
|
|
|
|
self.stats["snapshot_paths"] = len(all_snapshot_paths)
|
|
self.stats["tombstone_paths"] = len(tombstones)
|
|
self._scan_result = {
|
|
"writes": writes,
|
|
"edit_summaries": list(edit_summaries),
|
|
"snapshots": snapshots,
|
|
"tombstones": tombstones,
|
|
"snapshot_errors": blocking_errors,
|
|
"session_ids": session_ids,
|
|
"session_files": session_files,
|
|
}
|
|
return self._scan_result
|
|
|
|
def extract_write_calls(self) -> List[Dict[str, Any]]:
|
|
"""Return every valid Write tool call found in the session union."""
|
|
return list(self._scan_session()["writes"])
|
|
|
|
def extract_edit_calls(self) -> List[Dict[str, Any]]:
|
|
"""Return lightweight summaries for at most the five latest Edit calls."""
|
|
return list(self._scan_session()["edit_summaries"])
|
|
|
|
def extract_file_history_snapshots(self) -> Dict[str, Dict[str, Any]]:
|
|
"""Return the latest usable backup checkpoint for every path."""
|
|
return dict(self._scan_session()["snapshots"])
|
|
|
|
def _file_history_roots(self) -> List[Path]:
|
|
roots: List[Path] = []
|
|
seen: set[str] = set()
|
|
|
|
def add(root: Path) -> None:
|
|
expanded = root.expanduser()
|
|
try:
|
|
key = str(expanded.resolve())
|
|
except OSError:
|
|
key = str(expanded.absolute())
|
|
if key not in seen:
|
|
seen.add(key)
|
|
roots.append(expanded)
|
|
|
|
for session_file in self._discover_session_files():
|
|
for parent in session_file.resolve().parents:
|
|
if parent.name == "projects":
|
|
add(parent.parent / "file-history")
|
|
break
|
|
for source in self._history_sources():
|
|
add(source.home / "file-history")
|
|
for root in self.explicit_file_history_roots:
|
|
add(root)
|
|
return roots
|
|
|
|
@staticmethod
|
|
def _safe_identifier(value: str, label: str) -> str:
|
|
if not value or value in {".", ".."} or "/" in value or "\\" in value:
|
|
raise RecoveryError(f"Unsafe {label}: {value!r}")
|
|
return value
|
|
|
|
def _read_snapshot_backup(
|
|
self, entry: Dict[str, Any], roots: List[Path], session_ids: List[str]
|
|
) -> tuple[Path, str, int, int]:
|
|
backup_name = self._safe_identifier(
|
|
entry["backup_file_name"], "file-history backup name"
|
|
)
|
|
safe_session_ids = [
|
|
self._safe_identifier(session_id, "session id")
|
|
for session_id in session_ids
|
|
]
|
|
matches: List[tuple[Path, str, int, int]] = []
|
|
searched: List[Path] = []
|
|
|
|
for root in roots:
|
|
try:
|
|
resolved_root = root.resolve()
|
|
except OSError:
|
|
resolved_root = root.absolute()
|
|
for session_id in safe_session_ids:
|
|
session_dir = root / session_id
|
|
candidate = session_dir / backup_name
|
|
searched.append(candidate)
|
|
if session_dir.is_symlink():
|
|
raise RecoveryError(
|
|
f"Unsafe file-history session directory symlink: {session_dir}"
|
|
)
|
|
if not session_dir.exists():
|
|
continue
|
|
if not session_dir.is_dir():
|
|
raise RecoveryError(
|
|
"Unsafe file-history session object (expected a directory): "
|
|
f"{session_dir}"
|
|
)
|
|
try:
|
|
resolved_dir = session_dir.resolve()
|
|
resolved_dir.relative_to(resolved_root)
|
|
except (OSError, ValueError) as error:
|
|
raise RecoveryError(
|
|
f"file-history session directory escapes its root: {session_dir}"
|
|
) from error
|
|
if candidate.is_symlink():
|
|
raise RecoveryError(
|
|
f"Unsafe file-history backup symlink: {candidate}"
|
|
)
|
|
if not candidate.exists():
|
|
continue
|
|
if not candidate.is_file():
|
|
raise RecoveryError(
|
|
"Unsafe file-history backup object (expected a regular file): "
|
|
f"{candidate}"
|
|
)
|
|
try:
|
|
resolved_candidate = candidate.resolve()
|
|
resolved_candidate.relative_to(resolved_dir)
|
|
resolved_candidate.relative_to(resolved_root)
|
|
except (OSError, ValueError) as error:
|
|
raise RecoveryError(
|
|
f"file-history backup escapes its session directory: {candidate}"
|
|
) from error
|
|
try:
|
|
digest, size, lines = _inspect_file(candidate)
|
|
except OSError as error:
|
|
raise RecoveryError(
|
|
f"Cannot read file-history backup {candidate}: {error}"
|
|
) from error
|
|
matches.append((candidate, digest, size, lines))
|
|
|
|
if not matches:
|
|
searched_dirs = sorted({str(path.parent) for path in searched})
|
|
detail = (
|
|
", ".join(searched_dirs) if searched_dirs else "no roots discovered"
|
|
)
|
|
raise RecoveryError(
|
|
"Snapshot metadata exists but its exact backup is unavailable for "
|
|
f"{entry['file_path']}. Expected {backup_name} under: {detail}. "
|
|
"Provide the companion root with --file-history-root, or use "
|
|
"--write-only only if a lower-fidelity Write checkpoint is acceptable."
|
|
)
|
|
|
|
digests = {digest for _, digest, _, _ in matches}
|
|
if len(digests) != 1:
|
|
locations = ", ".join(str(path) for path, _, _, _ in matches)
|
|
raise RecoveryError(
|
|
"Conflicting file-history backups have the same metadata name but "
|
|
f"different bytes: {locations}"
|
|
)
|
|
return matches[0]
|
|
|
|
def _output_path(self, original_path: str) -> Path:
|
|
if re.match(r"^[A-Za-z]:[\\/]", original_path):
|
|
pure_path = PureWindowsPath(original_path)
|
|
parts = list(pure_path.parts)
|
|
start = 1
|
|
if len(parts) > 2 and parts[1].lower() == "users":
|
|
start = 3
|
|
else:
|
|
pure_path = PurePosixPath(original_path)
|
|
parts = list(pure_path.parts)
|
|
start = 0
|
|
if pure_path.is_absolute():
|
|
start = 1
|
|
if len(parts) > 2 and parts[1].lower() in {"users", "home"}:
|
|
start = 3
|
|
|
|
relative_parts = parts[start:]
|
|
if not relative_parts and pure_path.name:
|
|
relative_parts = [pure_path.name]
|
|
if not relative_parts or any(
|
|
part in {"", ".", ".."} for part in relative_parts
|
|
):
|
|
raise RecoveryError(f"Unsafe recovered file path: {original_path!r}")
|
|
|
|
output_path = self.output_dir.joinpath(*relative_parts)
|
|
try:
|
|
output_path.resolve().relative_to(self.output_dir.resolve())
|
|
except (OSError, ValueError) as error:
|
|
raise RecoveryError(
|
|
f"Recovered file path escapes the output directory: {original_path!r}"
|
|
) from error
|
|
return output_path
|
|
|
|
def _preflight_destinations(self, planned: List[Dict[str, Any]]) -> None:
|
|
"""Reject deterministic collisions before writing any recovered bytes."""
|
|
if self.output_dir.exists() and not self.output_dir.is_dir():
|
|
raise RecoveryError(
|
|
f"Recovery output path is not a directory: {self.output_dir}"
|
|
)
|
|
|
|
report_path = self.output_dir / "recovery_report.txt"
|
|
report_key = report_path.resolve()
|
|
destinations: Dict[Path, str] = {}
|
|
for item in planned:
|
|
output_path = self._output_path(item["original_path"])
|
|
key = output_path.resolve()
|
|
if key == report_key:
|
|
raise RecoveryError(
|
|
"A recovered artifact would overwrite the reserved recovery "
|
|
f"report: {item['original_path']!r} -> {report_path}"
|
|
)
|
|
collision = destinations.get(key)
|
|
if collision and collision != item["original_path"]:
|
|
raise RecoveryError(
|
|
"Two original paths map to the same recovery destination: "
|
|
f"{collision!r} and {item['original_path']!r} -> {output_path}"
|
|
)
|
|
destinations[key] = item["original_path"]
|
|
item["output_path"] = output_path
|
|
|
|
destination_keys = list(destinations)
|
|
for index, first in enumerate(destination_keys):
|
|
for second in destination_keys[index + 1 :]:
|
|
if first in second.parents or second in first.parents:
|
|
raise RecoveryError(
|
|
"Recovered destinations have a file/directory ancestor "
|
|
f"collision: {first} and {second}"
|
|
)
|
|
|
|
for destination in [
|
|
*(item["output_path"] for item in planned),
|
|
report_path,
|
|
]:
|
|
if destination.is_symlink():
|
|
raise RecoveryError(
|
|
f"Recovery destination is an existing symlink: {destination}"
|
|
)
|
|
if destination.exists() and destination.is_dir():
|
|
raise RecoveryError(
|
|
f"Recovery destination is an existing directory: {destination}"
|
|
)
|
|
parent = destination.parent
|
|
while parent != self.output_dir and self.output_dir in parent.parents:
|
|
if parent.is_symlink():
|
|
raise RecoveryError(
|
|
f"Recovery destination parent is a symlink: {parent}"
|
|
)
|
|
if parent.exists() and not parent.is_dir():
|
|
raise RecoveryError(
|
|
f"Recovery destination parent is not a directory: {parent}"
|
|
)
|
|
parent = parent.parent
|
|
|
|
@staticmethod
|
|
def _tombstone_note(tombstone: Dict[str, Any]) -> str:
|
|
timestamp = tombstone.get("backup_time") or tombstone.get("snapshot_time")
|
|
when = timestamp if isinstance(timestamp, str) and timestamp else "unknown time"
|
|
return f"recorded deleted at file-history v{tombstone['version']} ({when})"
|
|
|
|
@staticmethod
|
|
def _tombstone_follows_write(
|
|
tombstone: Dict[str, Any], write: Dict[str, Any]
|
|
) -> bool:
|
|
tombstone_time = _timestamp_rank(
|
|
tombstone.get("backup_time") or tombstone.get("snapshot_time")
|
|
)
|
|
write_time = _timestamp_rank(write.get("timestamp"))
|
|
return tombstone_time > write_time
|
|
|
|
@staticmethod
|
|
def _write_follows_tombstone(
|
|
write: Dict[str, Any], tombstone: Dict[str, Any]
|
|
) -> bool:
|
|
write_time = _timestamp_rank(write.get("timestamp"))
|
|
tombstone_time = _timestamp_rank(
|
|
tombstone.get("backup_time") or tombstone.get("snapshot_time")
|
|
)
|
|
return write_time > tombstone_time
|
|
|
|
@staticmethod
|
|
def _write_follows_snapshot(
|
|
write: Dict[str, Any], snapshot: Dict[str, Any]
|
|
) -> bool:
|
|
write_time = _timestamp_rank(write.get("timestamp"))
|
|
snapshot_time = max(
|
|
_timestamp_rank(snapshot.get("backup_time")),
|
|
_timestamp_rank(snapshot.get("snapshot_time")),
|
|
)
|
|
return (
|
|
write_time != float("-inf")
|
|
and snapshot_time != float("-inf")
|
|
and write_time > snapshot_time
|
|
)
|
|
|
|
def _select_writes(
|
|
self, writes: List[Dict[str, Any]]
|
|
) -> tuple[Dict[str, Dict[str, Any]], Dict[str, str]]:
|
|
calls_by_path: Dict[str, List[Dict[str, Any]]] = {}
|
|
for call in writes:
|
|
file_path = call["file_path"]
|
|
if file_path:
|
|
calls_by_path.setdefault(file_path, []).append(call)
|
|
|
|
selected: Dict[str, Dict[str, Any]] = {}
|
|
conflicts: Dict[str, str] = {}
|
|
for file_path, calls in calls_by_path.items():
|
|
latest_rank = max(_timestamp_rank(call.get("timestamp")) for call in calls)
|
|
latest = [
|
|
call
|
|
for call in calls
|
|
if _timestamp_rank(call.get("timestamp")) == latest_rank
|
|
]
|
|
latest.sort(key=lambda call: (str(call["source_file"]), call["line"]))
|
|
selected[file_path] = latest[0]
|
|
conflict = next(
|
|
(
|
|
call
|
|
for call in latest[1:]
|
|
if call["content"] != latest[0]["content"]
|
|
),
|
|
None,
|
|
)
|
|
if conflict is not None:
|
|
conflicts[file_path] = (
|
|
"Conflicting Write checkpoints have the same timestamp for "
|
|
f"{file_path}: {latest[0]['source_file']}:{latest[0]['line']} and "
|
|
f"{conflict['source_file']}:{conflict['line']}"
|
|
)
|
|
return selected, conflicts
|
|
|
|
def recover_files(
|
|
self, keywords: Optional[List[str]] = None
|
|
) -> List[Dict[str, Any]]:
|
|
"""Plan and preflight the whole recovery before writing selected files."""
|
|
scan = self._scan_session()
|
|
snapshots: Dict[str, Dict[str, Any]] = scan["snapshots"]
|
|
tombstones: Dict[str, Dict[str, Any]] = scan["tombstones"]
|
|
snapshot_errors: Dict[str, str] = scan["snapshot_errors"]
|
|
writes_by_path, write_conflicts = self._select_writes(scan["writes"])
|
|
failures: List[str] = []
|
|
|
|
all_paths = (
|
|
set(writes_by_path)
|
|
| set(write_conflicts)
|
|
| set(snapshots)
|
|
| set(tombstones)
|
|
| set(snapshot_errors)
|
|
)
|
|
if keywords:
|
|
lowered = [keyword.casefold() for keyword in keywords]
|
|
all_paths = {
|
|
path
|
|
for path in all_paths
|
|
if any(keyword in path.casefold() for keyword in lowered)
|
|
}
|
|
|
|
roots = self._file_history_roots()
|
|
planned: List[Dict[str, Any]] = []
|
|
|
|
for original_path in sorted(all_paths):
|
|
if not self.write_only and original_path in snapshot_errors:
|
|
failures.append(f"{original_path}: {snapshot_errors[original_path]}")
|
|
continue
|
|
|
|
snapshot = snapshots.get(original_path)
|
|
tombstone = tombstones.get(original_path)
|
|
write = writes_by_path.get(original_path)
|
|
later_state: Optional[str] = None
|
|
latest_tombstone = (
|
|
tombstone
|
|
if tombstone is not None
|
|
and (
|
|
snapshot is None
|
|
or _entry_rank(tombstone) > _entry_rank(snapshot)
|
|
)
|
|
else None
|
|
)
|
|
write_supersedes_snapshot = bool(
|
|
write is not None
|
|
and snapshot is not None
|
|
and (
|
|
self._write_follows_snapshot(write, snapshot)
|
|
or (
|
|
latest_tombstone is not None
|
|
and self._write_follows_tombstone(write, latest_tombstone)
|
|
)
|
|
)
|
|
)
|
|
if (
|
|
snapshot
|
|
and latest_tombstone
|
|
and not write_supersedes_snapshot
|
|
):
|
|
later_state = self._tombstone_note(latest_tombstone)
|
|
|
|
if (
|
|
not self.write_only
|
|
and snapshot is not None
|
|
and not write_supersedes_snapshot
|
|
):
|
|
try:
|
|
backup_path, digest, size, lines = self._read_snapshot_backup(
|
|
snapshot, roots, scan["session_ids"]
|
|
)
|
|
except RecoveryError as error:
|
|
failures.append(str(error))
|
|
continue
|
|
fidelity = "exact bytes from captured checkpoint"
|
|
if later_state:
|
|
fidelity += " before a later recorded deletion"
|
|
planned.append(
|
|
{
|
|
"original_path": original_path,
|
|
"content": None,
|
|
"source_file": backup_path,
|
|
"sha256": digest,
|
|
"size": size,
|
|
"lines": lines,
|
|
"source": "file-history",
|
|
"source_path": str(backup_path),
|
|
"version": snapshot["version"],
|
|
"timestamp": snapshot["backup_time"]
|
|
or snapshot["snapshot_time"],
|
|
"fidelity": fidelity,
|
|
"later_state": later_state,
|
|
}
|
|
)
|
|
continue
|
|
|
|
if write is None:
|
|
if self.write_only and (snapshot is not None or tombstone is not None):
|
|
self.warnings.append(
|
|
"Skipped snapshot-only path in --write-only mode: "
|
|
f"{original_path}"
|
|
)
|
|
elif tombstone is not None:
|
|
self.warnings.append(
|
|
"Skipped deleted path with no recoverable prior checkpoint: "
|
|
f"{original_path} ({self._tombstone_note(tombstone)})"
|
|
)
|
|
continue
|
|
|
|
if original_path in write_conflicts:
|
|
failures.append(write_conflicts[original_path])
|
|
continue
|
|
|
|
content = write["content"].encode("utf-8")
|
|
if latest_tombstone is not None and self._tombstone_follows_write(
|
|
latest_tombstone, write
|
|
):
|
|
later_state = self._tombstone_note(latest_tombstone)
|
|
planned.append(
|
|
{
|
|
"original_path": original_path,
|
|
"content": content,
|
|
"source_file": None,
|
|
"sha256": hashlib.sha256(content).hexdigest(),
|
|
"size": len(content),
|
|
"lines": content.count(b"\n") + (1 if content else 0),
|
|
"source": "Write",
|
|
"source_path": f"{write['source_file']}:{write['line']}",
|
|
"version": None,
|
|
"timestamp": write.get("timestamp", ""),
|
|
"fidelity": (
|
|
"Write checkpoint; later Edit or shell changes may be absent"
|
|
),
|
|
"later_state": later_state,
|
|
}
|
|
)
|
|
|
|
if failures:
|
|
raise RecoveryError(
|
|
"Recovery aborted before writing files:\n- " + "\n- ".join(failures)
|
|
)
|
|
|
|
self._preflight_destinations(planned)
|
|
saved: List[Dict[str, Any]] = []
|
|
for item in planned:
|
|
output_path = item["output_path"]
|
|
if item["source"] == "file-history":
|
|
_atomic_copy(output_path, item["source_file"], item["sha256"])
|
|
else:
|
|
_atomic_write(output_path, item["content"])
|
|
saved.append(
|
|
{
|
|
"file": output_path.name,
|
|
"original_path": item["original_path"],
|
|
"size": item["size"],
|
|
"lines": item["lines"],
|
|
"timestamp": item["timestamp"] or "unknown",
|
|
"output_path": str(output_path),
|
|
"source": item["source"],
|
|
"source_path": item["source_path"],
|
|
"version": item["version"],
|
|
"fidelity": item["fidelity"],
|
|
"later_state": item["later_state"],
|
|
"sha256": item["sha256"],
|
|
}
|
|
)
|
|
self.stats["files_recovered"] += 1
|
|
if item["source"] == "file-history":
|
|
self.stats["exact_recoveries"] += 1
|
|
else:
|
|
self.stats["write_recoveries"] += 1
|
|
return saved
|
|
|
|
def save_recovered_files(
|
|
self, write_calls: List[Dict[str, Any]], keywords: Optional[List[str]] = None
|
|
) -> List[Dict[str, Any]]:
|
|
"""Compatibility wrapper; recovery now selects the best source itself."""
|
|
del write_calls
|
|
return self.recover_files(keywords)
|
|
|
|
def generate_report(self, saved_files: List[Dict[str, Any]]) -> str:
|
|
"""Generate a provenance-rich recovery report."""
|
|
scan = self._scan_session()
|
|
report_lines = [
|
|
"=" * 60,
|
|
"Claude Code Session Content Recovery Report",
|
|
"=" * 60,
|
|
"",
|
|
f"Requested session file: {self.session_file}",
|
|
f"Output directory: {self.output_dir}",
|
|
"",
|
|
"Session copies scanned:",
|
|
*(f" - {path}" for path in scan["session_files"]),
|
|
"",
|
|
"Statistics:",
|
|
f" Session copies: {self.stats['session_copies']}",
|
|
f" Total lines processed: {self.stats['total_lines']:,}",
|
|
f" Duplicate records skipped: {self.stats['duplicate_records_skipped']}",
|
|
f" Write tool calls found: {self.stats['write_calls']}",
|
|
" Failed Write tool calls skipped: "
|
|
f"{self.stats['failed_write_calls_skipped']}",
|
|
f" Edit tool calls found: {self.stats['edit_calls']}",
|
|
f" File-history snapshot records: {self.stats['snapshot_records']}",
|
|
f" Paths with snapshot metadata: {self.stats['snapshot_paths']}",
|
|
f" Paths with deletion tombstones: {self.stats['tombstone_paths']}",
|
|
f" Files recovered: {self.stats['files_recovered']}",
|
|
f" Exact checkpoint recoveries: {self.stats['exact_recoveries']}",
|
|
f" Write checkpoint recoveries: {self.stats['write_recoveries']}",
|
|
"",
|
|
]
|
|
|
|
if self.warnings:
|
|
report_lines.append("Warnings:")
|
|
report_lines.extend(f" - {warning}" for warning in self.warnings)
|
|
report_lines.append("")
|
|
|
|
if saved_files:
|
|
report_lines.extend(["Recovered Files:", ""])
|
|
for item in saved_files:
|
|
version = (
|
|
f" v{item['version']}" if isinstance(item["version"], int) else ""
|
|
)
|
|
report_lines.extend(
|
|
[
|
|
f"OK {item['file']}",
|
|
f" Original: {item['original_path']}",
|
|
f" Source: {item['source']}{version} ({item['source_path']})",
|
|
f" Fidelity: {item['fidelity']}",
|
|
]
|
|
)
|
|
if item["later_state"]:
|
|
report_lines.append(f" Later state: {item['later_state']}")
|
|
report_lines.extend(
|
|
[
|
|
f" Captured: {item['timestamp']}",
|
|
f" Size: {item['size']:,} bytes",
|
|
f" Lines: {item['lines']:,}",
|
|
f" SHA-256: {item['sha256']}",
|
|
f" Saved to: {item['output_path']}",
|
|
"",
|
|
]
|
|
)
|
|
else:
|
|
report_lines.extend(["No files matched the requested recovery scope.", ""])
|
|
|
|
report_lines.extend(["=" * 60, ""])
|
|
return "\n".join(report_lines)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description=(
|
|
"Recover exact file-history checkpoints when available, otherwise "
|
|
"recover explicitly labeled Write-tool checkpoints"
|
|
)
|
|
)
|
|
parser.add_argument("session_file", type=Path, help="Claude Code session JSONL")
|
|
parser.add_argument(
|
|
"-o",
|
|
"--output",
|
|
type=Path,
|
|
help="Output directory (default: ./recovered_content)",
|
|
)
|
|
parser.add_argument(
|
|
"-k",
|
|
"--keywords",
|
|
nargs="+",
|
|
help="Recover paths matching any keyword",
|
|
)
|
|
parser.add_argument(
|
|
"--file-history-root",
|
|
action="append",
|
|
type=Path,
|
|
default=[],
|
|
metavar="DIR",
|
|
help=(
|
|
"Additional file-history root containing <session-id>/ directories "
|
|
"(repeatable)"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--write-only",
|
|
action="store_true",
|
|
help=(
|
|
"Ignore file-history metadata and explicitly recover lower-fidelity "
|
|
"Write checkpoints"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--show-edits",
|
|
action="store_true",
|
|
help="List the five latest Edit operations",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
if not args.session_file.is_file():
|
|
print(f"Error: Session file not found: {args.session_file}", file=sys.stderr)
|
|
return 1
|
|
|
|
recovery = SessionContentRecovery(
|
|
args.session_file,
|
|
args.output,
|
|
args.file_history_root,
|
|
args.write_only,
|
|
)
|
|
print(f"Analyzing session: {args.session_file}")
|
|
print(f"Output directory: {recovery.output_dir}\n")
|
|
|
|
try:
|
|
write_calls = recovery.extract_write_calls()
|
|
print(f"Write calls: {len(write_calls)}")
|
|
print(f"Paths with file-history metadata: {recovery.stats['snapshot_paths']}")
|
|
if args.write_only:
|
|
print("Recovery mode: explicit Write-only checkpoint mode")
|
|
else:
|
|
print("Recovery mode: exact file-history checkpoint preferred")
|
|
if args.keywords:
|
|
print(f"Path filters: {', '.join(args.keywords)}")
|
|
print()
|
|
|
|
saved = recovery.recover_files(args.keywords)
|
|
if args.show_edits:
|
|
edits = recovery.extract_edit_calls()
|
|
print(f"Edit calls: {recovery.stats['edit_calls']}")
|
|
for edit in edits:
|
|
print(f" - {Path(str(edit['file_path'])).name} (line {edit['line']})")
|
|
print()
|
|
|
|
report = recovery.generate_report(saved)
|
|
print(report)
|
|
report_file = recovery.output_dir / "recovery_report.txt"
|
|
_atomic_write(report_file, report.encode("utf-8"))
|
|
except (RecoveryError, OSError) as error:
|
|
print(f"Error: {error}", file=sys.stderr)
|
|
return 2
|
|
|
|
print(f"Report saved to: {report_file}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|