mirror of
https://github.com/daymade/claude-code-skills.git
synced 2026-09-14 16:15:21 +08:00
44785fa918
* fix(history): prefilter candidates before metadata parsing Bounded keyword searches now discover native file candidates first while preserving archive unions, untimed-record accounting, Unicode recall, and exact no-prefilter output. Co-Authored-By: Codex <noreply@openai.com> * fix(history): preserve mixed JSON Unicode matches Match every raw/Unicode-escaped mixture with a linear-size regex, including uppercase hex and decoded ASCII case semantics. Keep grep fail-safe by falling back to full parsing, update help, and add regressions for the independently reproduced false negative. Co-Authored-By: Codex <noreply@openai.com> * fix(history): retain escaped full-fold candidates When any query character is ASCII, keep files containing Unicode escapes so mixed CJK+ASCII searches cannot miss decoded full-fold equivalents such as Kelvin-sign k. Pure CJK queries keep the exact fast path. Co-Authored-By: Codex <noreply@openai.com> * fix(history): join renamed copies by session identity Peek only the first internal sessionId in each physical JSONL before candidate filtering, then retain every active/archive filename for a matching identity. This restores provenance, untimed-record counts, and exact session totals without full-corpus transcript parsing. Co-Authored-By: Codex <noreply@openai.com> * fix(history): bound identity peeks and fold markers Cap session identity discovery at 32 records or 256 KiB, promote unresolved files only after the native scan, and keep their full parsing candidate-scoped. Also derive markers for casefolds containing ASCII substrings such as dotted-I so date-bounded candidate discovery remains complete. Co-Authored-By: Codex <noreply@openai.com> * perf(history): keep pure CJK on exact candidate path Only enable ASCII fold and broad Unicode-escape markers when the query contains an ASCII character. Pure CJK now uses the exact raw/escaped regex and ignores unrelated Kelvin, dotted-I, and ligature text. Co-Authored-By: Codex <noreply@openai.com> * fix(history): select candidates by physical path Prevent same-named files with different internal sessionIds from entering the full parse set, while keeping renamed copies of one identity joined. Scope the release note to Claude candidate-first discovery and the shared Unicode prefilter actually used by both readers. Co-Authored-By: Codex <noreply@openai.com> * fix(history): make identity gaps conservative Apply --exclude-session only after resolving the internal session identity, so renamed aliases cannot disappear early. Treat unreadable or dangling JSONL prefixes as unknown and fall through to the existing tolerant full-scan behavior instead of raising. Co-Authored-By: Codex <noreply@openai.com> * fix(history): resolve exclusion after full identity Treat a provisional prefix ID that appears excluded as unknown, promote it through the native candidate path, and apply exclusion only after the full scanner resolves the final internal session identity. Co-Authored-By: Codex <noreply@openai.com> * fix(history): expand candidates from final identities Full-scan each matched or uncertain candidate once, cache its summary for merge, and rebuild copy unions and totals from the final internal sessionId. Prefix-to-final identity changes now preserve renamed provenance, untimed counts, exclusions, and single-pass candidate parsing. Co-Authored-By: Codex <noreply@openai.com> * fix(history): derive final identity from EOF Read at most 32 complete records and 64 KiB backwards from EOF, so any discovered sessionId is exactly the full scanner's final identity. Unknown suffixes remain conservative candidates; final-ID totals, exclusions, copy unions, and cached single-pass summaries stay exact. Co-Authored-By: Codex <noreply@openai.com> --------- Co-authored-by: Codex <noreply@openai.com>
2942 lines
124 KiB
Python
Executable File
2942 lines
124 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Analyze Claude Code session files to find relevant sessions and statistics.
|
|
|
|
This script helps locate sessions containing specific keywords, analyze
|
|
session activity, and generate reports about session content.
|
|
|
|
By default, history is searched across every active Claude config home plus
|
|
every long-term archive registered in ~/.claude/history-sources.json. Searching
|
|
only ~/.claude or only the current active tree can silently miss a real session.
|
|
Conversation dates come from internal JSONL records, never file mtime.
|
|
|
|
Three opt-in widenings exist because "not found" is the expensive answer:
|
|
--all-projects sweeps every project when the project is a guess, --codex
|
|
also searches Codex rollout history (~/.codex), and --kimi also searches
|
|
Kimi CLI sessions (~/.kimi-code) — different stores that the Claude registry
|
|
never covers.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Dict, List, Optional
|
|
from collections import Counter, defaultdict
|
|
|
|
|
|
# Multi-home discovery lives in the bundled `_core` package — the single source
|
|
# of truth is daymade-claude-code/_conversation_core/, copied here into
|
|
# scripts/_core/ by sync_core.py so this skill stays self-contained. Make this
|
|
# script's own dir importable regardless of how it is invoked, then import.
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from _core.claude import peek_final_claude_session_id, scan_claude_session # noqa: E402
|
|
from _core.codex import codex_meta_from_rollout, codex_session_id # noqa: E402
|
|
from _core.kimi import ( # noqa: E402
|
|
iter_kimi_session_dirs,
|
|
kimi_wire_files,
|
|
kimi_wire_time_range,
|
|
load_kimi_state,
|
|
resolve_kimi_home,
|
|
scan_kimi_session,
|
|
) # noqa: E402
|
|
from _core.homes import home_label # noqa: E402
|
|
from _core.parse import ( # noqa: E402
|
|
TimestampRange,
|
|
format_timestamp,
|
|
parse_date_boundary,
|
|
parse_timestamp,
|
|
range_overlaps_window,
|
|
timestamp_in_window,
|
|
workspace_matches,
|
|
)
|
|
from _core.sources import ( # noqa: E402
|
|
HistorySource,
|
|
HistorySourceConfigError,
|
|
discover_claude_sources,
|
|
)
|
|
from _core.text import ( # noqa: E402
|
|
SearchSegment,
|
|
extract_text,
|
|
files_possibly_matching,
|
|
is_automated_title,
|
|
is_claude_agent_prompt_record,
|
|
iter_jsonl,
|
|
keywords_are_raw_byte_safe,
|
|
searchable_segments,
|
|
)
|
|
|
|
|
|
CODEX_EXACT_SESSION_ID_RE = re.compile(
|
|
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
|
re.IGNORECASE,
|
|
)
|
|
DEFAULT_CODEX_SCAN_BUDGET_SECONDS = 300.0
|
|
CODEX_PROGRESS_INTERVAL_SECONDS = 15.0
|
|
|
|
|
|
class CodexScanBudgetExceeded(RuntimeError):
|
|
"""Raised instead of silently returning an incomplete broad-search result."""
|
|
|
|
def __init__(
|
|
self, scanned: int, total: Optional[int], elapsed: float, stage: str
|
|
):
|
|
self.scanned = scanned
|
|
self.total = total
|
|
self.elapsed = elapsed
|
|
self.stage = stage
|
|
total_text = "?" if total is None else str(total)
|
|
super().__init__(
|
|
f"Codex scan exceeded its time budget during {stage}: "
|
|
f"{scanned}/{total_text} rollouts inspected in {elapsed:.1f}s"
|
|
)
|
|
|
|
|
|
class CodexScanIncomplete(RuntimeError):
|
|
"""One or more rollout files could not be parsed completely."""
|
|
|
|
def __init__(self, paths: List[Path]):
|
|
self.paths = list(dict.fromkeys(paths))
|
|
super().__init__(
|
|
f"{len(self.paths)} Codex rollout(s) could not be read completely"
|
|
)
|
|
|
|
|
|
def _record_identity(record: Dict[str, Any]) -> str:
|
|
"""Return a stable identity for record-level union across session copies."""
|
|
canonical = json.dumps(
|
|
record,
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
).encode("utf-8")
|
|
return "sha256:" + hashlib.sha256(canonical).hexdigest()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Session tail classification (`triage` command) — see references/
|
|
# session_file_format.md "Detect Session Interruption" and "Tool Use / Tool
|
|
# Result Ordering" for the reasoning this implements.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Structural terminal states. This is deliberately NOT a judgment about
|
|
# whether a human reply is expected — "done" only means the last assistant
|
|
# turn produced a normal text block, which is equally true of a session that
|
|
# fully wrapped up and one that surfaced a finding and is waiting for a
|
|
# response. Reading last_assistant_text is the caller's job.
|
|
TAIL_INTERRUPTED_EXPLICIT = "interrupted_explicit"
|
|
TAIL_NET_ERROR = "net_error"
|
|
TAIL_STUCK_NO_RESULT = "stuck_no_result"
|
|
TAIL_DONE = "done"
|
|
TAIL_EMPTY = "empty"
|
|
|
|
_INTERRUPTED_MARKER = "[Request interrupted by user"
|
|
_NET_ERROR_PREFIX = "API Error"
|
|
|
|
|
|
@dataclass
|
|
class SessionTail:
|
|
"""Structural classification of how a session's final turn ended."""
|
|
|
|
kind: str
|
|
last_user_text: str
|
|
last_assistant_kind: str # "text" | "tool_use" | "thinking_only" | "none"
|
|
last_assistant_text: str
|
|
last_assistant_timestamp: Optional[float]
|
|
|
|
|
|
def classify_session_tail(path: Path) -> SessionTail:
|
|
"""Classify a session's ending state by streaming its records once.
|
|
|
|
Resolves tool_use/tool_result across the *whole* file as a true
|
|
set-difference, not an incremental add/discard in file order: a
|
|
tool_result can be written before the tool_use record it answers (see
|
|
"Tool Use / Tool Result Ordering" in references/session_file_format.md).
|
|
A single-pass ``discard-then-add`` was tried first and is NOT actually
|
|
order-independent — ``discard()`` on an id not yet seen is a silent
|
|
no-op, so a tool_result appearing before its tool_use left the id
|
|
"pending" even though it was genuinely resolved (verified against real
|
|
session data, 2026-08: 11/14 files hitting this ordering had their
|
|
final `kind` flipped). Accumulating two never-mutated sets and diffing
|
|
them once at the end is immune to this, because neither operation can
|
|
ever silently miss the other regardless of which came first in the file.
|
|
|
|
Classification is also computed from the RAW content of the LAST
|
|
assistant record only, not from state that could carry over from an
|
|
earlier turn: a final turn that produces no text and no tool_use (e.g.
|
|
thinking-only) previously left `last_assistant_kind`/`last_assistant_text`
|
|
holding an earlier turn's already-answered reply, misreporting a session
|
|
that crashed before responding to its latest question as `done`.
|
|
|
|
The interruption marker is checked the same way: `tail_is_interrupt` is
|
|
reset by any later user or assistant record, so it only survives to the
|
|
end of the loop when the marker is the LAST relevant record in the file.
|
|
A mid-session Ctrl+C that the conversation continued past is not a tail
|
|
interruption — treating "marker appears anywhere" as equivalent to "the
|
|
session ended on interruption" was tried first and false-positived on
|
|
exactly that shape (verified against real session data, 2026-08).
|
|
"""
|
|
all_tool_use_ids: set = set()
|
|
all_resolved_tool_use_ids: set = set()
|
|
last_user_text = ""
|
|
last_assistant_content: Any = None
|
|
last_assistant_timestamp: Optional[float] = None
|
|
tail_is_interrupt = False
|
|
|
|
for record in iter_jsonl(path):
|
|
record_type = record.get("type")
|
|
message = record.get("message")
|
|
content = message.get("content") if isinstance(message, dict) else None
|
|
|
|
if record_type == "user" and not record.get("isMeta"):
|
|
if isinstance(content, str) and _INTERRUPTED_MARKER in content:
|
|
tail_is_interrupt = True
|
|
continue
|
|
tail_is_interrupt = False
|
|
if isinstance(content, str):
|
|
last_user_text = content
|
|
elif isinstance(content, list):
|
|
is_tool_result_only = bool(content) and all(
|
|
isinstance(block, dict) and block.get("type") == "tool_result"
|
|
for block in content
|
|
)
|
|
for block in content:
|
|
if not isinstance(block, dict):
|
|
continue
|
|
if block.get("type") == "tool_result":
|
|
tool_use_id = block.get("tool_use_id")
|
|
if tool_use_id is not None:
|
|
all_resolved_tool_use_ids.add(tool_use_id)
|
|
if not is_tool_result_only:
|
|
text = extract_text(content)
|
|
if text:
|
|
last_user_text = text
|
|
|
|
elif record_type == "assistant":
|
|
tail_is_interrupt = False
|
|
parsed_ts = parse_timestamp(record.get("timestamp"))
|
|
if parsed_ts is not None:
|
|
last_assistant_timestamp = parsed_ts
|
|
last_assistant_content = content
|
|
if isinstance(content, list):
|
|
for block in content:
|
|
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
tool_use_id = block.get("id")
|
|
if tool_use_id is not None:
|
|
all_tool_use_ids.add(tool_use_id)
|
|
|
|
pending_tool_use_ids = all_tool_use_ids - all_resolved_tool_use_ids
|
|
|
|
last_assistant_kind = "none"
|
|
last_assistant_text = ""
|
|
final_turn_has_pending_tool = False
|
|
if isinstance(last_assistant_content, list):
|
|
text_blocks = [
|
|
block.get("text", "")
|
|
for block in last_assistant_content
|
|
if isinstance(block, dict) and block.get("type") == "text"
|
|
]
|
|
tool_blocks = [
|
|
block
|
|
for block in last_assistant_content
|
|
if isinstance(block, dict) and block.get("type") == "tool_use"
|
|
]
|
|
joined_text = "\n".join(part for part in text_blocks if part)
|
|
final_turn_has_pending_tool = any(
|
|
block.get("id") in pending_tool_use_ids for block in tool_blocks
|
|
)
|
|
if final_turn_has_pending_tool:
|
|
last_assistant_kind = "tool_use"
|
|
pending_names = [
|
|
block.get("name", "?")
|
|
for block in tool_blocks
|
|
if block.get("id") in pending_tool_use_ids
|
|
]
|
|
last_assistant_text = f"[tool_use:{pending_names[-1]}]"
|
|
elif joined_text:
|
|
last_assistant_kind = "text"
|
|
last_assistant_text = joined_text
|
|
elif tool_blocks:
|
|
# Every tool_use in this turn already has a matching result
|
|
# elsewhere in the file, but no further assistant reply followed
|
|
# it — the harness likely stopped between the tool result
|
|
# landing and the model's next turn being captured. Distinct
|
|
# from `final_turn_has_pending_tool`: nothing is unresolved, but
|
|
# nothing was said either, so this is not a normal `done` reply.
|
|
last_assistant_kind = "tool_use"
|
|
last_assistant_text = f"[tool_use:{tool_blocks[-1].get('name', '?')}] (resolved, no further reply)"
|
|
else:
|
|
last_assistant_kind = "thinking_only"
|
|
last_assistant_text = (
|
|
"(final assistant turn has no text or tool_use block — "
|
|
"thinking-only or empty content)"
|
|
)
|
|
elif isinstance(last_assistant_content, str) and last_assistant_content:
|
|
last_assistant_kind = "text"
|
|
last_assistant_text = last_assistant_content
|
|
|
|
if tail_is_interrupt:
|
|
kind = TAIL_INTERRUPTED_EXPLICIT
|
|
elif last_assistant_kind == "none":
|
|
kind = TAIL_EMPTY
|
|
elif last_assistant_kind == "text" and last_assistant_text.startswith(
|
|
_NET_ERROR_PREFIX
|
|
):
|
|
kind = TAIL_NET_ERROR
|
|
elif last_assistant_kind == "text":
|
|
kind = TAIL_DONE
|
|
else:
|
|
# tool_use (pending or just-resolved-with-no-followup) and
|
|
# thinking_only all mean the same thing for triage purposes: the
|
|
# final turn produced no textual reply, so the model was still
|
|
# working when the file stopped.
|
|
kind = TAIL_STUCK_NO_RESULT
|
|
|
|
return SessionTail(
|
|
kind=kind,
|
|
last_user_text=last_user_text,
|
|
last_assistant_kind=last_assistant_kind,
|
|
last_assistant_text=last_assistant_text,
|
|
last_assistant_timestamp=last_assistant_timestamp,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Codex rollout search (--codex)
|
|
#
|
|
# Codex stores conversations outside the Claude history registry: rollout
|
|
# JSONL files under <CODEX_HOME>/sessions/<YYYY>/<MM>/<DD>/ plus
|
|
# <CODEX_HOME>/archived_sessions/. Their record schema is NOT the Claude one
|
|
# (response_item/event_msg/session_meta, not user/assistant/queue-operation),
|
|
# so searchable_segments() does not apply. The extractor below covers the
|
|
# user-visible payload of each response_item variant. event_msg user/agent
|
|
# message records are deliberate strict mirrors of response_item message text
|
|
# (verified 2026-07-16: 26/26 and 104/104 subset on a real rollout), so they
|
|
# are skipped to avoid double-counting.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _flatten_strings(value: Any) -> List[str]:
|
|
"""Flatten nested str/list/dict content into plain strings."""
|
|
if isinstance(value, str):
|
|
return [value]
|
|
if isinstance(value, list):
|
|
return [part for item in value for part in _flatten_strings(item)]
|
|
if isinstance(value, dict):
|
|
return [part for item in value.values() for part in _flatten_strings(item)]
|
|
return []
|
|
|
|
|
|
def codex_searchable_segments(record: Dict[str, Any]) -> List[SearchSegment]:
|
|
"""Extract searchable text fields from one Codex rollout record."""
|
|
segments: List[SearchSegment] = []
|
|
|
|
def add(source: str, value: Any) -> None:
|
|
for text_value in _flatten_strings(value):
|
|
segments.append(SearchSegment(source=source, text=text_value))
|
|
|
|
if record.get("type") == "compacted":
|
|
# Compaction records carry a summary of earlier conversation content.
|
|
payload = record.get("payload")
|
|
if isinstance(payload, dict):
|
|
add("summary", payload.get("message"))
|
|
return list(dict.fromkeys(segments))
|
|
|
|
if record.get("type") != "response_item":
|
|
return segments
|
|
payload = record.get("payload")
|
|
if not isinstance(payload, dict):
|
|
return segments
|
|
payload_type = payload.get("type")
|
|
if payload_type == "message":
|
|
for block in payload.get("content") or []:
|
|
if isinstance(block, dict) and block.get("type") in {
|
|
"input_text",
|
|
"output_text",
|
|
}:
|
|
add("message", block.get("text"))
|
|
elif payload_type == "reasoning":
|
|
for block in payload.get("summary") or []:
|
|
if isinstance(block, dict) and block.get("type") == "summary_text":
|
|
add("thinking", block.get("text"))
|
|
elif payload_type in {"function_call", "custom_tool_call"}:
|
|
name = payload.get("name")
|
|
source = (
|
|
f"tool_input:{name}"
|
|
if isinstance(name, str) and name
|
|
else "tool_input"
|
|
)
|
|
add(source, payload.get("arguments"))
|
|
add(source, payload.get("input"))
|
|
elif payload_type in {"function_call_output", "custom_tool_call_output"}:
|
|
add("tool_result", payload.get("output"))
|
|
return list(dict.fromkeys(segments))
|
|
|
|
|
|
def discover_codex_rollouts(
|
|
codex_home: Path,
|
|
*,
|
|
on_progress: Optional[Callable[[int], None]] = None,
|
|
) -> List[Path]:
|
|
"""Enumerate Codex rollout files under a Codex home (sessions + archived)."""
|
|
rollouts: List[Path] = []
|
|
sessions_dir = codex_home / "sessions"
|
|
if sessions_dir.is_dir():
|
|
for path in sessions_dir.rglob("*.jsonl"):
|
|
rollouts.append(path)
|
|
if on_progress is not None and len(rollouts) % 256 == 0:
|
|
on_progress(len(rollouts))
|
|
archived_dir = codex_home / "archived_sessions"
|
|
if archived_dir.is_dir():
|
|
for path in archived_dir.glob("*.jsonl"):
|
|
rollouts.append(path)
|
|
if on_progress is not None and len(rollouts) % 256 == 0:
|
|
on_progress(len(rollouts))
|
|
if on_progress is not None:
|
|
on_progress(len(rollouts))
|
|
return sorted(rollouts)
|
|
|
|
|
|
def locate_codex_rollouts(codex_home: Path, session_id: str) -> List[Dict[str, Any]]:
|
|
"""Locate one exact Codex session without parsing the rollout corpus.
|
|
|
|
Current Codex rollout filenames carry the session UUID. Globbing that
|
|
exact UUID touches directory entries only, then each small candidate is
|
|
validated against the authoritative ``session_meta.id`` before it is
|
|
returned. A UUID is metadata, not conversation text; scanning multi-GB
|
|
rollout bodies for metadata is the failure mode this function prevents.
|
|
"""
|
|
normalized = session_id.strip().lower()
|
|
if not CODEX_EXACT_SESSION_ID_RE.fullmatch(normalized):
|
|
raise ValueError(f"invalid Codex session id: {session_id!r}")
|
|
|
|
candidates: set[Path] = set()
|
|
sessions_dir = codex_home / "sessions"
|
|
if sessions_dir.is_dir():
|
|
candidates.update(
|
|
sessions_dir.glob(f"*/*/*/rollout-*-{normalized}.jsonl")
|
|
)
|
|
archived_dir = codex_home / "archived_sessions"
|
|
if archived_dir.is_dir():
|
|
candidates.update(archived_dir.glob(f"rollout-*-{normalized}.jsonl"))
|
|
|
|
located: List[Dict[str, Any]] = []
|
|
for path in sorted(candidates):
|
|
meta = codex_meta_from_rollout(path, strict=True)
|
|
if not isinstance(meta, dict):
|
|
continue
|
|
authoritative_id = meta.get("id")
|
|
if (
|
|
not isinstance(authoritative_id, str)
|
|
or not authoritative_id.strip()
|
|
or authoritative_id.lower() != normalized
|
|
):
|
|
continue
|
|
located.append(
|
|
{
|
|
"session_id": authoritative_id,
|
|
"path": path,
|
|
"cwd": meta.get("cwd"),
|
|
"timestamp": parse_timestamp(meta.get("timestamp")),
|
|
"storage": "archived" if archived_dir in path.parents else "active",
|
|
}
|
|
)
|
|
located.sort(key=lambda item: (item["storage"] == "archived", str(item["path"])))
|
|
return located
|
|
|
|
|
|
def _print_codex_locations(codex_home: Path, session_id: str) -> int:
|
|
try:
|
|
locations = locate_codex_rollouts(codex_home, session_id)
|
|
except (ValueError, OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
print(f"Codex session lookup was incomplete: {error}", file=sys.stderr)
|
|
return 2
|
|
if not locations:
|
|
print(
|
|
f"No Codex rollout with session_meta.id={session_id} under {codex_home}.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
print(
|
|
f"Codex session {locations[0]['session_id']} "
|
|
f"({len(locations)} physical copy/copies):"
|
|
)
|
|
for item in locations:
|
|
print(f" - Storage: {item['storage']}")
|
|
if isinstance(item.get("cwd"), str) and item["cwd"]:
|
|
print(f" cwd: {item['cwd']}")
|
|
if item.get("timestamp") is not None:
|
|
print(f" Created: {format_timestamp(item['timestamp'])}")
|
|
print(f" Path: {item['path']}")
|
|
return 0
|
|
|
|
|
|
def file_content_digest(path: Path) -> Optional[str]:
|
|
"""SHA-256 over a file's bytes, or ``None`` when it cannot be read.
|
|
|
|
``None`` means "no information" and must make the caller fall through to
|
|
the normal full parse — never to treating two files as identical.
|
|
"""
|
|
digest = hashlib.sha256()
|
|
try:
|
|
with open(path, "rb") as handle:
|
|
for block in iter(lambda: handle.read(1 << 20), b""):
|
|
digest.update(block)
|
|
except (OSError, ValueError):
|
|
return None
|
|
return digest.hexdigest()
|
|
|
|
|
|
def repeated_copy_sizes(copies: List[Dict[str, Any]]) -> set:
|
|
"""Byte sizes that occur more than once among a session's copies.
|
|
|
|
Only these are worth hashing: a size that appears once cannot have a
|
|
byte-identical twin, so single-copy sessions pay nothing for the
|
|
duplicate-copy optimisation.
|
|
"""
|
|
sizes = Counter()
|
|
for item in copies:
|
|
try:
|
|
sizes[item["path"].stat().st_size] += 1
|
|
except OSError:
|
|
continue
|
|
return {size for size, count in sizes.items() if count > 1}
|
|
|
|
|
|
def search_codex_rollouts(
|
|
rollouts: List[Path],
|
|
keywords: List[str],
|
|
case_sensitive: bool = False,
|
|
from_timestamp: Optional[float] = None,
|
|
to_timestamp: Optional[float] = None,
|
|
project_path: Optional[str] = None,
|
|
exclude_ids: Optional[set] = None,
|
|
use_prefilter: bool = True,
|
|
max_scan_seconds: Optional[float] = DEFAULT_CODEX_SCAN_BUDGET_SECONDS,
|
|
progress_interval_seconds: float = CODEX_PROGRESS_INTERVAL_SECONDS,
|
|
clock: Callable[[], float] = time.monotonic,
|
|
progress: Optional[Callable[[str], None]] = None,
|
|
started_at: Optional[float] = None,
|
|
) -> List[Dict[str, Any]]:
|
|
"""Search Codex rollouts for keywords, one match dict per session.
|
|
|
|
``project_path`` filters by the rollout's session_meta cwd (recursive
|
|
workspace match). Rollouts are de-duplicated by session id — a copy left
|
|
in both sessions/ and archived_sessions/ is reported once.
|
|
|
|
``use_prefilter`` skips per-file and per-line json.loads() work a raw byte
|
|
scan already proves cannot match (see ``files_possibly_matching`` and
|
|
``iter_jsonl``'s ``line_keywords``). The FILE-level skip is safe
|
|
unconditionally — every counter this function reports (``excluded_untimed``,
|
|
``session_range``, ``match_range``) is scoped to a single rollout file and
|
|
only surfaces when that same file has ``total_mentions > 0``, so a file
|
|
ruled out entirely never had anything to report.
|
|
|
|
Codex does NOT do line-level skipping at all — not even outside a date
|
|
window. Two separate accountings need to see every record: ``session_range``
|
|
(which would otherwise collapse onto ``match_range``) and, under a date
|
|
window, ``excluded_untimed``. See the comment at the ``line_keywords``
|
|
assignment below; the disabling is unconditional and a test enforces it.
|
|
"""
|
|
search_keywords = [
|
|
(keyword, keyword if case_sensitive else keyword.casefold())
|
|
for keyword in keywords
|
|
]
|
|
exclude = exclude_ids or set()
|
|
matches: List[Dict[str, Any]] = []
|
|
seen_ids: set[str] = set()
|
|
incomplete_paths: List[Path] = []
|
|
|
|
started = clock() if started_at is None else started_at
|
|
next_progress = started + progress_interval_seconds
|
|
progress_sink = progress or (
|
|
lambda message: print(message, file=sys.stderr, flush=True)
|
|
)
|
|
scanned_files = 0
|
|
|
|
def check_liveness(stage: str) -> None:
|
|
nonlocal next_progress
|
|
now = clock()
|
|
elapsed = now - started
|
|
if (
|
|
max_scan_seconds is not None
|
|
and max_scan_seconds > 0
|
|
and elapsed > max_scan_seconds
|
|
):
|
|
raise CodexScanBudgetExceeded(scanned_files, len(rollouts), elapsed, stage)
|
|
if progress_interval_seconds > 0 and now >= next_progress:
|
|
progress_sink(
|
|
"Codex scan progress: "
|
|
f"stage={stage}, {scanned_files}/{len(rollouts)} rollouts, "
|
|
f"matches={len(matches)}, elapsed={elapsed:.1f}s"
|
|
)
|
|
while next_progress <= now:
|
|
next_progress += progress_interval_seconds
|
|
|
|
matched_files: Optional[set[Path]] = None
|
|
if use_prefilter:
|
|
check_liveness("prefilter-start")
|
|
deadline = None
|
|
if max_scan_seconds is not None and max_scan_seconds > 0:
|
|
deadline = started + max_scan_seconds
|
|
matched_files = files_possibly_matching(
|
|
rollouts,
|
|
keywords,
|
|
case_sensitive=case_sensitive,
|
|
deadline=deadline,
|
|
clock=clock,
|
|
progress_interval_seconds=progress_interval_seconds,
|
|
on_progress=lambda: check_liveness("prefilter"),
|
|
)
|
|
check_liveness("prefilter-complete")
|
|
# Codex gets FILE-level pre-filtering only, never line-level.
|
|
#
|
|
# ``session_range.observe()`` has to see every record — including the
|
|
# ``session_meta`` first line — to report the conversation's time span.
|
|
# Skipping lines that lack the keyword bytes collapses ``Internal range``
|
|
# onto ``Match range``: measured on a 4-line rollout, ``01-01 .. 01-20``
|
|
# became ``01-10 .. 01-10``. That is not "one less field", it is a *wrong
|
|
# value* in a field callers quote, and it fires on the plain no-date-window
|
|
# search — the most common invocation there is.
|
|
#
|
|
# A file the file-level filter rules out entirely reports no range at all,
|
|
# so that layer stays safe and stays on.
|
|
line_keywords = None
|
|
|
|
for path_index, path in enumerate(rollouts, start=1):
|
|
scanned_files = path_index - 1
|
|
check_liveness("rollout-open")
|
|
try:
|
|
meta = codex_meta_from_rollout(path, strict=True)
|
|
except (OSError, UnicodeError, json.JSONDecodeError) as e:
|
|
print(f"Warning: Error reading {path}: {e}", file=sys.stderr)
|
|
incomplete_paths.append(path)
|
|
continue
|
|
sid = codex_session_id(meta, path) if meta else None
|
|
if not sid:
|
|
sid = path.stem
|
|
# Dedup and exclusion happen BEFORE the pre-filter check below, on
|
|
# purpose: they must claim/reject this path exactly as before, in the
|
|
# same traversal order, regardless of whether it turns out to have a
|
|
# keyword match. Moving the pre-filter check earlier would change
|
|
# which physical copy "wins" the session id when two copies of one
|
|
# session exist (sessions/ vs archived_sessions/) — a real behavior
|
|
# change this performance fix has no business making silently.
|
|
if sid in seen_ids:
|
|
continue
|
|
seen_ids.add(sid)
|
|
if sid in exclude or path.stem in exclude:
|
|
continue
|
|
if matched_files is not None and path not in matched_files:
|
|
continue
|
|
cwd = meta.get("cwd") if isinstance(meta, dict) else None
|
|
if project_path is not None:
|
|
if not isinstance(cwd, str) or not workspace_matches(
|
|
cwd, project_path, recursive=True
|
|
):
|
|
continue
|
|
|
|
keyword_counts: Dict[str, int] = defaultdict(int)
|
|
total_mentions = 0
|
|
match_sources: set[str] = set()
|
|
session_range = TimestampRange()
|
|
match_range = TimestampRange()
|
|
excluded_untimed = 0
|
|
try:
|
|
for record_index, record in enumerate(
|
|
iter_jsonl(path, line_keywords=line_keywords, strict=True)
|
|
):
|
|
if record_index % 512 == 0:
|
|
check_liveness("rollout-parse")
|
|
record_timestamp = parse_timestamp(record.get("timestamp"))
|
|
if record_timestamp is not None:
|
|
session_range.observe(record_timestamp)
|
|
if record.get("type") == "session_meta" and isinstance(
|
|
record.get("payload"), dict
|
|
):
|
|
meta_timestamp = parse_timestamp(
|
|
record["payload"].get("timestamp")
|
|
)
|
|
if meta_timestamp is not None:
|
|
session_range.observe(meta_timestamp)
|
|
if from_timestamp is not None or to_timestamp is not None:
|
|
if record_timestamp is None:
|
|
excluded_untimed += 1
|
|
continue
|
|
if not timestamp_in_window(
|
|
record_timestamp, from_timestamp, to_timestamp
|
|
):
|
|
continue
|
|
record_counts: Dict[str, int] = defaultdict(int)
|
|
record_sources: set[str] = set()
|
|
for segment in codex_searchable_segments(record):
|
|
search_text = (
|
|
segment.text if case_sensitive else segment.text.casefold()
|
|
)
|
|
for keyword, search_keyword in search_keywords:
|
|
count = search_text.count(search_keyword)
|
|
if count > 0:
|
|
record_counts[keyword] += count
|
|
record_sources.add(segment.source)
|
|
record_mentions = sum(record_counts.values())
|
|
if not record_mentions:
|
|
continue
|
|
for keyword, count in record_counts.items():
|
|
keyword_counts[keyword] += count
|
|
total_mentions += record_mentions
|
|
match_sources.update(record_sources)
|
|
if record_timestamp is not None:
|
|
match_range.observe(record_timestamp)
|
|
except (OSError, UnicodeError, json.JSONDecodeError) as e:
|
|
print(f"Warning: Error processing {path}: {e}", file=sys.stderr)
|
|
incomplete_paths.append(path)
|
|
continue
|
|
|
|
scanned_files = path_index
|
|
check_liveness("rollout-complete")
|
|
|
|
if total_mentions > 0:
|
|
matches.append(
|
|
{
|
|
"session_id": sid,
|
|
"path": path,
|
|
"cwd": cwd,
|
|
"total_mentions": total_mentions,
|
|
"keyword_counts": dict(keyword_counts),
|
|
"match_sources": sorted(match_sources),
|
|
"created_at": session_range.earliest,
|
|
"updated_at": session_range.latest,
|
|
"match_created_at": match_range.earliest,
|
|
"match_updated_at": match_range.latest,
|
|
"excluded_untimed_records": excluded_untimed,
|
|
}
|
|
)
|
|
|
|
if incomplete_paths:
|
|
raise CodexScanIncomplete(incomplete_paths)
|
|
|
|
matches.sort(
|
|
key=lambda match: (
|
|
match["total_mentions"],
|
|
match["match_updated_at"]
|
|
if match["match_updated_at"] is not None
|
|
else float("-inf"),
|
|
),
|
|
reverse=True,
|
|
)
|
|
return matches
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Kimi CLI session search (--kimi)
|
|
#
|
|
# Kimi CLI (kimi-code) stores conversations under <KIMI_HOME>/sessions/
|
|
# wd_<workspace>_<hash>/session_<uuid>/agents/<agent>/wire.jsonl, with a
|
|
# per-session state.json (id/cwd/title/createdAt/updatedAt in ms). The wire
|
|
# record schema is NOT the Claude or Codex one (turn.prompt,
|
|
# context.append_message, context.append_loop_event, all timestamped by a
|
|
# `time` epoch-ms field), so neither searchable_segments() nor
|
|
# codex_searchable_segments() applies. Layout and record shapes were verified
|
|
# against Kimi CLI 0.38.0 (wire protocol 1.5) on a real store; see
|
|
# _core/kimi.py's module docstring for the full format contract.
|
|
#
|
|
# Searchable coverage is the conversation itself: user prompts (turn.prompt /
|
|
# turn.steer), appended messages (user/assistant text and attachments), and
|
|
# loop events (assistant content parts, tool calls, tool results). Static
|
|
# boilerplate — config.update / profile.bind system prompts, tool snapshots,
|
|
# usage/token metrics — is deliberately NOT indexed: a keyword that only
|
|
# appears in a shared system prompt would match every session, and "not found"
|
|
# is the answer this tool is trusted to give about conversation content.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _kimi_flatten_payload(value: Any) -> List[str]:
|
|
"""Flatten nested Kimi payload content, dropping structural identifier keys.
|
|
|
|
Same principle as text.py's searchable_segments excluding id/signature
|
|
keys: UUID-class fields (turnId, toolCallId, …) are unique per record, so
|
|
indexing them only manufactures false-positive hits and dilutes the match
|
|
source attribution (independent review, 2026-08).
|
|
"""
|
|
if isinstance(value, str):
|
|
return [value]
|
|
if isinstance(value, list):
|
|
return [part for item in value for part in _kimi_flatten_payload(item)]
|
|
if isinstance(value, dict):
|
|
return [
|
|
part
|
|
for key, item in value.items()
|
|
if key not in _KIMI_STRUCTURAL_KEYS
|
|
for part in _kimi_flatten_payload(item)
|
|
]
|
|
return []
|
|
|
|
|
|
_KIMI_STRUCTURAL_KEYS = frozenset(
|
|
{
|
|
"type",
|
|
"id",
|
|
"uuid",
|
|
"stepUuid",
|
|
"turnId",
|
|
"toolCallId",
|
|
"parentUuid",
|
|
"callId",
|
|
}
|
|
)
|
|
|
|
|
|
def kimi_searchable_segments(record: Dict[str, Any]) -> List[SearchSegment]:
|
|
"""Extract searchable text fields from one Kimi wire.jsonl record."""
|
|
segments: List[SearchSegment] = []
|
|
|
|
def add(source: str, value: Any) -> None:
|
|
for text_value in _kimi_flatten_payload(value):
|
|
segments.append(SearchSegment(source=source, text=text_value))
|
|
|
|
record_type = record.get("type")
|
|
if record_type in {"turn.prompt", "turn.steer"}:
|
|
add("prompt", record.get("input"))
|
|
elif record_type == "context.append_message":
|
|
message = record.get("message")
|
|
if isinstance(message, dict):
|
|
add("message", message.get("content"))
|
|
add("tool_input", message.get("toolCalls"))
|
|
elif record_type == "context.append_loop_event":
|
|
event = record.get("event")
|
|
if isinstance(event, dict):
|
|
event_type = event.get("type")
|
|
if event_type == "content.part":
|
|
add("message", event)
|
|
elif event_type == "tool.call":
|
|
add("tool_input", event)
|
|
elif event_type == "tool.result":
|
|
add("tool_result", event)
|
|
# step.begin / step.end carry no conversation text.
|
|
elif record_type == "plugin.session_start":
|
|
add("plugin", record.get("content"))
|
|
return list(dict.fromkeys(segments))
|
|
|
|
|
|
def discover_kimi_wires(kimi_home: Path) -> List[tuple]:
|
|
"""Enumerate (session_dir, agent_name, wire_path) triples under a Kimi home."""
|
|
wires: List[tuple] = []
|
|
for session_dir in iter_kimi_session_dirs(kimi_home):
|
|
main_wire, subagent_wires = kimi_wire_files(session_dir)
|
|
for path in ([main_wire] if main_wire else []) + subagent_wires:
|
|
if path is not None:
|
|
wires.append((session_dir, path.parent.name, path))
|
|
return wires
|
|
|
|
|
|
def search_kimi_wires(
|
|
wires: List[tuple],
|
|
keywords: List[str],
|
|
case_sensitive: bool = False,
|
|
from_timestamp: Optional[float] = None,
|
|
to_timestamp: Optional[float] = None,
|
|
project_path: Optional[str] = None,
|
|
exclude_ids: Optional[set] = None,
|
|
use_prefilter: bool = True,
|
|
) -> List[Dict[str, Any]]:
|
|
"""Search Kimi wire files for keywords, one match dict per SESSION.
|
|
|
|
A session's subagent wires (agents/agent-N/) are runs of the same
|
|
conversation, so matches aggregate at session level with the agent name
|
|
prefixed into each match source (e.g. "main:message", "agent-0:tool_input").
|
|
|
|
Like the Codex search, Kimi gets FILE-level pre-filtering only, never
|
|
line-level: the session's internal range must observe every record's
|
|
`time`, and collapsing it onto the match range would report a wrong value
|
|
in a field callers quote. One Kimi-specific subtlety: the pre-filter rules
|
|
out whole wires, but a session's range spans ALL its wires — so when any
|
|
wire of a session matched, the remaining wires are re-read for their time
|
|
ranges only (no keyword work), keeping the displayed range exact.
|
|
"""
|
|
search_keywords = [
|
|
(keyword, keyword if case_sensitive else keyword.casefold())
|
|
for keyword in keywords
|
|
]
|
|
exclude = exclude_ids or set()
|
|
|
|
matched_files: Optional[set] = None
|
|
if use_prefilter:
|
|
matched_files = files_possibly_matching(
|
|
[wire_path for _, _, wire_path in wires],
|
|
keywords,
|
|
case_sensitive=case_sensitive,
|
|
)
|
|
line_keywords = None # see docstring — never line-level for Kimi either
|
|
|
|
# Group wires by session, preserving discovery order.
|
|
sessions: Dict[Path, List[tuple]] = {}
|
|
for session_dir, agent_name, wire_path in wires:
|
|
sessions.setdefault(session_dir, []).append((agent_name, wire_path))
|
|
|
|
matches: List[Dict[str, Any]] = []
|
|
for session_dir, agent_wires in sessions.items():
|
|
state = load_kimi_state(session_dir) or {}
|
|
session_id = state.get("id") if isinstance(state.get("id"), str) else None
|
|
if not session_id:
|
|
session_id = session_dir.name
|
|
if session_id in exclude or session_dir.name in exclude:
|
|
continue
|
|
if isinstance(state.get("cwd"), str) and state["cwd"].strip():
|
|
cwd: Optional[str] = state["cwd"].strip()
|
|
else:
|
|
cwd = None
|
|
if project_path is not None:
|
|
if not cwd or not workspace_matches(cwd, project_path, recursive=True):
|
|
continue
|
|
|
|
keyword_counts: Dict[str, int] = defaultdict(int)
|
|
total_mentions = 0
|
|
match_sources: set = set()
|
|
session_range = TimestampRange()
|
|
match_range = TimestampRange()
|
|
excluded_untimed = 0
|
|
skipped_wires: List[Path] = []
|
|
|
|
for agent_name, wire_path in agent_wires:
|
|
if matched_files is not None and wire_path not in matched_files:
|
|
skipped_wires.append(wire_path)
|
|
continue
|
|
try:
|
|
for record in iter_jsonl(wire_path, line_keywords=line_keywords):
|
|
record_timestamp = parse_timestamp(record.get("time"))
|
|
if record_timestamp is None and record.get("type") == "metadata":
|
|
record_timestamp = parse_timestamp(record.get("created_at"))
|
|
if record_timestamp is not None:
|
|
session_range.observe(record_timestamp)
|
|
if from_timestamp is not None or to_timestamp is not None:
|
|
if record_timestamp is None:
|
|
excluded_untimed += 1
|
|
continue
|
|
if not timestamp_in_window(
|
|
record_timestamp, from_timestamp, to_timestamp
|
|
):
|
|
continue
|
|
record_counts: Dict[str, int] = defaultdict(int)
|
|
record_sources: set = set()
|
|
for segment in kimi_searchable_segments(record):
|
|
search_text = (
|
|
segment.text
|
|
if case_sensitive
|
|
else segment.text.casefold()
|
|
)
|
|
for keyword, search_keyword in search_keywords:
|
|
count = search_text.count(search_keyword)
|
|
if count > 0:
|
|
record_counts[keyword] += count
|
|
record_sources.add(f"{agent_name}:{segment.source}")
|
|
record_mentions = sum(record_counts.values())
|
|
if not record_mentions:
|
|
continue
|
|
for keyword, count in record_counts.items():
|
|
keyword_counts[keyword] += count
|
|
total_mentions += record_mentions
|
|
match_sources.update(record_sources)
|
|
if record_timestamp is not None:
|
|
match_range.observe(record_timestamp)
|
|
except (OSError, UnicodeError) as e:
|
|
print(f"Warning: Error processing {wire_path}: {e}", file=sys.stderr)
|
|
continue
|
|
|
|
if total_mentions > 0:
|
|
# A prefiltered-out wire can still hold earlier/later records of
|
|
# this matched session; fold its time range in so the displayed
|
|
# internal range stays exact.
|
|
for wire_path in skipped_wires:
|
|
try:
|
|
extra = kimi_wire_time_range(wire_path)
|
|
except OSError:
|
|
continue
|
|
for value in (extra.earliest, extra.latest):
|
|
if value is not None:
|
|
session_range.observe(value)
|
|
matches.append(
|
|
{
|
|
"session_id": session_id,
|
|
"path": session_dir,
|
|
"title": scan_kimi_session(session_dir).title,
|
|
"cwd": cwd or "",
|
|
"total_mentions": total_mentions,
|
|
"keyword_counts": dict(keyword_counts),
|
|
"match_sources": sorted(match_sources),
|
|
"created_at": session_range.earliest,
|
|
"updated_at": session_range.latest,
|
|
"match_created_at": match_range.earliest,
|
|
"match_updated_at": match_range.latest,
|
|
"excluded_untimed_records": excluded_untimed,
|
|
}
|
|
)
|
|
|
|
matches.sort(
|
|
key=lambda match: (
|
|
match["total_mentions"],
|
|
match["match_updated_at"]
|
|
if match["match_updated_at"] is not None
|
|
else float("-inf"),
|
|
),
|
|
reverse=True,
|
|
)
|
|
return matches
|
|
|
|
|
|
class SessionAnalyzer:
|
|
"""Analyze Claude Code session history files across all config homes."""
|
|
|
|
def __init__(
|
|
self,
|
|
homes: Optional[List[Path]] = None,
|
|
sources: Optional[List[HistorySource]] = None,
|
|
warnings: Optional[List[str]] = None,
|
|
):
|
|
"""
|
|
Initialize analyzer.
|
|
|
|
Args:
|
|
homes: Exact list of Claude config home directories to search (each
|
|
must
|
|
contain a ``projects/`` subdir). Pass ``None`` (the default) to
|
|
auto-discover active homes and load registered archives. Pass an
|
|
explicit list to restrict the search — an EMPTY list means
|
|
"search nothing", it must NOT silently fall back to full
|
|
discovery (that would turn a scope-narrowing flag into the
|
|
widest possible scope).
|
|
"""
|
|
if homes is not None and sources is not None:
|
|
raise ValueError("Pass homes or sources, not both")
|
|
if sources is not None:
|
|
self.sources = list(sources)
|
|
elif homes is not None:
|
|
self.sources = [
|
|
HistorySource(
|
|
provider="claude",
|
|
kind="active",
|
|
label=home_label(home),
|
|
home=Path(home),
|
|
)
|
|
for home in homes
|
|
]
|
|
else:
|
|
self.sources, discovered_warnings = discover_claude_sources()
|
|
warnings = (warnings or []) + discovered_warnings
|
|
self.homes = [source.home for source in self.sources]
|
|
self.warnings = list(warnings or [])
|
|
|
|
def find_project_sessions(self, project_path: str) -> List[Dict[str, Any]]:
|
|
"""
|
|
Find all session files for a project ACROSS every discovered home.
|
|
|
|
Sessions are de-duplicated by session id (the ``.jsonl`` filename), so a
|
|
conversation shared across profiles is reported once. Every physical
|
|
copy remains attached to that session reference: search must union the
|
|
records from active and archive copies because one copy can retain
|
|
content that another copy no longer has. Agent side-files
|
|
(``agent-*.jsonl``) are excluded.
|
|
|
|
Args:
|
|
project_path: The project's working directory. An absolute path, a
|
|
``~`` path, or a relative path are all expanded and resolved to
|
|
an absolute path before encoding. A bare directory name
|
|
(basename) is also accepted and matched via reverse lookup.
|
|
|
|
Returns:
|
|
Session-reference dictionaries sorted by the unioned maximum
|
|
internal timestamp (newest first). Each includes a representative
|
|
``path``, every physical ``copy``, the unioned
|
|
``created_at``/``updated_at`` range, and all ``sources``/``homes``
|
|
where that session ID was observed. Empty if the project has no
|
|
history in the configured source set.
|
|
"""
|
|
return self._merge_sessions_from_dirs(
|
|
self._resolve_project_dirs(project_path)
|
|
)
|
|
|
|
def _merge_sessions_from_dirs(
|
|
self,
|
|
pairs: List[tuple],
|
|
candidate_paths: Optional[set[str]] = None,
|
|
candidate_summaries: Optional[Dict[str, Any]] = None,
|
|
) -> List[Dict[str, Any]]:
|
|
"""Collect + de-duplicate sessions from ``(source, project_dir)`` pairs.
|
|
|
|
Per-model profile homes (``~/.claude-profiles/<name>``) commonly
|
|
symlink their ``projects/`` straight to the main home's — same real
|
|
directory, different nominal ``source.home``. ``pairs`` is built by
|
|
the caller per source label, so the same physical file can arrive
|
|
here once per profile that happens to alias it. Group by the
|
|
*resolved* real path first so each physical file is opened and
|
|
parsed exactly once no matter how many source labels point at it.
|
|
|
|
Every group member keeps its own *nominal* (unresolved) directory
|
|
so per-copy paths stay distinct strings — e.g. a session aliased
|
|
under both ``~/.claude/projects`` and ``~/.claude-profiles/css/
|
|
projects`` still reports two different copy paths, matching what
|
|
the un-grouped code produced. Collapsing them to one shared string
|
|
was tried first and broke ``search``'s "Other matching copies:"
|
|
listing (independent review, 2026-08-04): it distinguishes the
|
|
primary copy from the others by path-string inequality, so giving
|
|
every aliased copy the identical string made all-but-one silently
|
|
vanish from that field even though the ``sources``/``homes``
|
|
label lists stayed correct.
|
|
"""
|
|
groups = self._group_project_dirs(pairs)
|
|
|
|
by_id: Dict[str, Dict[str, Any]] = {}
|
|
for scan_dir, group_members in groups:
|
|
group_sources = [source for source, _nominal_dir in group_members]
|
|
# any(...) — not group_sources[0].kind — because "at least one
|
|
# active alias in the group" must win regardless of which
|
|
# member happened to be discovered first; index-0 only agrees
|
|
# with that whenever active sources are enumerated before
|
|
# archives (true for both shipped call sites today, but not a
|
|
# contract this function should silently depend on).
|
|
group_has_active = any(source.kind == "active" for source in group_sources)
|
|
group_kind = "active" if group_has_active else group_sources[0].kind
|
|
for file in scan_dir.glob("*.jsonl"):
|
|
if file.name.startswith("agent-"):
|
|
continue
|
|
try:
|
|
physical_file = str(file.resolve())
|
|
except (OSError, RuntimeError):
|
|
physical_file = str(file)
|
|
if candidate_paths is not None:
|
|
if physical_file not in candidate_paths:
|
|
continue
|
|
summary = (
|
|
candidate_summaries.get(physical_file)
|
|
if candidate_summaries is not None
|
|
else None
|
|
)
|
|
if summary is None:
|
|
summary = scan_claude_session(file)
|
|
sid = summary.session_id
|
|
copies = [
|
|
{
|
|
"path": nominal_dir / file.name,
|
|
"source": source,
|
|
"created_at": summary.created_at,
|
|
"updated_at": summary.updated_at,
|
|
}
|
|
for source, nominal_dir in group_members
|
|
]
|
|
candidate = {
|
|
"path": file,
|
|
"copies": copies,
|
|
"sources": list(group_sources),
|
|
"homes": list(
|
|
dict.fromkeys(source.home for source in group_sources)
|
|
),
|
|
"created_at": summary.created_at,
|
|
"updated_at": summary.updated_at,
|
|
"timestamp_source": (
|
|
"session-record-minmax"
|
|
if summary.timestamp_count
|
|
else "unknown"
|
|
),
|
|
"selected_kind": group_kind,
|
|
"selected_updated_at": summary.updated_at,
|
|
"session_id": sid,
|
|
}
|
|
entry = by_id.get(sid)
|
|
if entry is None:
|
|
by_id[sid] = candidate
|
|
continue
|
|
entry["copies"].extend(copies)
|
|
sources = list(entry["sources"])
|
|
existing_labels = {item.display_label for item in sources}
|
|
for source in group_sources:
|
|
if source.display_label not in existing_labels:
|
|
sources.append(source)
|
|
existing_labels.add(source.display_label)
|
|
homes = list(entry["homes"])
|
|
for source in group_sources:
|
|
if source.home not in homes:
|
|
homes.append(source.home)
|
|
existing_selected_time = (
|
|
entry["selected_updated_at"]
|
|
if entry["selected_updated_at"] is not None
|
|
else float("-inf")
|
|
)
|
|
candidate_time = (
|
|
summary.updated_at
|
|
if summary.updated_at is not None
|
|
else float("-inf")
|
|
)
|
|
candidate_wins = candidate_time > existing_selected_time or (
|
|
candidate_time == existing_selected_time
|
|
and group_kind == "active"
|
|
and entry["selected_kind"] != "active"
|
|
)
|
|
if candidate_wins:
|
|
entry["path"] = file
|
|
entry["selected_kind"] = group_kind
|
|
entry["selected_updated_at"] = summary.updated_at
|
|
entry["sources"] = sources
|
|
entry["homes"] = homes
|
|
created_values = [
|
|
value
|
|
for value in (entry["created_at"], summary.created_at)
|
|
if value is not None
|
|
]
|
|
updated_values = [
|
|
value
|
|
for value in (entry["updated_at"], summary.updated_at)
|
|
if value is not None
|
|
]
|
|
entry["created_at"] = min(created_values) if created_values else None
|
|
entry["updated_at"] = max(updated_values) if updated_values else None
|
|
if summary.timestamp_count:
|
|
entry["timestamp_source"] = "session-record-minmax"
|
|
|
|
return sorted(
|
|
by_id.values(),
|
|
key=lambda entry: (
|
|
entry["updated_at"]
|
|
if entry["updated_at"] is not None
|
|
else float("-inf")
|
|
),
|
|
reverse=True,
|
|
)
|
|
|
|
@staticmethod
|
|
def _group_project_dirs(pairs: List[tuple]) -> List[tuple]:
|
|
"""Group nominal source dirs by physical directory identity."""
|
|
groups: Dict[str, tuple] = {}
|
|
for source, project_dir in pairs:
|
|
try:
|
|
key = str(project_dir.resolve())
|
|
except (OSError, RuntimeError):
|
|
key = str(project_dir)
|
|
group = groups.get(key)
|
|
if group is None:
|
|
groups[key] = (project_dir, [(source, project_dir)])
|
|
else:
|
|
group[1].append((source, project_dir))
|
|
return list(groups.values())
|
|
|
|
def project_dir_pairs(self) -> Dict[str, List[tuple]]:
|
|
"""Enumerate every project dir across all sources.
|
|
|
|
Returns ``{encoded_project_name: [(source, dir), ...]}`` — one entry
|
|
per distinct project, with a pair per source that holds history for
|
|
it. The encoded name (``-Users-<name>-app``) is the project's true
|
|
identity; decoding ``-`` back to ``/`` is lossy (real dir names may
|
|
contain hyphens), so the encoded form is displayed as-is.
|
|
"""
|
|
result: Dict[str, List[tuple]] = {}
|
|
for source in self.sources:
|
|
projects_dir = source.home / "projects"
|
|
if not projects_dir.is_dir():
|
|
continue
|
|
for candidate in sorted(projects_dir.iterdir()):
|
|
if candidate.is_dir():
|
|
result.setdefault(candidate.name, []).append(
|
|
(source, candidate)
|
|
)
|
|
return result
|
|
|
|
def find_all_projects_sessions(self) -> List[Dict[str, Any]]:
|
|
"""Find sessions for EVERY project across all sources (--all-projects).
|
|
|
|
Each returned ref carries a ``project`` field with the encoded
|
|
project name. Sorted newest first across all projects.
|
|
"""
|
|
sessions: List[Dict[str, Any]] = []
|
|
for project_name, pairs in self.project_dir_pairs().items():
|
|
for ref in self._merge_sessions_from_dirs(pairs):
|
|
ref["project"] = project_name
|
|
sessions.append(ref)
|
|
return sorted(
|
|
sessions,
|
|
key=lambda entry: (
|
|
entry["updated_at"]
|
|
if entry["updated_at"] is not None
|
|
else float("-inf")
|
|
),
|
|
reverse=True,
|
|
)
|
|
|
|
def find_search_sessions(
|
|
self,
|
|
*,
|
|
project_path: Optional[str],
|
|
all_projects: bool,
|
|
keywords: List[str],
|
|
case_sensitive: bool,
|
|
use_prefilter: bool,
|
|
exclude_sessions: set[str],
|
|
on_prefilter_progress: Optional[Callable[[], None]] = None,
|
|
) -> tuple[List[Dict[str, Any]], int, int, bool]:
|
|
"""Collect search refs after a safe file-level candidate pass.
|
|
|
|
Metadata discovery used to call ``scan_claude_session`` for every
|
|
session before keyword or date filtering, then ``search_sessions``
|
|
parsed the same corpus again. On the real 5,293-session inventory
|
|
that made a one-day query spend minutes parsing files that could not
|
|
contain the keyword.
|
|
|
|
This method de-duplicates aliased physical directories, peeks only far
|
|
enough from EOF to read each file's final internal ``sessionId``, and
|
|
lets the native byte scanner identify candidate physical files. Once
|
|
any copy is a candidate, every active/archive filename carrying that
|
|
same session id is retained and fully parsed; that preserves renamed
|
|
copy unions and the exact untimed-record count under date windows.
|
|
``None`` from the scanner means no trustworthy filtering information,
|
|
so it falls back to the original full metadata scan.
|
|
|
|
Returns ``(candidate_refs, total_sessions, project_count, applied)``.
|
|
The total is derived from ``(project, sessionId)`` identities so the
|
|
user-visible searched-session count stays stable even when copies were
|
|
renamed. Only the short identity peek is paid by every file; complete
|
|
metadata and conversation parsing remain candidate-only. Conversation
|
|
chronology still comes exclusively from internal records.
|
|
"""
|
|
if all_projects:
|
|
project_pairs = self.project_dir_pairs()
|
|
else:
|
|
pairs = self._resolve_project_dirs(project_path or "")
|
|
project_pairs = {pairs[0][1].name: pairs} if pairs else {}
|
|
|
|
if not use_prefilter:
|
|
sessions: List[Dict[str, Any]] = []
|
|
for project_name, pairs in project_pairs.items():
|
|
for ref in self._merge_sessions_from_dirs(pairs):
|
|
if all_projects:
|
|
ref["project"] = project_name
|
|
sessions.append(ref)
|
|
sessions = [
|
|
ref
|
|
for ref in sessions
|
|
if ref["session_id"] not in exclude_sessions
|
|
]
|
|
project_count = len({ref.get("project") for ref in sessions}) if all_projects else int(bool(sessions))
|
|
return sessions, len(sessions), project_count, False
|
|
|
|
unique_paths: Dict[str, Path] = {}
|
|
keys_by_path: Dict[str, set[tuple[str, str]]] = defaultdict(set)
|
|
for project_name, pairs in project_pairs.items():
|
|
for scan_dir, _group_members in self._group_project_dirs(pairs):
|
|
for file in scan_dir.glob("*.jsonl"):
|
|
if file.name.startswith("agent-"):
|
|
continue
|
|
key = (project_name, file.name)
|
|
try:
|
|
physical = str(file.resolve())
|
|
except (OSError, RuntimeError):
|
|
physical = str(file)
|
|
unique_paths.setdefault(physical, file)
|
|
keys_by_path[physical].add(key)
|
|
|
|
identity_by_path: Dict[str, Optional[str]] = {}
|
|
uncertain_paths: set[str] = set()
|
|
for physical, file in unique_paths.items():
|
|
session_id = peek_final_claude_session_id(file)
|
|
if session_id is None or session_id in exclude_sessions:
|
|
identity_by_path[physical] = None
|
|
uncertain_paths.add(physical)
|
|
else:
|
|
identity_by_path[physical] = session_id
|
|
|
|
matched_files = files_possibly_matching(
|
|
unique_paths.values(),
|
|
keywords,
|
|
case_sensitive=case_sensitive,
|
|
on_progress=on_prefilter_progress,
|
|
)
|
|
if matched_files is None:
|
|
return self.find_search_sessions(
|
|
project_path=project_path,
|
|
all_projects=all_projects,
|
|
keywords=keywords,
|
|
case_sensitive=case_sensitive,
|
|
use_prefilter=False,
|
|
exclude_sessions=exclude_sessions,
|
|
)
|
|
|
|
matched_physical: set[str] = set()
|
|
for file in matched_files:
|
|
try:
|
|
matched_physical.add(str(file.resolve()))
|
|
except (OSError, RuntimeError):
|
|
matched_physical.add(str(file))
|
|
|
|
# A bounded identity miss is uncertainty, not evidence that filename
|
|
# identity is authoritative. Promote it to the candidate set, then pay
|
|
# the full metadata scan only for that small subset. This runs after
|
|
# the native keyword pass, so old/no-ID files cannot silently turn the
|
|
# identity index back into a full-corpus JSON parser.
|
|
candidate_physical = matched_physical | uncertain_paths
|
|
candidate_summaries: Dict[str, Any] = {}
|
|
for physical in candidate_physical:
|
|
summary = scan_claude_session(unique_paths[physical])
|
|
candidate_summaries[physical] = summary
|
|
identity_by_path[physical] = summary.session_id
|
|
|
|
total_identities: set[tuple[str, str]] = set()
|
|
projects_with_sessions: set[str] = set()
|
|
for physical in unique_paths:
|
|
session_id = identity_by_path[physical]
|
|
if session_id is None or session_id in exclude_sessions:
|
|
continue
|
|
for project_name, _filename in keys_by_path[physical]:
|
|
identity = (project_name, session_id)
|
|
total_identities.add(identity)
|
|
projects_with_sessions.add(project_name)
|
|
|
|
candidate_identities: set[tuple[str, str]] = set()
|
|
for physical in candidate_physical:
|
|
session_id = identity_by_path.get(physical)
|
|
if session_id is None or session_id in exclude_sessions:
|
|
continue
|
|
for project_name, _filename in keys_by_path[physical]:
|
|
candidate_identities.add((project_name, session_id))
|
|
|
|
candidate_paths_by_project: Dict[str, set[str]] = defaultdict(set)
|
|
for physical in unique_paths:
|
|
session_id = identity_by_path[physical]
|
|
if session_id is None or session_id in exclude_sessions:
|
|
continue
|
|
for project_name, _filename in keys_by_path[physical]:
|
|
if (project_name, session_id) in candidate_identities:
|
|
candidate_paths_by_project[project_name].add(physical)
|
|
|
|
sessions = []
|
|
for project_name, pairs in project_pairs.items():
|
|
candidate_paths = candidate_paths_by_project.get(project_name, set())
|
|
if not candidate_paths:
|
|
continue
|
|
for ref in self._merge_sessions_from_dirs(
|
|
pairs,
|
|
candidate_paths=candidate_paths,
|
|
candidate_summaries=candidate_summaries,
|
|
):
|
|
if ref["session_id"] in exclude_sessions:
|
|
continue
|
|
if all_projects:
|
|
ref["project"] = project_name
|
|
sessions.append(ref)
|
|
sessions.sort(
|
|
key=lambda entry: (
|
|
entry["updated_at"]
|
|
if entry["updated_at"] is not None
|
|
else float("-inf")
|
|
),
|
|
reverse=True,
|
|
)
|
|
return (
|
|
sessions,
|
|
len(total_identities),
|
|
len(projects_with_sessions),
|
|
True,
|
|
)
|
|
|
|
def _resolve_project_dirs(self, project_path: str) -> List[tuple]:
|
|
"""
|
|
Resolve a project path to its encoded dir under EACH home's projects/.
|
|
|
|
Claude Code encodes the project's ABSOLUTE working-directory path by
|
|
replacing every ``/`` with ``-`` (e.g. ``/Users/<name>/app`` ->
|
|
``-Users-<name>-app``). The directory name is NOT the basename, so a
|
|
bare name or an unexpanded ``~`` path never matches directly — the #1
|
|
reason a real history is mistaken for "no sessions". The same project
|
|
has a same-named encoded dir under every home that holds history for it.
|
|
|
|
Strategy (the exact-vs-fallback decision is GLOBAL, not per-home):
|
|
1. Try the exact encoded dir in every home. If it matches in ANY home,
|
|
the project identity is known precisely — return those exact matches
|
|
only. A home lacking the exact dir contributes nothing; it is NOT
|
|
fuzzy-matched, so a different project that merely shares the basename
|
|
in another profile home can never be conflated in.
|
|
2. Only if NO home has the exact dir, treat the input as a bare basename
|
|
and reverse-look-up ``-<basename>`` across all homes. Require a SINGLE
|
|
distinct encoded name; if the basename maps to two different projects
|
|
(within OR across homes), that is ambiguous — warn and return nothing
|
|
rather than guess.
|
|
|
|
Returns:
|
|
List of ``(source, encoded_dir)`` pairs — one per source where the
|
|
resolved project dir exists.
|
|
"""
|
|
# Encode the resolved absolute path once (for exact matching).
|
|
exact_name: Optional[str] = None
|
|
try:
|
|
abs_path = Path(project_path).expanduser().resolve()
|
|
exact_name = str(abs_path).replace("/", "-")
|
|
except (OSError, RuntimeError):
|
|
exact_name = None
|
|
base = Path(project_path).name
|
|
|
|
# Pass 1 — exact encoded-dir match across ALL homes. A single exact hit
|
|
# anywhere fixes the project identity, so we never fuzzy-fall-back.
|
|
if exact_name is not None:
|
|
exact_hits = [
|
|
(source, source.home / "projects" / exact_name)
|
|
for source in self.sources
|
|
if (source.home / "projects" / exact_name).is_dir()
|
|
]
|
|
if exact_hits:
|
|
return exact_hits
|
|
|
|
# Pass 2 — no exact match anywhere: reverse-look-up the bare basename,
|
|
# but bind only ONE distinct project so same-basename projects living in
|
|
# different homes are never conflated together.
|
|
if not base:
|
|
return []
|
|
by_name: Dict[str, List[tuple]] = {}
|
|
for source in self.sources:
|
|
projects_dir = source.home / "projects"
|
|
if not projects_dir.is_dir():
|
|
continue
|
|
for d in projects_dir.iterdir():
|
|
if d.is_dir() and d.name.endswith("-" + base):
|
|
by_name.setdefault(d.name, []).append((source, d))
|
|
|
|
if not by_name:
|
|
return []
|
|
if len(by_name) > 1:
|
|
print(
|
|
f"Ambiguous project name '{base}' — {len(by_name)} distinct "
|
|
"projects match across homes; re-run with the full absolute path:",
|
|
file=sys.stderr,
|
|
)
|
|
for name in sorted(by_name):
|
|
sources_str = ", ".join(
|
|
source.display_label for source, _ in by_name[name]
|
|
)
|
|
print(f" {name} [{sources_str}]", file=sys.stderr)
|
|
return []
|
|
# Exactly one distinct project — use it wherever it exists.
|
|
return next(iter(by_name.values()))
|
|
|
|
def search_sessions(
|
|
self,
|
|
session_refs: List[Dict[str, Any]],
|
|
keywords: List[str],
|
|
case_sensitive: bool = False,
|
|
from_timestamp: Optional[float] = None,
|
|
to_timestamp: Optional[float] = None,
|
|
use_prefilter: bool = True,
|
|
include_agent_prompts: bool = False,
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Search sessions for keywords.
|
|
|
|
Args:
|
|
session_refs: Session refs from ``find_project_sessions`` (each has
|
|
every active/archive copy plus a representative path).
|
|
keywords: Keywords to search for.
|
|
case_sensitive: Whether to perform case-sensitive search.
|
|
from_timestamp: Inclusive lower internal record timestamp.
|
|
to_timestamp: Inclusive upper internal record timestamp.
|
|
use_prefilter: Skip the expensive per-line json.loads() + text
|
|
extraction pass on copies a raw byte scan already proves
|
|
cannot contain any keyword (see ``files_possibly_matching``).
|
|
Forced off automatically when a date window is active — see
|
|
below for why.
|
|
include_agent_prompts: Include Claude ``type=user`` records marked
|
|
``isSidechain``. Those records are prompts written by the main
|
|
agent to a subagent, not human-authored conversation. They are
|
|
excluded by default; assistant-side sidechain records remain
|
|
searchable because they contain the subagent's real output.
|
|
|
|
Returns:
|
|
List of match dicts (session ref + match counts), most mentions
|
|
first.
|
|
"""
|
|
# The pre-filter is only safe to apply when no date window is active.
|
|
# `excluded_untimed_count` below counts records that lack a timestamp
|
|
# ACROSS EVERY COPY of a session, independent of whether those records
|
|
# contain a keyword — it exists purely to report "N records could not
|
|
# be checked against your --from-date/--to-date window". Skipping a
|
|
# copy's full parse because it has no keyword match would silently
|
|
# drop its contribution to that count. Outside a date window this
|
|
# counter is never computed, so skipping is unconditionally safe —
|
|
# the file-level pre-filter is provably an over-approximation (see
|
|
# files_possibly_matching's docstring), so a copy it rules out cannot
|
|
# contain a matchable record.
|
|
use_prefilter = use_prefilter and from_timestamp is None and to_timestamp is None
|
|
matched_files: Optional[set[Path]] = None
|
|
# Always case-folded regardless of `case_sensitive`: casefold-matching
|
|
# is a strict superset of exact-case matching (anything an exact-case
|
|
# check would find, casefold-matching finds too), so it is always a
|
|
# safe over-approximation to hand to the line-level pre-check in
|
|
# iter_jsonl — it costs a little filtering precision in
|
|
# case-sensitive mode, never a missed match.
|
|
# Gate on the ORIGINAL keywords, not the folded ones: "ß".casefold()
|
|
# is "ss" (ASCII), so checking after folding would answer "safe" for
|
|
# precisely the input the check exists to reject.
|
|
line_keywords = (
|
|
[kw.casefold() for kw in keywords]
|
|
if use_prefilter and keywords_are_raw_byte_safe(keywords)
|
|
else None
|
|
)
|
|
if use_prefilter:
|
|
all_copy_paths = [
|
|
copy["path"]
|
|
for ref in session_refs
|
|
for copy in (
|
|
ref.get("copies")
|
|
or [{"path": ref["path"], "source": ref["sources"][0]}]
|
|
)
|
|
]
|
|
matched_files = files_possibly_matching(
|
|
all_copy_paths, keywords, case_sensitive=case_sensitive
|
|
)
|
|
|
|
matches: List[Dict[str, Any]] = []
|
|
search_keywords = [
|
|
(keyword, keyword if case_sensitive else keyword.casefold())
|
|
for keyword in keywords
|
|
]
|
|
|
|
for ref in session_refs:
|
|
keyword_counts = defaultdict(int)
|
|
total_mentions = 0
|
|
match_sources: set[str] = set()
|
|
matching_source_labels: set[str] = set()
|
|
match_timestamps = TimestampRange()
|
|
excluded_untimed_count = 0
|
|
excluded_untimed_from_prior_copies: set[str] = set()
|
|
matched_records_from_prior_copies: set[str] = set()
|
|
matched_file_history_paths: set[str] = set()
|
|
matching_copies: List[Dict[str, Any]] = []
|
|
|
|
copies = ref.get("copies") or [
|
|
{
|
|
"path": ref["path"],
|
|
"source": ref["sources"][0],
|
|
"created_at": ref["created_at"],
|
|
"updated_at": ref["updated_at"],
|
|
}
|
|
]
|
|
# Archive copies of one session are routinely byte-identical: a
|
|
# registered long-term backup snapshots the same .jsonl every run,
|
|
# so one session commonly carries a dozen copies with a single
|
|
# distinct content (measured on a real project: 46 of 60 sampled
|
|
# multi-copy sessions held 13 copies and exactly 1 content; that
|
|
# redundancy was 85% of the corpus's bytes). Parsing all of them
|
|
# costs Nx for one session's worth of records — _record_identity
|
|
# dedupes them straight back down afterwards.
|
|
#
|
|
# A byte-identical copy therefore has a fully predictable outcome:
|
|
# every record identity is already registered by the twin that ran
|
|
# first, so it can add no new mentions, no new untimed records and
|
|
# no new timestamps. Only its own source label is new. Reuse that
|
|
# conclusion instead of re-parsing.
|
|
#
|
|
# Hash only sizes that actually repeat — a unique size cannot have
|
|
# an identical twin, so single-copy sessions pay nothing.
|
|
sizes_worth_hashing = repeated_copy_sizes(copies)
|
|
digest_had_match: Dict[str, bool] = {}
|
|
|
|
for copy in copies:
|
|
session_file = copy["path"]
|
|
source = copy["source"]
|
|
if matched_files is not None and session_file not in matched_files:
|
|
# A raw byte scan already proved this exact copy cannot
|
|
# contain any keyword — see the use_prefilter comment
|
|
# above for why skipping it here (no date window active)
|
|
# cannot under-count anything.
|
|
continue
|
|
|
|
copy_digest: Optional[str] = None
|
|
try:
|
|
copy_size = session_file.stat().st_size
|
|
except OSError:
|
|
copy_size = None
|
|
if copy_size in sizes_worth_hashing:
|
|
copy_digest = file_content_digest(session_file)
|
|
if copy_digest is not None and copy_digest in digest_had_match:
|
|
if digest_had_match[copy_digest]:
|
|
# Same bytes as a copy already processed for this
|
|
# session: it matched, so this one does too, and every
|
|
# one of its records was already counted.
|
|
matching_source_labels.add(source.display_label)
|
|
matching_copies.append(
|
|
{
|
|
"path": session_file,
|
|
"source": source,
|
|
"new_mentions": 0,
|
|
}
|
|
)
|
|
continue
|
|
|
|
copy_had_match = False
|
|
copy_new_mentions = 0
|
|
copy_untimed_records: set[str] = set()
|
|
copy_matched_records: set[str] = set()
|
|
try:
|
|
records = iter_jsonl(session_file, line_keywords=line_keywords)
|
|
for data in records:
|
|
record_identity = _record_identity(data)
|
|
hide_agent_prompt = (
|
|
not include_agent_prompts
|
|
and is_claude_agent_prompt_record(data)
|
|
)
|
|
record_timestamp = parse_timestamp(data.get("timestamp"))
|
|
if (
|
|
record_timestamp is None
|
|
and data.get("type") == "file-history-snapshot"
|
|
):
|
|
snapshot = data.get("snapshot")
|
|
if isinstance(snapshot, dict):
|
|
record_timestamp = parse_timestamp(
|
|
snapshot.get("timestamp")
|
|
)
|
|
if from_timestamp is not None or to_timestamp is not None:
|
|
if record_timestamp is None:
|
|
if record_identity not in excluded_untimed_from_prior_copies:
|
|
excluded_untimed_count += 1
|
|
copy_untimed_records.add(record_identity)
|
|
continue
|
|
if not timestamp_in_window(
|
|
record_timestamp, from_timestamp, to_timestamp
|
|
):
|
|
continue
|
|
|
|
if data.get("type") == "file-history-snapshot":
|
|
snapshot = data.get("snapshot")
|
|
tracked = (
|
|
snapshot.get("trackedFileBackups")
|
|
if isinstance(snapshot, dict)
|
|
else None
|
|
)
|
|
if isinstance(tracked, dict):
|
|
for original_path in tracked:
|
|
if not isinstance(original_path, str):
|
|
continue
|
|
path_text = (
|
|
original_path
|
|
if case_sensitive
|
|
else original_path.casefold()
|
|
)
|
|
path_counts = {
|
|
keyword: 1
|
|
for keyword, search_keyword in search_keywords
|
|
if search_keyword in path_text
|
|
}
|
|
if not path_counts:
|
|
continue
|
|
copy_had_match = True
|
|
matching_source_labels.add(source.display_label)
|
|
match_sources.add("file_history_path")
|
|
if record_timestamp is not None:
|
|
match_timestamps.observe(record_timestamp)
|
|
if original_path in matched_file_history_paths:
|
|
continue
|
|
matched_file_history_paths.add(original_path)
|
|
path_mentions = sum(path_counts.values())
|
|
for keyword, count in path_counts.items():
|
|
keyword_counts[keyword] += count
|
|
total_mentions += path_mentions
|
|
copy_new_mentions += path_mentions
|
|
|
|
record_counts = defaultdict(int)
|
|
record_sources: set[str] = set()
|
|
for segment in searchable_segments(data):
|
|
if hide_agent_prompt and segment.source == "message":
|
|
continue
|
|
search_text = (
|
|
segment.text
|
|
if case_sensitive
|
|
else segment.text.casefold()
|
|
)
|
|
for keyword, search_keyword in search_keywords:
|
|
count = search_text.count(search_keyword)
|
|
if count > 0:
|
|
record_counts[keyword] += count
|
|
record_sources.add(segment.source)
|
|
record_mentions = sum(record_counts.values())
|
|
if not record_mentions:
|
|
continue
|
|
|
|
copy_had_match = True
|
|
matching_source_labels.add(source.display_label)
|
|
copy_matched_records.add(record_identity)
|
|
if record_identity in matched_records_from_prior_copies:
|
|
continue
|
|
for keyword, count in record_counts.items():
|
|
keyword_counts[keyword] += count
|
|
total_mentions += record_mentions
|
|
copy_new_mentions += record_mentions
|
|
match_sources.update(record_sources)
|
|
if record_timestamp is not None:
|
|
match_timestamps.observe(record_timestamp)
|
|
except (OSError, UnicodeError) as e:
|
|
print(
|
|
f"Warning: Error processing {session_file}: {e}",
|
|
file=sys.stderr,
|
|
)
|
|
continue
|
|
|
|
excluded_untimed_from_prior_copies.update(copy_untimed_records)
|
|
matched_records_from_prior_copies.update(copy_matched_records)
|
|
if copy_digest is not None:
|
|
digest_had_match[copy_digest] = copy_had_match
|
|
if copy_had_match:
|
|
matching_copies.append(
|
|
{
|
|
"path": session_file,
|
|
"source": source,
|
|
"new_mentions": copy_new_mentions,
|
|
}
|
|
)
|
|
|
|
if total_mentions > 0:
|
|
primary_copy = max(
|
|
matching_copies,
|
|
key=lambda item: (
|
|
item["new_mentions"],
|
|
item["source"].kind == "active",
|
|
),
|
|
)
|
|
matches.append(
|
|
{
|
|
"path": primary_copy["path"],
|
|
"matching_copies": matching_copies,
|
|
"homes": ref["homes"],
|
|
"sources": ref["sources"],
|
|
"matching_source_labels": matching_source_labels,
|
|
"total_mentions": total_mentions,
|
|
"keyword_counts": dict(keyword_counts),
|
|
"match_sources": sorted(match_sources),
|
|
"created_at": ref["created_at"],
|
|
"updated_at": ref["updated_at"],
|
|
"match_created_at": match_timestamps.earliest,
|
|
"match_updated_at": match_timestamps.latest,
|
|
"timestamp_source": ref["timestamp_source"],
|
|
"excluded_untimed_records": excluded_untimed_count,
|
|
"size": sum(item["path"].stat().st_size for item in copies),
|
|
}
|
|
)
|
|
|
|
matches.sort(
|
|
key=lambda match: (
|
|
match["total_mentions"],
|
|
match["match_updated_at"]
|
|
if match["match_updated_at"] is not None
|
|
else float("-inf"),
|
|
),
|
|
reverse=True,
|
|
)
|
|
return matches
|
|
|
|
def get_session_stats(self, session_file: Path) -> Dict[str, Any]:
|
|
"""
|
|
Get detailed statistics for a session file.
|
|
|
|
Args:
|
|
session_file: Path to session JSONL file
|
|
|
|
Returns:
|
|
Dictionary of session statistics
|
|
"""
|
|
stats = {
|
|
"total_lines": 0,
|
|
"user_messages": 0,
|
|
"assistant_messages": 0,
|
|
"tool_uses": defaultdict(int),
|
|
"write_calls": 0,
|
|
"edit_calls": 0,
|
|
"read_calls": 0,
|
|
"bash_calls": 0,
|
|
"file_operations": [],
|
|
}
|
|
|
|
try:
|
|
with open(session_file, "r") as f:
|
|
for line in f:
|
|
stats["total_lines"] += 1
|
|
|
|
try:
|
|
data = json.loads(line.strip())
|
|
|
|
# Count message types
|
|
role = data.get("role") or data.get("message", {}).get("role")
|
|
if role == "user":
|
|
stats["user_messages"] += 1
|
|
elif role == "assistant":
|
|
stats["assistant_messages"] += 1
|
|
|
|
# Analyze tool uses
|
|
content = data.get("content") or data.get("message", {}).get(
|
|
"content", []
|
|
)
|
|
for item in content:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
|
|
if item.get("type") == "tool_use":
|
|
tool_name = item.get("name", "unknown")
|
|
stats["tool_uses"][tool_name] += 1
|
|
|
|
# Track file operations
|
|
if tool_name == "Write":
|
|
stats["write_calls"] += 1
|
|
file_path = item.get("input", {}).get(
|
|
"file_path", ""
|
|
)
|
|
if file_path:
|
|
stats["file_operations"].append(
|
|
("write", file_path)
|
|
)
|
|
elif tool_name == "Edit":
|
|
stats["edit_calls"] += 1
|
|
file_path = item.get("input", {}).get(
|
|
"file_path", ""
|
|
)
|
|
if file_path:
|
|
stats["file_operations"].append(
|
|
("edit", file_path)
|
|
)
|
|
elif tool_name == "Read":
|
|
stats["read_calls"] += 1
|
|
elif tool_name == "Bash":
|
|
stats["bash_calls"] += 1
|
|
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
except Exception as e:
|
|
print(f"Error analyzing {session_file}: {e}", file=sys.stderr)
|
|
|
|
# Convert defaultdict to regular dict
|
|
stats["tool_uses"] = dict(stats["tool_uses"])
|
|
|
|
return stats
|
|
|
|
def _extract_text_content(self, data: Dict[str, Any]) -> str:
|
|
"""Compatibility wrapper around the structured event extractor."""
|
|
return " ".join(segment.text for segment in searchable_segments(data))
|
|
|
|
|
|
def _add_home_flags(subparser) -> None:
|
|
"""Attach the shared home-scoping flags to a subparser (list / search)."""
|
|
subparser.add_argument(
|
|
"--home",
|
|
action="append",
|
|
metavar="DIR",
|
|
help="Restrict to exact Claude home dir(s) and bypass the archive "
|
|
"registry (repeatable). Default: search every active home plus "
|
|
"registered archives.",
|
|
)
|
|
subparser.add_argument(
|
|
"--main-only",
|
|
action="store_true",
|
|
help="Search only ~/.claude, bypassing profile homes and archives.",
|
|
)
|
|
subparser.add_argument(
|
|
"--history-sources",
|
|
metavar="FILE",
|
|
help=(
|
|
"History source registry (default: ~/.claude/history-sources.json "
|
|
"when present). Incompatible with --home/--main-only."
|
|
),
|
|
)
|
|
subparser.add_argument(
|
|
"--from-date",
|
|
help="Inclusive start: YYYY-MM-DD (local day) or timezone-qualified ISO datetime",
|
|
)
|
|
subparser.add_argument(
|
|
"--to-date",
|
|
help="Inclusive end: YYYY-MM-DD (local day) or timezone-qualified ISO datetime",
|
|
)
|
|
|
|
|
|
def _sources_for(args) -> tuple:
|
|
"""Resolve active/archive sources from CLI flags (used by list / search).
|
|
|
|
Returns ``(sources, narrowed, warnings)``. ``narrowed`` is True when the user passed
|
|
``--home`` / ``--main-only``, so the caller can treat an empty result as a
|
|
real "your selection matched no home with history" error instead of
|
|
silently widening back to searching every home.
|
|
"""
|
|
main_only = getattr(args, "main_only", False)
|
|
explicit = getattr(args, "home", None)
|
|
registry = getattr(args, "history_sources", None)
|
|
if main_only and explicit:
|
|
raise HistorySourceConfigError("--main-only cannot be combined with --home")
|
|
if registry and (main_only or explicit):
|
|
raise HistorySourceConfigError(
|
|
"--history-sources cannot be combined with --home/--main-only"
|
|
)
|
|
if main_only:
|
|
sources, warnings = discover_claude_sources(
|
|
explicit_homes=[Path.home() / ".claude"]
|
|
)
|
|
return sources, True, warnings
|
|
if explicit:
|
|
sources, warnings = discover_claude_sources(explicit_homes=explicit)
|
|
return sources, True, warnings
|
|
sources, warnings = discover_claude_sources(manifest_path=registry)
|
|
return sources, False, warnings
|
|
|
|
|
|
def _analyzer_or_exit(args) -> "SessionAnalyzer":
|
|
"""Build a SessionAnalyzer, erroring out if a narrowing flag matched no home.
|
|
|
|
Without this, an explicit ``--home``/``--main-only`` that resolves to no
|
|
home-with-history would (via an empty list) either search nothing or, worse,
|
|
reintroduce full discovery — turning a scope-narrowing flag into the widest
|
|
possible scope. We fail loudly instead.
|
|
"""
|
|
try:
|
|
sources, narrowed, warnings = _sources_for(args)
|
|
except HistorySourceConfigError as error:
|
|
print(f"History source configuration error: {error}", file=sys.stderr)
|
|
sys.exit(2)
|
|
if narrowed and not sources:
|
|
print(
|
|
"No Claude home with a projects/ dir matched your --home/--main-only "
|
|
"selection (a --home value must be a config home such as ~/.claude "
|
|
"or ~/.claude-profiles/<name>, not its projects/ subdir).",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
return SessionAnalyzer(sources=sources, warnings=warnings)
|
|
|
|
|
|
def _parse_date_window(args, parser) -> tuple[Optional[float], Optional[float]]:
|
|
try:
|
|
from_timestamp = (
|
|
parse_date_boundary(args.from_date) if args.from_date else None
|
|
)
|
|
to_timestamp = (
|
|
parse_date_boundary(args.to_date, end=True) if args.to_date else None
|
|
)
|
|
except ValueError as error:
|
|
parser.error(str(error))
|
|
if (
|
|
from_timestamp is not None
|
|
and to_timestamp is not None
|
|
and from_timestamp > to_timestamp
|
|
):
|
|
parser.error("--from-date must not be later than --to-date")
|
|
return from_timestamp, to_timestamp
|
|
|
|
|
|
def _format_range(earliest: Optional[float], latest: Optional[float]) -> str:
|
|
if earliest is None or latest is None:
|
|
return "unknown (no internal timestamp)"
|
|
return f"{format_timestamp(earliest)} .. {format_timestamp(latest)}"
|
|
|
|
|
|
def _source_summary(sources: List[HistorySource]) -> str:
|
|
return ", ".join(source.display_label for source in sources)
|
|
|
|
|
|
def _validate_project_scope(args, parser) -> None:
|
|
"""Exactly one of project_path / --all-projects must be given."""
|
|
if args.all_projects and args.project_path:
|
|
parser.error("pass either a project path or --all-projects, not both")
|
|
if not args.all_projects and not args.project_path:
|
|
parser.error(
|
|
"a project path is required unless --all-projects is given "
|
|
"(use --all-projects when you do not know which project it was)"
|
|
)
|
|
|
|
|
|
def _normalize_search_scope(args, parser) -> None:
|
|
"""Resolve the search positional grammar without argparse ambiguity.
|
|
|
|
``project_path?`` followed by ``keywords+`` is ambiguous when
|
|
``--all-projects`` is active: argparse consumes the first of two keywords as
|
|
the optional project. Parse one term list instead, then apply the scope the
|
|
caller selected.
|
|
"""
|
|
terms = list(args.search_terms)
|
|
if args.all_projects:
|
|
args.project_path = None
|
|
args.keywords = terms
|
|
return
|
|
if len(terms) < 2:
|
|
parser.error(
|
|
"search requires a project path followed by at least one keyword; "
|
|
"use --all-projects when the project is unknown"
|
|
)
|
|
args.project_path = terms[0]
|
|
args.keywords = terms[1:]
|
|
|
|
|
|
def _maybe_hint_all_projects(project_path: str) -> None:
|
|
"""Warn when an unresolved ``project_path`` looks like it was meant as a
|
|
keyword, not a path — the exact trap of running ``search`` with keywords
|
|
only and forgetting ``--all-projects``: argparse's positional grammar
|
|
(``project_path?`` then ``keywords+``) silently consumes the first
|
|
keyword as the project, the lookup finds nothing, and the message alone
|
|
("No sessions found for project: embedding") reads as "your keyword
|
|
doesn't exist" rather than "you searched for a project by that name".
|
|
A real project path always contains a path separator or resolves to an
|
|
existing directory; a bare word that does neither almost certainly was
|
|
not one.
|
|
"""
|
|
if "/" in project_path or "\\" in project_path or Path(project_path).exists():
|
|
return
|
|
print(
|
|
"Hint: this looks like it might be a keyword, not a project path — "
|
|
"if you don't know which project the conversation happened in, "
|
|
"re-run with --all-projects (e.g. `search --all-projects "
|
|
f"{project_path} ...`).",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
|
|
def _collect_sessions(analyzer: "SessionAnalyzer", args) -> List[Dict[str, Any]]:
|
|
"""Collect session refs for the requested scope, applying exclusions."""
|
|
if args.all_projects:
|
|
sessions = analyzer.find_all_projects_sessions()
|
|
else:
|
|
sessions = analyzer.find_project_sessions(args.project_path)
|
|
exclude = set(getattr(args, "exclude_session", None) or [])
|
|
if exclude:
|
|
sessions = [
|
|
ref
|
|
for ref in sessions
|
|
if ref["session_id"] not in exclude and ref["path"].stem not in exclude
|
|
]
|
|
return sessions
|
|
|
|
|
|
def _collect_search_sessions(
|
|
analyzer: "SessionAnalyzer", args
|
|
) -> tuple[List[Dict[str, Any]], int, int, bool]:
|
|
"""Candidate-first collection for keyword search, with visible progress."""
|
|
exclude = set(getattr(args, "exclude_session", None) or [])
|
|
progress_count = 0
|
|
|
|
def prefilter_progress() -> None:
|
|
nonlocal progress_count
|
|
progress_count += 1
|
|
print(
|
|
"History keyword prefilter is still scanning physical files "
|
|
f"({progress_count * 15}s elapsed)...",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
return analyzer.find_search_sessions(
|
|
project_path=args.project_path,
|
|
all_projects=args.all_projects,
|
|
keywords=args.keywords,
|
|
case_sensitive=args.case_sensitive,
|
|
use_prefilter=not args.no_prefilter,
|
|
exclude_sessions=exclude,
|
|
on_prefilter_progress=prefilter_progress,
|
|
)
|
|
|
|
|
|
def _codex_home_for(args) -> Path:
|
|
explicit = getattr(args, "codex_home", None)
|
|
if explicit:
|
|
return Path(explicit).expanduser()
|
|
env_home = os.environ.get("CODEX_HOME")
|
|
if env_home:
|
|
return Path(env_home).expanduser()
|
|
return Path.home() / ".codex"
|
|
|
|
|
|
def _kimi_home_for(args) -> Path:
|
|
return resolve_kimi_home(getattr(args, "kimi_home", None))
|
|
|
|
|
|
def _print_search_widening_hint(args) -> None:
|
|
"""On zero matches, point at the widening the user has NOT applied yet.
|
|
|
|
"Not found" is the expensive failure mode of this tool, and each of the
|
|
three widenings covers a distinct reason a real conversation can be
|
|
missed: wrong project guess, wrong tool (Codex), or a remembered quote
|
|
whose wording differs from the real one.
|
|
"""
|
|
tips: List[str] = []
|
|
if not getattr(args, "all_projects", False):
|
|
tips.append(
|
|
"--all-projects (the conversation may belong to a different "
|
|
"project than the one searched)"
|
|
)
|
|
if not getattr(args, "codex", False):
|
|
tips.append(
|
|
"--codex (it may have been a Codex conversation — Codex rollouts "
|
|
"are a separate store this search skips by default)"
|
|
)
|
|
if not getattr(args, "kimi", False):
|
|
tips.append(
|
|
"--kimi (it may have been a Kimi CLI conversation — Kimi sessions "
|
|
"are a separate store this search skips by default)"
|
|
)
|
|
tips.append(
|
|
"shorter distinctive substrings (a remembered quote often differs "
|
|
"from the real wording in punctuation or a few words)"
|
|
)
|
|
print("Tip: no matches. Before concluding it is absent, retry with:", file=sys.stderr)
|
|
for tip in tips:
|
|
print(f" - {tip}", file=sys.stderr)
|
|
|
|
|
|
def main():
|
|
"""Main entry point."""
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description="Analyze Claude Code session history files"
|
|
)
|
|
|
|
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
|
|
|
# List sessions command
|
|
list_parser = subparsers.add_parser("list", help="List all sessions for a project")
|
|
list_parser.add_argument(
|
|
"project_path",
|
|
nargs="?",
|
|
help="Project path (omit when using --all-projects)",
|
|
)
|
|
list_parser.add_argument(
|
|
"--all-projects",
|
|
action="store_true",
|
|
help="Sweep every project across all sources instead of one project.",
|
|
)
|
|
list_parser.add_argument(
|
|
"--exclude-session",
|
|
action="append",
|
|
metavar="ID",
|
|
default=[],
|
|
help="Exclude a session id (repeatable) — e.g. the current session, "
|
|
"which always matches the phrase you just typed.",
|
|
)
|
|
list_parser.add_argument(
|
|
"--limit", type=int, default=10, help="Max sessions to show (default: 10)"
|
|
)
|
|
_add_home_flags(list_parser)
|
|
|
|
# Triage command — classify how sessions in scope ended (crash recovery,
|
|
# backlog audit). Distinct from `list`: prints the full last-assistant
|
|
# text so a human/agent can judge whether a reply is still expected,
|
|
# rather than a truncated title.
|
|
triage_parser = subparsers.add_parser(
|
|
"triage",
|
|
help="Classify how sessions in a time window/project ended "
|
|
"(interrupted / net-error / stuck-on-tool / done) with full tail text",
|
|
)
|
|
triage_parser.add_argument(
|
|
"project_path",
|
|
nargs="?",
|
|
help="Project path (omit when using --all-projects)",
|
|
)
|
|
triage_parser.add_argument(
|
|
"--all-projects",
|
|
action="store_true",
|
|
help="Sweep every project across all sources instead of one project.",
|
|
)
|
|
triage_parser.add_argument(
|
|
"--exclude-session",
|
|
action="append",
|
|
metavar="ID",
|
|
default=[],
|
|
help="Exclude a session id (repeatable) — e.g. the current session.",
|
|
)
|
|
triage_parser.add_argument(
|
|
"--include-automated",
|
|
action="store_true",
|
|
help="Include sessions whose opening prompt matches the generic "
|
|
"smoke-test pattern ('reply/respond exactly ...'). Excluded by "
|
|
"default. This does NOT catch project-specific automation (e.g. a "
|
|
"git hook that always opens with the same review prompt) — use "
|
|
"--exclude-title-prefix for that; it is deliberately not hardcoded "
|
|
"here since the wording is per-project, not a Claude Code convention.",
|
|
)
|
|
triage_parser.add_argument(
|
|
"--exclude-title-prefix",
|
|
action="append",
|
|
metavar="TEXT",
|
|
default=[],
|
|
help="Exclude sessions whose opening prompt starts with TEXT "
|
|
"(repeatable, case-sensitive). Use this for a project's own "
|
|
"automation convention, e.g. a code-review hook that always opens "
|
|
"with the same fixed prompt — these otherwise dominate a triage "
|
|
"pass because they end in a routine structured tool call, not an "
|
|
"interruption.",
|
|
)
|
|
triage_parser.add_argument(
|
|
"--kind",
|
|
action="append",
|
|
choices=[
|
|
TAIL_INTERRUPTED_EXPLICIT,
|
|
TAIL_NET_ERROR,
|
|
TAIL_STUCK_NO_RESULT,
|
|
TAIL_DONE,
|
|
TAIL_EMPTY,
|
|
],
|
|
metavar="KIND",
|
|
help="Restrict to one or more tail kinds (repeatable). Default: all "
|
|
"kinds. Note 'done' includes sessions that gave a real reply and may "
|
|
"still be awaiting a response — read last_assistant_text to judge "
|
|
"that; 'done' is not a claim that nothing is outstanding.",
|
|
)
|
|
triage_parser.add_argument(
|
|
"--tail-chars",
|
|
type=int,
|
|
default=4000,
|
|
help="Max characters of the last assistant message to print "
|
|
"(default: 4000; 0 = full text, no truncation).",
|
|
)
|
|
triage_parser.add_argument(
|
|
"--limit",
|
|
type=int,
|
|
default=200,
|
|
help="Max sessions to print, 0 = no limit (default: 200). This is a "
|
|
"print-time cap, not a scan-time one: every session in scope is "
|
|
"still classified before --limit or --kind trims the output, so "
|
|
"--kind does not reduce cost the way narrowing --from-date/--to-date "
|
|
"does. The 200 default exists specifically to stop an accidentally "
|
|
"unscoped `--all-projects` with no date bound from dumping tens of "
|
|
"thousands of lines; pass --limit 0 to explicitly opt into an "
|
|
"unbounded dump once you know the scope is narrow.",
|
|
)
|
|
_add_home_flags(triage_parser)
|
|
|
|
locate_codex_parser = subparsers.add_parser(
|
|
"locate-codex",
|
|
help="Locate an exact Codex session UUID from metadata without a corpus scan",
|
|
)
|
|
locate_codex_parser.add_argument("session_id", help="Exact Codex session UUID")
|
|
locate_codex_parser.add_argument(
|
|
"--codex-home",
|
|
metavar="DIR",
|
|
help="Codex home (default: $CODEX_HOME or ~/.codex).",
|
|
)
|
|
|
|
# Search command
|
|
search_parser = subparsers.add_parser("search", help="Search sessions for keywords")
|
|
search_parser.add_argument(
|
|
"search_terms",
|
|
nargs="+",
|
|
metavar="PROJECT_OR_KEYWORD",
|
|
help=(
|
|
"Project path followed by keywords, or only keywords with "
|
|
"--all-projects"
|
|
),
|
|
)
|
|
search_parser.add_argument(
|
|
"--all-projects",
|
|
action="store_true",
|
|
help="Sweep every project across all sources — the default move when "
|
|
"you do not know which project the conversation happened in.",
|
|
)
|
|
search_parser.add_argument(
|
|
"--exclude-session",
|
|
action="append",
|
|
metavar="ID",
|
|
default=[],
|
|
help="Exclude a session id (repeatable) — e.g. the current session, "
|
|
"which always matches the phrase you just typed.",
|
|
)
|
|
search_parser.add_argument(
|
|
"--codex",
|
|
action="store_true",
|
|
help="Also search Codex rollout history (sessions/** + "
|
|
"archived_sessions/ under the Codex home). Codex uses a different "
|
|
"store and schema that the Claude registry never covers.",
|
|
)
|
|
search_parser.add_argument(
|
|
"--codex-home",
|
|
metavar="DIR",
|
|
help="Codex home for --codex (default: $CODEX_HOME or ~/.codex).",
|
|
)
|
|
search_parser.add_argument(
|
|
"--codex-max-scan-seconds",
|
|
type=float,
|
|
default=DEFAULT_CODEX_SCAN_BUDGET_SECONDS,
|
|
metavar="SECONDS",
|
|
help="Stop a broad Codex rollout scan after SECONDS (default: 300). "
|
|
"Use 0 only to explicitly accept an unbounded scan; partial results "
|
|
"are never reported as complete.",
|
|
)
|
|
search_parser.add_argument(
|
|
"--kimi",
|
|
action="store_true",
|
|
help="Also search Kimi CLI session history (sessions/**/wire.jsonl "
|
|
"under the Kimi home). Kimi CLI uses a different store and schema "
|
|
"that the Claude registry never covers.",
|
|
)
|
|
search_parser.add_argument(
|
|
"--kimi-home",
|
|
metavar="DIR",
|
|
help="Kimi CLI home for --kimi (default: $KIMI_HOME or ~/.kimi-code).",
|
|
)
|
|
search_parser.add_argument(
|
|
"--case-sensitive", action="store_true", help="Case-sensitive search"
|
|
)
|
|
search_parser.add_argument(
|
|
"--include-agent-prompts",
|
|
action="store_true",
|
|
help="Include Claude user-side isSidechain records. These are prompts "
|
|
"the main agent sent to subagents, so they are excluded by default. "
|
|
"Assistant-side subagent output remains searchable either way.",
|
|
)
|
|
search_parser.add_argument(
|
|
"--no-prefilter",
|
|
action="store_true",
|
|
help="Disable native file/line pre-filters and fully parse every "
|
|
"session file. The filters are conservative speedups: ordinary ASCII "
|
|
"uses literal matching, and uncased Unicode such as CJK uses an exact "
|
|
"raw/JSON-escaped file regex when ripgrep is available. Unsafe escape "
|
|
"characters, cased or multi-codepoint Unicode folds, missing tools, "
|
|
"and scanner errors already fall back to full parsing. Use this flag "
|
|
"to verify output neutrality or diagnose a suspected missed match.",
|
|
)
|
|
_add_home_flags(search_parser)
|
|
|
|
# Stats command
|
|
stats_parser = subparsers.add_parser("stats", help="Get session statistics")
|
|
stats_parser.add_argument("session_file", type=Path, help="Session file path")
|
|
stats_parser.add_argument(
|
|
"--show-files", action="store_true", help="Show file operations"
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
if not args.command:
|
|
parser.print_help()
|
|
sys.exit(1)
|
|
|
|
if args.command == "locate-codex":
|
|
raise SystemExit(_print_codex_locations(_codex_home_for(args), args.session_id))
|
|
|
|
if args.command == "search":
|
|
_normalize_search_scope(args, parser)
|
|
if args.codex_max_scan_seconds < 0:
|
|
parser.error("--codex-max-scan-seconds must be 0 or greater")
|
|
if (
|
|
args.codex
|
|
and len(args.keywords) == 1
|
|
and CODEX_EXACT_SESSION_ID_RE.fullmatch(args.keywords[0])
|
|
and args.keywords[0] not in set(args.exclude_session)
|
|
):
|
|
print(
|
|
"Exact Codex session ID detected; using session_meta locator "
|
|
"instead of searching conversation bytes."
|
|
)
|
|
raise SystemExit(
|
|
_print_codex_locations(_codex_home_for(args), args.keywords[0])
|
|
)
|
|
|
|
if args.command == "list":
|
|
_validate_project_scope(args, parser)
|
|
from_timestamp, to_timestamp = _parse_date_window(args, parser)
|
|
analyzer = _analyzer_or_exit(args)
|
|
source_summary = _source_summary(analyzer.sources)
|
|
sessions = _collect_sessions(analyzer, args)
|
|
for warning in analyzer.warnings:
|
|
print(f"Warning: {warning}", file=sys.stderr)
|
|
unknown = 0
|
|
if from_timestamp is not None or to_timestamp is not None:
|
|
filtered = []
|
|
for ref in sessions:
|
|
if ref["created_at"] is None or ref["updated_at"] is None:
|
|
unknown += 1
|
|
continue
|
|
if range_overlaps_window(
|
|
ref["created_at"],
|
|
ref["updated_at"],
|
|
from_timestamp,
|
|
to_timestamp,
|
|
):
|
|
filtered.append(ref)
|
|
sessions = filtered
|
|
if not sessions:
|
|
if args.all_projects:
|
|
print("No sessions found across all projects")
|
|
else:
|
|
print(f"No sessions found for project: {args.project_path}")
|
|
_maybe_hint_all_projects(args.project_path)
|
|
print(
|
|
f"(searched {len(analyzer.sources)} source(s): {source_summary})",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
project_names = sorted(
|
|
{ref.get("project") for ref in sessions if ref.get("project")}
|
|
)
|
|
if args.all_projects:
|
|
print(
|
|
f"Found {len(sessions)} session(s) across "
|
|
f"{len(project_names)} project(s)"
|
|
)
|
|
else:
|
|
print(f"Found {len(sessions)} session(s) for {args.project_path}")
|
|
print(
|
|
f"Searched {len(analyzer.sources)} source(s): {source_summary}\n"
|
|
)
|
|
if unknown:
|
|
print(
|
|
f"Warning: excluded {unknown} session(s) without an internal "
|
|
"timestamp; file mtime was not used as a fallback.",
|
|
file=sys.stderr,
|
|
)
|
|
print(f"Showing {min(args.limit, len(sessions))} most recent:\n")
|
|
|
|
shown = sessions[: args.limit]
|
|
last_project: Optional[str] = None
|
|
for i, ref in enumerate(shown, 1):
|
|
if args.all_projects:
|
|
project = ref.get("project") or "unknown-project"
|
|
if project != last_project:
|
|
count = sum(
|
|
1 for item in sessions if item.get("project") == project
|
|
)
|
|
print(f"== {project} ({count} session(s))")
|
|
last_project = project
|
|
session = ref["path"]
|
|
size_kb = session.stat().st_size / 1024
|
|
labels = _source_summary(ref["sources"])
|
|
print(f"{i}. {session.name}")
|
|
print(
|
|
f" Internal range: "
|
|
f"{_format_range(ref['created_at'], ref['updated_at'])}"
|
|
)
|
|
print(f" Size: {size_kb:.1f} KB")
|
|
print(f" Source: {labels}")
|
|
if len(ref["copies"]) > 1:
|
|
print(f" Physical copies searched by keyword queries: {len(ref['copies'])}")
|
|
print(f" Path: {session}")
|
|
print()
|
|
|
|
elif args.command == "triage":
|
|
_validate_project_scope(args, parser)
|
|
from_timestamp, to_timestamp = _parse_date_window(args, parser)
|
|
analyzer = _analyzer_or_exit(args)
|
|
source_summary = _source_summary(analyzer.sources)
|
|
sessions = _collect_sessions(analyzer, args)
|
|
for warning in analyzer.warnings:
|
|
print(f"Warning: {warning}", file=sys.stderr)
|
|
|
|
unknown = 0
|
|
if from_timestamp is not None or to_timestamp is not None:
|
|
# Deliberately `timestamp_in_window` on updated_at, NOT
|
|
# `range_overlaps_window` on the full [created_at, updated_at]
|
|
# span: triage asks "did this session's momentum stop around
|
|
# this boundary," so a session that merely happened to be
|
|
# running through the window but kept going for hours/days
|
|
# afterward is not a candidate, even though its range overlaps
|
|
# the window. (list's browsing use case wants the overlap
|
|
# semantics; triage does not — verified against a real reboot
|
|
# window, where overlap swept in every still-running session.)
|
|
filtered = []
|
|
for ref in sessions:
|
|
if ref["updated_at"] is None:
|
|
unknown += 1
|
|
continue
|
|
if timestamp_in_window(ref["updated_at"], from_timestamp, to_timestamp):
|
|
filtered.append(ref)
|
|
sessions = filtered
|
|
|
|
# One lightweight scan per session gets both the title (for automated
|
|
# exclusion) and cwd (for display) — cheaper than two passes, and
|
|
# find_*_sessions()'s ref dict does not carry cwd itself.
|
|
exclude_prefixes = tuple(args.exclude_title_prefix)
|
|
excluded_generic = 0
|
|
excluded_prefix = 0
|
|
scanned = []
|
|
for ref in sessions:
|
|
summary = scan_claude_session(ref["path"])
|
|
if not args.include_automated and is_automated_title(summary.title):
|
|
excluded_generic += 1
|
|
continue
|
|
if exclude_prefixes and summary.title.startswith(exclude_prefixes):
|
|
excluded_prefix += 1
|
|
continue
|
|
scanned.append((ref, summary))
|
|
excluded_automated = excluded_generic + excluded_prefix
|
|
|
|
allowed_kinds = set(args.kind) if args.kind else None
|
|
results = []
|
|
for ref, summary in scanned:
|
|
tail = classify_session_tail(ref["path"])
|
|
if allowed_kinds is not None and tail.kind not in allowed_kinds:
|
|
continue
|
|
results.append((ref, summary, tail))
|
|
results.sort(
|
|
key=lambda triple: triple[0]["updated_at"]
|
|
if triple[0]["updated_at"] is not None
|
|
else float("-inf"),
|
|
reverse=True,
|
|
)
|
|
|
|
# Deliberately one empty-result check, run AFTER --kind filtering,
|
|
# not two separate ones (scanned-empty vs. results-empty) that used
|
|
# to give different exit codes/messages for the same "nothing to
|
|
# show" outcome depending on which filter zeroed it (independent
|
|
# review, 2026-08).
|
|
if not results:
|
|
print("No sessions matched this triage scope.")
|
|
print(
|
|
f"(searched {len(analyzer.sources)} source(s): {source_summary}; "
|
|
f"scanned {len(scanned)}, excluded {excluded_generic} generic-automated "
|
|
f"+ {excluded_prefix} --exclude-title-prefix)",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
total_matched = len(results)
|
|
if args.limit:
|
|
results = results[: args.limit]
|
|
|
|
print(
|
|
f"Triaged {len(results)} session(s)"
|
|
+ (f" of {total_matched} matched" if len(results) < total_matched else "")
|
|
+ f" (excluded {excluded_generic} generic-automated + "
|
|
f"{excluded_prefix} --exclude-title-prefix); "
|
|
f"searched {len(analyzer.sources)} source(s): {source_summary}"
|
|
)
|
|
if unknown:
|
|
print(
|
|
f"Warning: excluded {unknown} session(s) without an internal "
|
|
"timestamp; file mtime was not used as a fallback.",
|
|
file=sys.stderr,
|
|
)
|
|
print()
|
|
|
|
for ref, summary, tail in results:
|
|
updated_at = ref["updated_at"]
|
|
when = format_timestamp(updated_at) if updated_at is not None else "(unknown)"
|
|
print("=" * 88)
|
|
print(ref["session_id"])
|
|
print(f" last update: {when}")
|
|
print(f" cwd: {summary.cwd or '(unknown)'}")
|
|
print(f" kind: {tail.kind}")
|
|
last_user_preview = " ".join(tail.last_user_text.split())[:200]
|
|
print(f" last user: {last_user_preview}")
|
|
print(f" last assistant ({tail.last_assistant_kind}):")
|
|
text = tail.last_assistant_text
|
|
if args.tail_chars and len(text) > args.tail_chars:
|
|
text = text[: args.tail_chars] + f"... [+{len(text) - args.tail_chars} chars]"
|
|
for line in text.splitlines() or [""]:
|
|
print(f" {line}")
|
|
print("=" * 88)
|
|
|
|
elif args.command == "search":
|
|
_validate_project_scope(args, parser)
|
|
from_timestamp, to_timestamp = _parse_date_window(args, parser)
|
|
analyzer = _analyzer_or_exit(args)
|
|
source_summary = _source_summary(analyzer.sources)
|
|
(
|
|
sessions,
|
|
discovered_session_count,
|
|
discovered_project_count,
|
|
candidate_prefilter_applied,
|
|
) = _collect_search_sessions(analyzer, args)
|
|
for warning in analyzer.warnings:
|
|
print(f"Warning: {warning}", file=sys.stderr)
|
|
if discovered_session_count == 0 and not args.codex and not args.kimi:
|
|
if args.all_projects:
|
|
print("No sessions found across all projects")
|
|
else:
|
|
print(f"No sessions found for project: {args.project_path}")
|
|
_maybe_hint_all_projects(args.project_path)
|
|
print(
|
|
f"(searched {len(analyzer.sources)} source(s): {source_summary})",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
scope_desc = (
|
|
f" in {discovered_project_count} project(s)"
|
|
if args.all_projects
|
|
else ""
|
|
)
|
|
print(
|
|
f"Searching {discovered_session_count} session(s) across {len(analyzer.sources)} "
|
|
f"source(s) [{source_summary}]{scope_desc} for: {', '.join(args.keywords)}\n"
|
|
)
|
|
if candidate_prefilter_applied:
|
|
print(
|
|
"Candidate prefilter: full-session parsing required for "
|
|
f"{len(sessions)}/{discovered_session_count} session(s).",
|
|
file=sys.stderr,
|
|
)
|
|
matches = (
|
|
analyzer.search_sessions(
|
|
sessions,
|
|
args.keywords,
|
|
args.case_sensitive,
|
|
from_timestamp,
|
|
to_timestamp,
|
|
not args.no_prefilter and not candidate_prefilter_applied,
|
|
args.include_agent_prompts,
|
|
)
|
|
if sessions
|
|
else []
|
|
)
|
|
|
|
codex_matches: List[Dict[str, Any]] = []
|
|
codex_home: Optional[Path] = None
|
|
if args.codex:
|
|
codex_home = _codex_home_for(args)
|
|
scan_started = time.monotonic()
|
|
next_discovery_progress = scan_started + CODEX_PROGRESS_INTERVAL_SECONDS
|
|
|
|
def discovery_progress(discovered: int) -> None:
|
|
nonlocal next_discovery_progress
|
|
now = time.monotonic()
|
|
elapsed = now - scan_started
|
|
if (
|
|
args.codex_max_scan_seconds > 0
|
|
and elapsed > args.codex_max_scan_seconds
|
|
):
|
|
raise CodexScanBudgetExceeded(
|
|
discovered, None, elapsed, "rollout-discovery"
|
|
)
|
|
if now >= next_discovery_progress:
|
|
print(
|
|
"Codex scan progress: "
|
|
f"stage=rollout-discovery, discovered={discovered}, "
|
|
f"elapsed={elapsed:.1f}s",
|
|
file=sys.stderr,
|
|
flush=True,
|
|
)
|
|
while next_discovery_progress <= now:
|
|
next_discovery_progress += CODEX_PROGRESS_INTERVAL_SECONDS
|
|
|
|
try:
|
|
rollouts = discover_codex_rollouts(
|
|
codex_home, on_progress=discovery_progress
|
|
)
|
|
print(
|
|
f"Also searching {len(rollouts)} Codex rollout(s) under "
|
|
f"{codex_home} "
|
|
f"(--codex; {args.codex_max_scan_seconds:g}s stop-loss)\n",
|
|
flush=True,
|
|
)
|
|
codex_matches = search_codex_rollouts(
|
|
rollouts,
|
|
args.keywords,
|
|
args.case_sensitive,
|
|
from_timestamp,
|
|
to_timestamp,
|
|
None if args.all_projects else args.project_path,
|
|
set(args.exclude_session),
|
|
not args.no_prefilter,
|
|
args.codex_max_scan_seconds,
|
|
started_at=scan_started,
|
|
)
|
|
except CodexScanBudgetExceeded as error:
|
|
total_text = "?" if error.total is None else str(error.total)
|
|
print(
|
|
f"Stopped Codex scan after {error.elapsed:.1f}s at "
|
|
f"{error.scanned}/{total_text} rollouts ({error.stage}). "
|
|
"No partial result is being presented as complete. Narrow by "
|
|
"project/date, use locate-codex for a session UUID, or pass "
|
|
"--codex-max-scan-seconds 0 only after explicitly accepting an "
|
|
"unbounded scan.",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(2)
|
|
except CodexScanIncomplete as error:
|
|
sample = ", ".join(str(path) for path in error.paths[:3])
|
|
extra = "" if len(error.paths) <= 3 else f" (+{len(error.paths) - 3} more)"
|
|
print(
|
|
"Codex scan stopped because rollout input was incomplete; "
|
|
"no partial result is being presented as complete. "
|
|
f"Unreadable/malformed: {sample}{extra}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(2)
|
|
|
|
kimi_matches: List[Dict[str, Any]] = []
|
|
kimi_home: Optional[Path] = None
|
|
if args.kimi:
|
|
kimi_home = _kimi_home_for(args)
|
|
kimi_wires = discover_kimi_wires(kimi_home)
|
|
print(
|
|
f"Also searching {len(kimi_wires)} Kimi CLI wire(s) under "
|
|
f"{kimi_home} (--kimi)\n"
|
|
)
|
|
kimi_matches = search_kimi_wires(
|
|
kimi_wires,
|
|
args.keywords,
|
|
args.case_sensitive,
|
|
from_timestamp,
|
|
to_timestamp,
|
|
None if args.all_projects else args.project_path,
|
|
set(args.exclude_session),
|
|
not args.no_prefilter,
|
|
)
|
|
|
|
if matches:
|
|
print(f"Found {len(matches)} session(s) with matches:\n")
|
|
project_by_path = {
|
|
ref["path"]: ref.get("project") for ref in sessions
|
|
}
|
|
for info in matches:
|
|
session = info["path"]
|
|
labels = _source_summary(info["sources"])
|
|
matching_labels = ", ".join(sorted(info["matching_source_labels"]))
|
|
print(f"📄 {session.name}")
|
|
project = project_by_path.get(session)
|
|
if project:
|
|
print(f" Project: {project}")
|
|
print(
|
|
" Internal range: "
|
|
f"{_format_range(info['created_at'], info['updated_at'])}"
|
|
)
|
|
print(
|
|
" Match range: "
|
|
f"{_format_range(info['match_created_at'], info['match_updated_at'])}"
|
|
)
|
|
print(f" Session sources: {labels}")
|
|
print(f" Match sources: {matching_labels}")
|
|
print(f" Total mentions: {info['total_mentions']}")
|
|
print(
|
|
f" Keywords: {', '.join(f'{k}({v})' for k, v in info['keyword_counts'].items())}"
|
|
)
|
|
print(f" Match fields: {', '.join(info['match_sources'])}")
|
|
if info["excluded_untimed_records"]:
|
|
print(
|
|
" Date-filter note: excluded "
|
|
f"{info['excluded_untimed_records']} record(s) without an "
|
|
"internal timestamp; file mtime was not used."
|
|
)
|
|
print(f" Path: {session}")
|
|
if len(info["matching_copies"]) > 1:
|
|
print(" Other matching copies:")
|
|
for copy in info["matching_copies"]:
|
|
if copy["path"] == session:
|
|
continue
|
|
print(
|
|
f" - [{copy['source'].display_label}] {copy['path']}"
|
|
)
|
|
print()
|
|
|
|
if args.codex:
|
|
if codex_matches:
|
|
print(
|
|
f"Codex rollout matches (home: {codex_home}):\n"
|
|
)
|
|
for info in codex_matches:
|
|
print(f"📦 {info['path'].name}")
|
|
print(f" Session: {info['session_id']}")
|
|
if info.get("cwd"):
|
|
print(f" cwd: {info['cwd']}")
|
|
print(
|
|
" Internal range: "
|
|
f"{_format_range(info['created_at'], info['updated_at'])}"
|
|
)
|
|
print(
|
|
" Match range: "
|
|
f"{_format_range(info['match_created_at'], info['match_updated_at'])}"
|
|
)
|
|
print(f" Total mentions: {info['total_mentions']}")
|
|
print(
|
|
f" Keywords: {', '.join(f'{k}({v})' for k, v in info['keyword_counts'].items())}"
|
|
)
|
|
print(f" Match fields: {', '.join(info['match_sources'])}")
|
|
if info["excluded_untimed_records"]:
|
|
print(
|
|
" Date-filter note: excluded "
|
|
f"{info['excluded_untimed_records']} record(s) without an "
|
|
"internal timestamp; file mtime was not used."
|
|
)
|
|
print(f" Path: {info['path']}")
|
|
print()
|
|
else:
|
|
print(f"No Codex rollout matches (home: {codex_home}).")
|
|
|
|
if args.kimi:
|
|
if kimi_matches:
|
|
print(f"Kimi CLI session matches (home: {kimi_home}):\n")
|
|
for info in kimi_matches:
|
|
print(f"🌙 {info['path'].name}")
|
|
print(f" Session: {info['session_id']}")
|
|
if info.get("title"):
|
|
print(f" Title: {info['title']}")
|
|
if info.get("cwd"):
|
|
print(f" cwd: {info['cwd']}")
|
|
print(
|
|
" Internal range: "
|
|
f"{_format_range(info['created_at'], info['updated_at'])}"
|
|
)
|
|
print(
|
|
" Match range: "
|
|
f"{_format_range(info['match_created_at'], info['match_updated_at'])}"
|
|
)
|
|
print(f" Total mentions: {info['total_mentions']}")
|
|
print(
|
|
f" Keywords: {', '.join(f'{k}({v})' for k, v in info['keyword_counts'].items())}"
|
|
)
|
|
print(f" Match fields: {', '.join(info['match_sources'])}")
|
|
if info["excluded_untimed_records"]:
|
|
print(
|
|
" Date-filter note: excluded "
|
|
f"{info['excluded_untimed_records']} record(s) without an "
|
|
"internal timestamp; file mtime was not used."
|
|
)
|
|
print(f" Path: {info['path']}")
|
|
print()
|
|
else:
|
|
print(f"No Kimi CLI session matches (home: {kimi_home}).")
|
|
|
|
if not matches and not codex_matches and not kimi_matches:
|
|
print("No matches found.")
|
|
_print_search_widening_hint(args)
|
|
sys.exit(0)
|
|
|
|
elif args.command == "stats":
|
|
if not args.session_file.exists():
|
|
print(f"Error: Session file not found: {args.session_file}")
|
|
sys.exit(1)
|
|
|
|
print(f"Analyzing session: {args.session_file}\n")
|
|
|
|
analyzer = SessionAnalyzer(homes=[])
|
|
stats = analyzer.get_session_stats(args.session_file)
|
|
|
|
print("=" * 60)
|
|
print("Session Statistics")
|
|
print("=" * 60)
|
|
print("\nMessages:")
|
|
print(f" Total lines: {stats['total_lines']:,}")
|
|
print(f" User messages: {stats['user_messages']}")
|
|
print(f" Assistant messages: {stats['assistant_messages']}")
|
|
|
|
print("\nTool Usage:")
|
|
print(f" Write calls: {stats['write_calls']}")
|
|
print(f" Edit calls: {stats['edit_calls']}")
|
|
print(f" Read calls: {stats['read_calls']}")
|
|
print(f" Bash calls: {stats['bash_calls']}")
|
|
|
|
if stats["tool_uses"]:
|
|
print("\n All tools:")
|
|
for tool, count in sorted(
|
|
stats["tool_uses"].items(), key=lambda x: x[1], reverse=True
|
|
):
|
|
print(f" {tool}: {count}")
|
|
|
|
if args.show_files and stats["file_operations"]:
|
|
print(f"\nFile Operations ({len(stats['file_operations'])}):")
|
|
# Group by file
|
|
files = defaultdict(list)
|
|
for op, path in stats["file_operations"]:
|
|
files[path].append(op)
|
|
|
|
# Limit to 20 files to prevent terminal flooding on large sessions
|
|
for file_path, ops in list(files.items())[:20]:
|
|
filename = Path(file_path).name
|
|
op_summary = ", ".join(
|
|
f"{op}({ops.count(op)})" for op in set(ops)
|
|
)
|
|
print(f" {filename}")
|
|
print(f" Operations: {op_summary}")
|
|
print(f" Path: {file_path}")
|
|
|
|
print()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|