elliot-stack 1.0.29 → 1.0.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +5 -0
- package/bin/install.cjs +981 -950
- package/hooks/repo-search-nudge.js +32 -32
- package/package.json +1 -1
- package/skills/estack-active-learning-tutor/SKILL.md +339 -339
- package/skills/estack-better-title/SKILL.md +64 -64
- package/skills/estack-better-title/scripts/rename.sh +55 -55
- package/skills/estack-chris-voss/SKILL.md +80 -80
- package/skills/estack-chris-voss/references/elliot-notes.md +120 -120
- package/skills/estack-chris-voss/references/voss-principles.md +210 -210
- package/skills/estack-customer-discovery/SKILL.md +60 -60
- package/skills/estack-flight-planner/SKILL.md +332 -332
- package/skills/estack-flight-planner/references/config_schema.md +156 -156
- package/skills/estack-flight-planner/references/flight_history_schema.md +97 -97
- package/skills/estack-flight-planner/references/shuttle_schedules.md +98 -98
- package/skills/estack-flight-planner/scripts/check_setup.sh +89 -89
- package/skills/estack-flight-planner/scripts/fetch_flights.py +99 -99
- package/skills/estack-flight-planner/scripts/filter_flights.py +265 -265
- package/skills/estack-flight-planner/scripts/pair_shuttles.py +173 -173
- package/skills/estack-github-issue-tracker/SKILL.md +322 -322
- package/skills/estack-github-issue-tracker/bin/tracker-tools.cjs +1358 -1358
- package/skills/estack-github-issue-tracker/references/gh-cli-patterns.md +124 -124
- package/skills/estack-github-issue-tracker/references/result-file-schema.md +156 -156
- package/skills/estack-github-issue-tracker/references/tracker-schema.md +96 -96
- package/skills/estack-github-issue-tracker/tracker-template.md +58 -58
- package/skills/estack-leadership-coach/SKILL.md +235 -0
- package/skills/estack-leadership-coach/adding-references.md +280 -0
- package/skills/estack-leadership-coach/frameworks/delegation/flows/post-mortem.md +120 -0
- package/skills/estack-leadership-coach/frameworks/delegation/flows/pre-delegation.md +138 -0
- package/skills/estack-leadership-coach/frameworks/delegation/phases/1-intake.md +145 -0
- package/skills/estack-leadership-coach/frameworks/delegation/phases/2-trm-assessment.md +119 -0
- package/skills/estack-leadership-coach/frameworks/delegation/phases/3-enrollment.md +132 -0
- package/skills/estack-leadership-coach/frameworks/delegation/phases/4-build-brief.md +171 -0
- package/skills/estack-leadership-coach/frameworks/delegation/phases/5-monitoring.md +134 -0
- package/skills/estack-leadership-coach/frameworks/delegation/phases/6-reverse-delegation.md +118 -0
- package/skills/estack-leadership-coach/frameworks/delegation/phases/7-diagnose.md +200 -0
- package/skills/estack-leadership-coach/references/.source-files/deci-ryan_self-determination-theory__deci-olafsen-ryan-2017-self-determination-theory-in-work-organizations.md +1881 -0
- package/skills/estack-leadership-coach/references/.source-files/deci-ryan_self-determination-theory__gagne-deci-2005-self-determination-theory-and-work-motivation.md +2058 -0
- package/skills/estack-leadership-coach/references/.source-files/deci-ryan_self-determination-theory__selfdeterminationtheory-org-theory-overview-page.md +61 -0
- package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-3-key-insights-into-the-global-workplace-2024.md +57 -0
- package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-managers-account-for-70-percent-of-variance-in-employee-engagement-2015.md +40 -0
- package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-state-of-the-global-workplace-2026-global-data-summary.md +73 -0
- package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-state-of-the-global-workplace-2026-report-landing.md +42 -0
- package/skills/estack-leadership-coach/references/.source-files/hormozi-leila_4-stages__leila-hormozi-the-art-of-delegation-blog-post.md +91 -0
- package/skills/estack-leadership-coach/references/.source-files/oncken-wass_monkeys-hbr-1974__oncken-wass-management-time-whos-got-the-monkey-hbr-classic-1974.md +969 -0
- package/skills/estack-leadership-coach/references/.source-files/sanchez_main-street-millionaire__codie-sanchez-afford-anything-podcast-ep-565-show-notes.md +89 -0
- package/skills/estack-leadership-coach/references/.source-files/sullivan_who-not-how__dan-sullivan-impact-filter-tool-and-guide-booklet.md +565 -0
- package/skills/estack-leadership-coach/references/.source-files/van-edwards_cues__vanessa-van-edwards-lewis-howes-school-of-greatness-ep-1231-show-notes.md +122 -0
- package/skills/estack-leadership-coach/references/.source-files/van-edwards_cues__vanessa-van-edwards-roger-dooley-cues-interview.md +194 -0
- package/skills/estack-leadership-coach/references/deci-ryan_self-determination-theory.md +166 -0
- package/skills/estack-leadership-coach/references/doerr_measure-what-matters.md +154 -0
- package/skills/estack-leadership-coach/references/ferriss_4hww.md +189 -0
- package/skills/estack-leadership-coach/references/gallup_engagement-research.md +105 -0
- package/skills/estack-leadership-coach/references/gerber_e-myth-revisited.md +118 -0
- package/skills/estack-leadership-coach/references/grove_high-output-management.md +95 -0
- package/skills/estack-leadership-coach/references/hormozi-alex_followthrough.md +152 -0
- package/skills/estack-leadership-coach/references/hormozi-leila_4-stages.md +146 -0
- package/skills/estack-leadership-coach/references/oncken-wass_monkeys-hbr-1974.md +128 -0
- package/skills/estack-leadership-coach/references/sanchez_main-street-millionaire.md +196 -0
- package/skills/estack-leadership-coach/references/sullivan_who-not-how.md +137 -0
- package/skills/estack-leadership-coach/references/van-edwards_cues.md +189 -0
- package/skills/estack-migrate-claude-session-history/SKILL.md +226 -0
- package/skills/estack-migrate-claude-session-history/references/path-encoding.md +55 -0
- package/skills/estack-migrate-claude-session-history/references/troubleshooting.md +96 -0
- package/skills/estack-migrate-claude-session-history/scripts/migrate-claude-history.js +1123 -0
- package/skills/estack-migrate-claude-session-history/scripts/test-append-note.js +48 -0
- package/skills/estack-migrate-claude-session-history/scripts/test-validate-migration.py +326 -0
- package/skills/estack-migrate-claude-session-history/scripts/validate-migration.py +493 -0
- package/skills/estack-pdf-to-md/SKILL.md +180 -0
- package/skills/estack-pdf-to-md/scripts/pdf_to_md.py +596 -0
- package/skills/estack-productivity-prioritization-coach/SKILL.md +124 -0
- package/skills/estack-productivity-prioritization-coach/sources/01-tony-robbins-rpm.md +39 -0
- package/skills/estack-productivity-prioritization-coach/sources/02-justin-sung-task-prioritization.md +34 -0
- package/skills/estack-prompt-builder-coach/SKILL.md +81 -81
- package/skills/estack-prompt-builder-coach/definition-of-done-generator.md +42 -42
- package/skills/estack-prompt-builder-coach/prompt-builder.md +37 -37
- package/skills/estack-prompt-builder-coach/task-shaper.md +36 -36
- package/skills/estack-prompt-builder-coach/vague-ask-auditor.md +37 -37
- package/skills/estack-read-claude-session-history/SKILL.md +204 -204
- package/skills/estack-read-claude-session-history/references/jsonl-schema.md +126 -126
- package/skills/estack-read-claude-session-history/references/modes.md +423 -423
- package/skills/estack-read-claude-session-history/references/recipes.md +271 -271
- package/skills/estack-read-claude-session-history/scripts/lib/__init__.py +1 -1
- package/skills/estack-read-claude-session-history/scripts/lib/parser.py +460 -460
- package/skills/estack-read-claude-session-history/scripts/lib/paths.py +234 -234
- package/skills/estack-read-claude-session-history/scripts/lib/search.py +179 -179
- package/skills/estack-read-claude-session-history/scripts/lib/subagents.py +88 -88
- package/skills/estack-read-claude-session-history/scripts/lib/tools.py +144 -144
- package/skills/estack-read-claude-session-history/scripts/read_transcript.py +1776 -1776
- package/skills/estack-read-claude-session-history/scripts/tests/conftest.py +40 -40
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/README.md +20 -20
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/all-noise.jsonl +4 -4
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/basic-session.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-gaps.jsonl +9 -9
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-noise.jsonl +7 -7
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-a.jsonl +3 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-b.jsonl +3 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-waiting.jsonl +5 -5
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/interrupted.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/multi-compact.jsonl +8 -8
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/pending-user.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta/subagents/agent-aaa.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.meta.json +1 -1
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent.jsonl +4 -4
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/time-spread.jsonl +6 -6
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/timeline-day-test.jsonl +5 -5
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-zoo.jsonl +10 -10
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/truncated.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/unicode.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-advisor.jsonl +3 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-compact.jsonl +5 -5
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-thinking.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/test_backup_roots.py +56 -56
- package/skills/estack-read-claude-session-history/scripts/tests/test_engagement.py +239 -239
- package/skills/estack-read-claude-session-history/scripts/tests/test_json_format.py +201 -201
- package/skills/estack-read-claude-session-history/scripts/tests/test_modes.py +199 -199
- package/skills/estack-read-claude-session-history/scripts/tests/test_parser.py +195 -195
- package/skills/estack-read-claude-session-history/scripts/tests/test_paths.py +133 -133
- package/skills/estack-read-claude-session-history/scripts/tests/test_search.py +78 -78
- package/skills/estack-read-claude-session-history/scripts/tests/test_subagents.py +43 -43
- package/skills/estack-read-claude-session-history/scripts/tests/test_timeline.py +179 -179
- package/skills/estack-read-claude-session-history/scripts/tests/test_timezone_and_project.py +212 -212
- package/skills/estack-read-claude-session-history/scripts/tests/test_tools.py +80 -80
- package/skills/estack-repo-search/SKILL.md +65 -65
- package/skills/estack-vscode-file-recovery/SKILL.md +188 -0
|
@@ -1,1776 +1,1776 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Extract signal from Claude Code session transcripts.
|
|
3
|
-
|
|
4
|
-
See SKILL.md for the full mode reference. Legacy flags from the v1 script
|
|
5
|
-
(``--list``, ``--list-subagents``, ``--mode {last,advisor,pre-compact,dump,search,debug}``)
|
|
6
|
-
remain byte-compatible.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
import argparse
|
|
12
|
-
import json
|
|
13
|
-
import re
|
|
14
|
-
import sys
|
|
15
|
-
from datetime import datetime, timedelta
|
|
16
|
-
from pathlib import Path
|
|
17
|
-
|
|
18
|
-
# Force UTF-8 output on Windows for emoji and non-ASCII content.
|
|
19
|
-
if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
|
|
20
|
-
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
21
|
-
if sys.stderr.encoding and sys.stderr.encoding.lower() != "utf-8":
|
|
22
|
-
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
23
|
-
|
|
24
|
-
# Make `from lib.* import …` work when run as a script.
|
|
25
|
-
_THIS_DIR = Path(__file__).resolve().parent
|
|
26
|
-
if str(_THIS_DIR) not in sys.path:
|
|
27
|
-
sys.path.insert(0, str(_THIS_DIR))
|
|
28
|
-
|
|
29
|
-
from lib import paths as P # noqa: E402
|
|
30
|
-
from lib import parser as PR # noqa: E402
|
|
31
|
-
from lib import tools as T # noqa: E402
|
|
32
|
-
from lib import search as S # noqa: E402
|
|
33
|
-
from lib import subagents as SA # noqa: E402
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
# ─────────────────────────────────────────────────────────────────────────────
|
|
37
|
-
# Legacy mode implementations (kept byte-identical to v1 for backwards-compat)
|
|
38
|
-
# ─────────────────────────────────────────────────────────────────────────────
|
|
39
|
-
|
|
40
|
-
def mode_last(lines, n=5):
|
|
41
|
-
messages = PR.get_messages(lines)
|
|
42
|
-
assistant_msgs = [m for m in messages if m["role"] == "assistant" and m["texts"]]
|
|
43
|
-
recent = assistant_msgs[-n:]
|
|
44
|
-
output = []
|
|
45
|
-
for i, m in enumerate(recent, 1):
|
|
46
|
-
output.append(f"=== Assistant message -{len(recent) - i + 1} from end ===")
|
|
47
|
-
output.append("\n".join(m["texts"]))
|
|
48
|
-
return "\n\n".join(output) if output else "No assistant messages found."
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def mode_advisor(lines):
|
|
52
|
-
results = []
|
|
53
|
-
for obj in lines:
|
|
54
|
-
if obj.get("type") in PR.NOISE_TYPES:
|
|
55
|
-
continue
|
|
56
|
-
msg = obj.get("message", {})
|
|
57
|
-
if not isinstance(msg.get("content"), list):
|
|
58
|
-
continue
|
|
59
|
-
for block in msg["content"]:
|
|
60
|
-
if block.get("type") == "advisor_tool_result":
|
|
61
|
-
inner = block.get("content", {})
|
|
62
|
-
if isinstance(inner, dict) and inner.get("text"):
|
|
63
|
-
results.append(inner["text"])
|
|
64
|
-
if not results:
|
|
65
|
-
return "No advisor calls found in this transcript."
|
|
66
|
-
output = []
|
|
67
|
-
for i, r in enumerate(results, 1):
|
|
68
|
-
output.append(f"=== Advisor response #{i} ===\n{r}")
|
|
69
|
-
return "\n\n".join(output)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def mode_pre_compact(lines, window=40):
|
|
73
|
-
messages = PR.get_messages(lines)
|
|
74
|
-
compact_idx = None
|
|
75
|
-
for i, m in enumerate(messages):
|
|
76
|
-
if m["is_compact"]:
|
|
77
|
-
compact_idx = i
|
|
78
|
-
if compact_idx is None:
|
|
79
|
-
return (
|
|
80
|
-
"No /compact found in this transcript. Showing last messages instead.\n\n"
|
|
81
|
-
+ mode_last(lines, 10)
|
|
82
|
-
)
|
|
83
|
-
start = max(0, compact_idx - window)
|
|
84
|
-
pre = messages[start:compact_idx]
|
|
85
|
-
output = [f"--- Pre-compact content ({len(pre)} exchanges before /compact) ---\n"]
|
|
86
|
-
for m in pre:
|
|
87
|
-
if m["texts"]:
|
|
88
|
-
role_label = "USER" if m["role"] == "user" else "ASSISTANT"
|
|
89
|
-
output.append(f"[{role_label}]\n" + "\n".join(m["texts"]))
|
|
90
|
-
return "\n\n".join(output) if output else "No content found before compact."
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def mode_dump(lines, limit=80):
|
|
94
|
-
messages = PR.get_messages(lines)
|
|
95
|
-
with_text = [m for m in messages if m["texts"]]
|
|
96
|
-
recent = with_text[-limit:]
|
|
97
|
-
output = [f"--- Conversation dump (last {len(recent)} messages with text) ---\n"]
|
|
98
|
-
for m in recent:
|
|
99
|
-
if m["is_compact"]:
|
|
100
|
-
output.append("\n--- /COMPACT ---\n")
|
|
101
|
-
continue
|
|
102
|
-
role_label = "USER" if m["role"] == "user" else "ASSISTANT"
|
|
103
|
-
text = "\n".join(m["texts"])
|
|
104
|
-
if len(text) > 1500:
|
|
105
|
-
text = text[:1500] + "\n[...truncated...]"
|
|
106
|
-
output.append(f"[{role_label}]\n{text}")
|
|
107
|
-
return "\n\n".join(output)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def mode_search_legacy(lines, query: str):
|
|
111
|
-
"""Legacy single-file search: assistant text only, case-insensitive."""
|
|
112
|
-
messages = PR.get_messages(lines)
|
|
113
|
-
results = []
|
|
114
|
-
q = query.lower()
|
|
115
|
-
for m in messages:
|
|
116
|
-
if m["role"] == "assistant":
|
|
117
|
-
combined = " ".join(m["texts"])
|
|
118
|
-
if q in combined.lower():
|
|
119
|
-
results.append(combined)
|
|
120
|
-
if not results:
|
|
121
|
-
return None
|
|
122
|
-
output = [f"=== {len(results)} match(es) for '{query}' ===\n"]
|
|
123
|
-
for i, r in enumerate(results, 1):
|
|
124
|
-
output.append(f"--- Match #{i} ---\n{r[:1500]}")
|
|
125
|
-
return "\n\n".join(output)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def mode_debug(lines):
|
|
129
|
-
output = []
|
|
130
|
-
type_counts: dict[str, int] = {}
|
|
131
|
-
for obj in lines:
|
|
132
|
-
t = obj.get("type", "<missing>")
|
|
133
|
-
type_counts[t] = type_counts.get(t, 0) + 1
|
|
134
|
-
output.append("=== Entry type distribution ===")
|
|
135
|
-
for t, count in sorted(type_counts.items(), key=lambda x: -x[1]):
|
|
136
|
-
marker = (
|
|
137
|
-
" [NOISE - skipped]" if t in PR.NOISE_TYPES
|
|
138
|
-
else " [SIGNAL]" if t in ("user", "assistant") else ""
|
|
139
|
-
)
|
|
140
|
-
output.append(f" {count:4d} {t}{marker}")
|
|
141
|
-
|
|
142
|
-
block_type_counts: dict[str, int] = {}
|
|
143
|
-
signal_entries = [o for o in lines if o.get("type") not in PR.NOISE_TYPES]
|
|
144
|
-
for obj in signal_entries:
|
|
145
|
-
content = obj.get("message", {}).get("content", [])
|
|
146
|
-
if isinstance(content, list):
|
|
147
|
-
for block in content:
|
|
148
|
-
if isinstance(block, dict):
|
|
149
|
-
bt = block.get("type", "<missing>")
|
|
150
|
-
block_type_counts[bt] = block_type_counts.get(bt, 0) + 1
|
|
151
|
-
|
|
152
|
-
output.append("\n=== Content block types (across all signal messages) ===")
|
|
153
|
-
if block_type_counts:
|
|
154
|
-
for bt, count in sorted(block_type_counts.items(), key=lambda x: -x[1]):
|
|
155
|
-
note = ""
|
|
156
|
-
if bt == "advisor_tool_result":
|
|
157
|
-
note = " ← advisor responses live here"
|
|
158
|
-
elif bt == "text":
|
|
159
|
-
note = " ← assistant/user text"
|
|
160
|
-
elif bt == "tool_use":
|
|
161
|
-
note = " ← regular tool calls"
|
|
162
|
-
elif bt == "server_tool_use":
|
|
163
|
-
note = " ← server-side tools (advisor calls)"
|
|
164
|
-
output.append(f" {count:4d} {bt}{note}")
|
|
165
|
-
else:
|
|
166
|
-
output.append(" (no block-structured content found — content may be plain strings)")
|
|
167
|
-
|
|
168
|
-
output.append("\n=== Advisor result probe ===")
|
|
169
|
-
advisor_found = []
|
|
170
|
-
for obj in lines:
|
|
171
|
-
msg = obj.get("message", {})
|
|
172
|
-
content = msg.get("content", [])
|
|
173
|
-
if not isinstance(content, list):
|
|
174
|
-
continue
|
|
175
|
-
for block in content:
|
|
176
|
-
if not isinstance(block, dict):
|
|
177
|
-
continue
|
|
178
|
-
if block.get("type") == "advisor_tool_result":
|
|
179
|
-
inner = block.get("content", {})
|
|
180
|
-
has_text = isinstance(inner, dict) and bool(inner.get("text"))
|
|
181
|
-
advisor_found.append({
|
|
182
|
-
"outer_keys": list(block.keys()),
|
|
183
|
-
"inner_type": type(inner).__name__,
|
|
184
|
-
"inner_keys": list(inner.keys()) if isinstance(inner, dict) else "N/A",
|
|
185
|
-
"has_text": has_text,
|
|
186
|
-
})
|
|
187
|
-
if advisor_found:
|
|
188
|
-
output.append(f" Found {len(advisor_found)} advisor_tool_result block(s)")
|
|
189
|
-
for i, a in enumerate(advisor_found[:3], 1):
|
|
190
|
-
output.append(
|
|
191
|
-
f" Block #{i}: outer_keys={a['outer_keys']}, "
|
|
192
|
-
f"inner={a['inner_type']}({a['inner_keys']}), has_text={a['has_text']}"
|
|
193
|
-
)
|
|
194
|
-
else:
|
|
195
|
-
output.append(" No advisor_tool_result blocks found.")
|
|
196
|
-
output.append(" → If you expected advisor output, the block type name may have changed.")
|
|
197
|
-
|
|
198
|
-
output.append("\n=== Compact marker probe ===")
|
|
199
|
-
compact_hits = []
|
|
200
|
-
for obj in lines:
|
|
201
|
-
msg = obj.get("message", {})
|
|
202
|
-
if msg.get("role") != "user":
|
|
203
|
-
continue
|
|
204
|
-
content = msg.get("content", "")
|
|
205
|
-
text = content if isinstance(content, str) else " ".join(
|
|
206
|
-
b.get("text", "") for b in content
|
|
207
|
-
if isinstance(b, dict) and b.get("type") == "text"
|
|
208
|
-
)
|
|
209
|
-
if PR.COMPACT_MARKER in text:
|
|
210
|
-
compact_hits.append(obj.get("type", "?"))
|
|
211
|
-
if compact_hits:
|
|
212
|
-
output.append(f" Found {len(compact_hits)} compact marker(s) in entry type(s): {compact_hits}")
|
|
213
|
-
else:
|
|
214
|
-
output.append(" No compact markers found in this transcript.")
|
|
215
|
-
|
|
216
|
-
output.append("\n=== Sample assistant messages (first 3 with text) ===")
|
|
217
|
-
messages = PR.get_messages(lines)
|
|
218
|
-
samples = [m for m in messages if m["role"] == "assistant" and m["texts"]][:3]
|
|
219
|
-
if samples:
|
|
220
|
-
for i, m in enumerate(samples, 1):
|
|
221
|
-
preview = m["texts"][0][:200].replace("\n", " ")
|
|
222
|
-
output.append(
|
|
223
|
-
f" [{i}] \"{preview}{'...' if len(m['texts'][0]) > 200 else ''}\""
|
|
224
|
-
)
|
|
225
|
-
else:
|
|
226
|
-
output.append(" No assistant messages with text found.")
|
|
227
|
-
output.append(" → Check that get_messages() is correctly identifying signal entries.")
|
|
228
|
-
|
|
229
|
-
return "\n".join(output)
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
# ─────────────────────────────────────────────────────────────────────────────
|
|
233
|
-
# New v2 modes
|
|
234
|
-
# ─────────────────────────────────────────────────────────────────────────────
|
|
235
|
-
|
|
236
|
-
def _fmt_mtime(mtime: float) -> str:
|
|
237
|
-
return PR.epoch_to_display(mtime).strftime("%Y-%m-%d %H:%M")
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
def _print_json(data) -> None:
|
|
241
|
-
print(json.dumps(data, indent=2, ensure_ascii=False, default=str))
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
def _emit(result) -> None:
|
|
245
|
-
"""Print a mode result: strings as-is, anything else as JSON."""
|
|
246
|
-
if isinstance(result, str):
|
|
247
|
-
print(result)
|
|
248
|
-
else:
|
|
249
|
-
_print_json(result)
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
def _summary_json(s: dict) -> dict:
|
|
253
|
-
"""JSON-safe copy of a session_summary dict."""
|
|
254
|
-
out = dict(s)
|
|
255
|
-
out["path"] = str(s.get("path", ""))
|
|
256
|
-
out["files_touched"] = [str(p) for p in s.get("files_touched", [])]
|
|
257
|
-
if s.get("mtime"):
|
|
258
|
-
out["mtime_iso"] = _fmt_mtime(s["mtime"])
|
|
259
|
-
return out
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
def _ts_iso(raw) -> str | None:
|
|
263
|
-
"""Raw JSONL timestamp (UTC) → display-timezone ISO string."""
|
|
264
|
-
ts = PR._parse_timestamp(raw)
|
|
265
|
-
if ts is not None:
|
|
266
|
-
return ts.isoformat()
|
|
267
|
-
return raw if isinstance(raw, str) else None
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
def _messages_json(messages: list[dict]) -> list[dict]:
|
|
271
|
-
return [
|
|
272
|
-
{
|
|
273
|
-
"role": m["role"],
|
|
274
|
-
"timestamp": _ts_iso(m.get("timestamp")),
|
|
275
|
-
"is_compact": m["is_compact"],
|
|
276
|
-
"text": "\n".join(m["texts"]),
|
|
277
|
-
}
|
|
278
|
-
for m in messages
|
|
279
|
-
if m["texts"] or m["is_compact"]
|
|
280
|
-
]
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
_STATUS_GLYPH = {
|
|
284
|
-
"clean": "✓", "interrupted": "!", "pending-user": "?", "active": "●",
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
def list_session_row(
|
|
289
|
-
summary: dict, show_project: bool = False, current_uuid: str | None = None
|
|
290
|
-
) -> str:
|
|
291
|
-
mtime = _fmt_mtime(summary["mtime"])
|
|
292
|
-
size_kb = summary["size"] / 1024
|
|
293
|
-
uuid_short = summary["uuid"][:8]
|
|
294
|
-
msg_n = summary.get("msg_count", 0)
|
|
295
|
-
flags = ""
|
|
296
|
-
if summary.get("has_compact"):
|
|
297
|
-
flags += "[C]"
|
|
298
|
-
if summary.get("has_subagents"):
|
|
299
|
-
flags += "[S]"
|
|
300
|
-
flags = flags or " "
|
|
301
|
-
status = _STATUS_GLYPH.get(summary.get("status", "clean"), "?")
|
|
302
|
-
marker = "[*]" if summary.get("is_current") else " "
|
|
303
|
-
proj = ""
|
|
304
|
-
if show_project:
|
|
305
|
-
proj = f" {summary.get('decoded_project', summary.get('cwd', ''))}"
|
|
306
|
-
title = summary.get("title") or summary.get("first_prompt", "")
|
|
307
|
-
title = title[:80]
|
|
308
|
-
return (
|
|
309
|
-
f"{marker} {mtime} {size_kb:6.0f}KB {uuid_short} msgs={msg_n:<4} "
|
|
310
|
-
f"{flags} {status}{proj} {title}"
|
|
311
|
-
)
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
def mode_list_legacy(cwd: str, root: Path) -> str:
|
|
315
|
-
"""Original v1 list output — preserved byte-identically."""
|
|
316
|
-
project_dir = P.find_project_dir(cwd, root)
|
|
317
|
-
files = P.list_transcripts(project_dir)
|
|
318
|
-
if not files:
|
|
319
|
-
return "No transcript files found."
|
|
320
|
-
out = [f"Transcripts for {cwd}:"]
|
|
321
|
-
for f in files:
|
|
322
|
-
size_kb = f.stat().st_size / 1024
|
|
323
|
-
mtime = _fmt_mtime(f.stat().st_mtime)
|
|
324
|
-
out.append(f" {mtime} {size_kb:6.0f}KB {f.name}")
|
|
325
|
-
return "\n".join(out)
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
def _scoped_project_dirs(
|
|
329
|
-
root: Path,
|
|
330
|
-
cwd: str | None,
|
|
331
|
-
all_projects: bool,
|
|
332
|
-
project: str | None,
|
|
333
|
-
default_all: bool = False,
|
|
334
|
-
) -> list[Path] | None:
|
|
335
|
-
"""Resolve --cwd / --all-projects / --project into project directories.
|
|
336
|
-
|
|
337
|
-
--project wins (name filter across all projects under root); then --cwd;
|
|
338
|
-
then --all-projects (or default_all). Returns None when no scope was given.
|
|
339
|
-
"""
|
|
340
|
-
if project:
|
|
341
|
-
dirs = P.filter_projects(root, project)
|
|
342
|
-
if not dirs:
|
|
343
|
-
raise FileNotFoundError(f"No project directory matches --project {project!r}")
|
|
344
|
-
return dirs
|
|
345
|
-
if cwd:
|
|
346
|
-
return [P.find_project_dir(cwd, root)]
|
|
347
|
-
if all_projects or default_all:
|
|
348
|
-
return P.list_projects(root)
|
|
349
|
-
return None
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
def mode_list(
|
|
353
|
-
root: Path,
|
|
354
|
-
cwd: str | None,
|
|
355
|
-
all_projects: bool,
|
|
356
|
-
since: datetime | None,
|
|
357
|
-
until: datetime | None,
|
|
358
|
-
exclude_current: bool,
|
|
359
|
-
current_uuid: str | None,
|
|
360
|
-
project: str | None = None,
|
|
361
|
-
fmt: str = "text",
|
|
362
|
-
):
|
|
363
|
-
"""Enriched v2 list — columns: marker, mtime, size, uuid-short, msgs, flags, status, project, title."""
|
|
364
|
-
project_dirs = _scoped_project_dirs(root, cwd, all_projects, project)
|
|
365
|
-
if project_dirs is None:
|
|
366
|
-
return "--cwd, --project, or --all-projects required"
|
|
367
|
-
rows = []
|
|
368
|
-
for pd in project_dirs:
|
|
369
|
-
for f in P.list_transcripts(pd, since=since, until=until):
|
|
370
|
-
summary = PR.session_summary(f, current_session_id=current_uuid)
|
|
371
|
-
if exclude_current and summary.get("is_current"):
|
|
372
|
-
continue
|
|
373
|
-
rows.append(summary)
|
|
374
|
-
rows.sort(key=lambda s: s["mtime"], reverse=True)
|
|
375
|
-
if fmt == "json":
|
|
376
|
-
return [_summary_json(r) for r in rows]
|
|
377
|
-
if not rows:
|
|
378
|
-
return "No transcript files found."
|
|
379
|
-
show_proj = all_projects or bool(project) or len(project_dirs) > 1
|
|
380
|
-
return "\n".join(list_session_row(r, show_proj, current_uuid) for r in rows)
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
def mode_lookup(uuid_prefix: str, root: Path, fmt: str = "text") -> tuple[int, object]:
|
|
384
|
-
"""Resolve a UUID prefix to an absolute path. Returns (exit_code, output)."""
|
|
385
|
-
if not uuid_prefix:
|
|
386
|
-
return 1, ({"error": "--uuid required"} if fmt == "json" else "--uuid required")
|
|
387
|
-
matches: list[Path] = []
|
|
388
|
-
for pd in P.list_projects(root):
|
|
389
|
-
for f in P.list_transcripts(pd):
|
|
390
|
-
if f.stem.startswith(uuid_prefix):
|
|
391
|
-
matches.append(f)
|
|
392
|
-
if fmt == "json":
|
|
393
|
-
code = 0 if len(matches) == 1 else (1 if not matches else 2)
|
|
394
|
-
return code, {
|
|
395
|
-
"prefix": uuid_prefix,
|
|
396
|
-
"path": str(matches[0]) if len(matches) == 1 else None,
|
|
397
|
-
"matches": [str(m) for m in matches],
|
|
398
|
-
}
|
|
399
|
-
if not matches:
|
|
400
|
-
return 1, f"No session found with UUID prefix: {uuid_prefix}"
|
|
401
|
-
if len(matches) > 1:
|
|
402
|
-
return 2, (
|
|
403
|
-
f"Ambiguous prefix {uuid_prefix!r} matches {len(matches)} sessions:\n"
|
|
404
|
-
+ "\n".join(str(m) for m in matches)
|
|
405
|
-
)
|
|
406
|
-
return 0, str(matches[0])
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
def mode_find(
|
|
410
|
-
root: Path,
|
|
411
|
-
title_q: str | None,
|
|
412
|
-
first_prompt_q: str | None,
|
|
413
|
-
current_uuid: str | None,
|
|
414
|
-
project: str | None = None,
|
|
415
|
-
fmt: str = "text",
|
|
416
|
-
):
|
|
417
|
-
"""Search session metadata by title or first prompt."""
|
|
418
|
-
if not (title_q or first_prompt_q):
|
|
419
|
-
return "--title or --first-prompt required"
|
|
420
|
-
project_dirs = _scoped_project_dirs(root, None, False, project, default_all=True)
|
|
421
|
-
rows = []
|
|
422
|
-
for pd in project_dirs:
|
|
423
|
-
for f in P.list_transcripts(pd):
|
|
424
|
-
summary = PR.session_summary(f, current_session_id=current_uuid)
|
|
425
|
-
hit = False
|
|
426
|
-
if title_q and title_q.lower() in (summary.get("title", "") or "").lower():
|
|
427
|
-
hit = True
|
|
428
|
-
if first_prompt_q and first_prompt_q.lower() in (summary.get("first_prompt", "") or "").lower():
|
|
429
|
-
hit = True
|
|
430
|
-
if hit:
|
|
431
|
-
rows.append(summary)
|
|
432
|
-
rows.sort(key=lambda s: s["mtime"], reverse=True)
|
|
433
|
-
if fmt == "json":
|
|
434
|
-
return [_summary_json(r) for r in rows]
|
|
435
|
-
if not rows:
|
|
436
|
-
return "No sessions matched."
|
|
437
|
-
return "\n".join(list_session_row(r, show_project=True) for r in rows)
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
def mode_resume_cmd(uuid_prefix: str, root: Path, fmt: str = "text") -> tuple[int, object]:
|
|
441
|
-
"""Generate `cd <cwd>; claude --resume <uuid>` for a UUID prefix."""
|
|
442
|
-
code, out = mode_lookup(uuid_prefix, root)
|
|
443
|
-
if code != 0:
|
|
444
|
-
if fmt == "json":
|
|
445
|
-
return code, {"error": out}
|
|
446
|
-
return code, out
|
|
447
|
-
path = Path(out)
|
|
448
|
-
encoded = path.parent.name
|
|
449
|
-
# Best-effort decode for cwd guess: we have the encoded form, the raw cwd
|
|
450
|
-
# cannot be unambiguously recovered, so emit a comment with the encoded name.
|
|
451
|
-
decoded = P.decode_project_name(encoded)
|
|
452
|
-
if fmt == "json":
|
|
453
|
-
return 0, {
|
|
454
|
-
"uuid": path.stem,
|
|
455
|
-
"path": str(path),
|
|
456
|
-
"project": decoded,
|
|
457
|
-
"encoded": encoded,
|
|
458
|
-
"command": f'cd "<original cwd>"; claude --resume {path.stem}',
|
|
459
|
-
}
|
|
460
|
-
return 0, (
|
|
461
|
-
f'# project: {decoded}\n'
|
|
462
|
-
f'# encoded: {encoded}\n'
|
|
463
|
-
f'cd "<original cwd>"; claude --resume {path.stem}'
|
|
464
|
-
)
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
def mode_brief(
|
|
468
|
-
path: Path, include_subagents: bool, current_uuid: str | None, fmt: str = "text"
|
|
469
|
-
):
|
|
470
|
-
"""6-line single-session summary for fan-out triage."""
|
|
471
|
-
summary = PR.session_summary(path, current_session_id=current_uuid)
|
|
472
|
-
if fmt == "json":
|
|
473
|
-
data = _summary_json(summary)
|
|
474
|
-
if include_subagents and summary.get("subagent_count"):
|
|
475
|
-
data["subagent_finals"] = [
|
|
476
|
-
{"id": agent_id, "agentType": meta.get("agentType", "unknown"), "text": text}
|
|
477
|
-
for agent_id, meta, text in SA.agent_finals(path)
|
|
478
|
-
]
|
|
479
|
-
return data
|
|
480
|
-
if not summary.get("exists"):
|
|
481
|
-
return f"File not found: {path}"
|
|
482
|
-
star = " [*]" if summary["is_current"] else ""
|
|
483
|
-
status = summary["status"]
|
|
484
|
-
line1 = f"{summary['uuid']} · {summary['decoded_project']} · {_fmt_mtime(summary['mtime'])} · {status}{star}"
|
|
485
|
-
line2 = f"intent: {summary['first_prompt'] or '(no user prompts)'}"
|
|
486
|
-
line3 = f"last: {summary['last_assistant'] or '(no assistant messages)'}"
|
|
487
|
-
files = summary["files_touched"][:3]
|
|
488
|
-
files_str = ", ".join(str(f) for f in files) or "(none)"
|
|
489
|
-
line4 = f"edits: {summary['edit_count']} files — {files_str}"
|
|
490
|
-
tools = summary["tool_counts"]
|
|
491
|
-
tools_str = " ".join(f"{k}={v}" for k, v in sorted(tools.items(), key=lambda x: -x[1])) or "(none)"
|
|
492
|
-
line5 = f"tools: {tools_str}"
|
|
493
|
-
sa_types = summary["subagent_types"]
|
|
494
|
-
sa_types_str = ""
|
|
495
|
-
if sa_types:
|
|
496
|
-
sa_types_str = " [" + " ".join(f"{k}={v}" for k, v in sorted(sa_types.items(), key=lambda x: -x[1])) + "]"
|
|
497
|
-
line6 = f"subagents: {summary['subagent_count']} spawned{sa_types_str}"
|
|
498
|
-
out = "\n".join([line1, line2, line3, line4, line5, line6])
|
|
499
|
-
|
|
500
|
-
if include_subagents and summary["subagent_count"]:
|
|
501
|
-
out += "\n"
|
|
502
|
-
for agent_id, meta, text in SA.agent_finals(path):
|
|
503
|
-
atype = meta.get("agentType", "unknown")
|
|
504
|
-
short = agent_id.replace("agent-", "")[:8]
|
|
505
|
-
tail = (text[:1500] + "…") if len(text) > 1500 else text
|
|
506
|
-
out += f"\n[subagent {short} · {atype}]\n{tail}\n"
|
|
507
|
-
return out
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
def _tool_calls_json(path: Path, tool_filter: set[str] | None, include_input: bool) -> list[dict]:
|
|
511
|
-
lines = PR.parse_lines(path)
|
|
512
|
-
calls = T.extract_tool_calls(lines, tool_filter)
|
|
513
|
-
out = []
|
|
514
|
-
for c in calls:
|
|
515
|
-
ts = PR._parse_timestamp(c.get("timestamp"))
|
|
516
|
-
row = {
|
|
517
|
-
"timestamp": ts.isoformat() if ts else None,
|
|
518
|
-
"tool": c["name"],
|
|
519
|
-
"summary": T.format_tool_call(c),
|
|
520
|
-
}
|
|
521
|
-
if include_input:
|
|
522
|
-
row["input"] = c.get("input", {})
|
|
523
|
-
out.append(row)
|
|
524
|
-
return out
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
def mode_changelog(path: Path, fmt: str = "text"):
|
|
528
|
-
"""`HH:MM:SS TOOL one-line-summary`, day-grouped."""
|
|
529
|
-
if fmt == "json":
|
|
530
|
-
return _tool_calls_json(path, None, include_input=False)
|
|
531
|
-
lines = PR.parse_lines(path)
|
|
532
|
-
calls = T.extract_tool_calls(lines)
|
|
533
|
-
if not calls:
|
|
534
|
-
return "No tool calls found in this session."
|
|
535
|
-
out: list[str] = []
|
|
536
|
-
last_day = None
|
|
537
|
-
for c in calls:
|
|
538
|
-
ts = PR._parse_timestamp(c.get("timestamp"))
|
|
539
|
-
day = ts.strftime("%Y-%m-%d") if ts else "unknown-date"
|
|
540
|
-
time = ts.strftime("%H:%M:%S") if ts else "??:??:??"
|
|
541
|
-
if day != last_day:
|
|
542
|
-
out.append(f"\n=== {day} ===")
|
|
543
|
-
last_day = day
|
|
544
|
-
out.append(f" {time} {T.format_tool_call(c)}")
|
|
545
|
-
return "\n".join(out).lstrip("\n")
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
def mode_file_edits(path: Path, fmt: str = "text"):
|
|
549
|
-
lines = PR.parse_lines(path)
|
|
550
|
-
files = T.files_touched(lines)
|
|
551
|
-
if fmt == "json":
|
|
552
|
-
return [
|
|
553
|
-
{"path": str(fp), "ops": ops}
|
|
554
|
-
for fp, ops in sorted(files.items(), key=lambda x: str(x[0]))
|
|
555
|
-
]
|
|
556
|
-
if not files:
|
|
557
|
-
return "No file operations found."
|
|
558
|
-
out = []
|
|
559
|
-
for fp, ops in sorted(files.items(), key=lambda x: str(x[0])):
|
|
560
|
-
# Count repeats — ops is a list of operation names
|
|
561
|
-
n = len(ops)
|
|
562
|
-
suffix = f" ({n}x)" if n > 1 else ""
|
|
563
|
-
out.append(f"{fp}{suffix} [{', '.join(ops)}]")
|
|
564
|
-
return "\n".join(out)
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
def mode_tool_calls(path: Path, tool_filter: set[str] | None, fmt: str = "text"):
|
|
568
|
-
if fmt == "json":
|
|
569
|
-
return _tool_calls_json(path, tool_filter, include_input=True)
|
|
570
|
-
lines = PR.parse_lines(path)
|
|
571
|
-
calls = T.extract_tool_calls(lines, tool_filter)
|
|
572
|
-
if not calls:
|
|
573
|
-
return "No tool calls found."
|
|
574
|
-
out = []
|
|
575
|
-
for c in calls:
|
|
576
|
-
ts = PR._parse_timestamp(c.get("timestamp"))
|
|
577
|
-
time = ts.strftime("%Y-%m-%d %H:%M:%S") if ts else "?"
|
|
578
|
-
out.append(f"\n[{time}]\n {T.format_tool_call(c)}")
|
|
579
|
-
return "\n".join(out).lstrip("\n")
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
def mode_search_v2(
|
|
583
|
-
root: Path,
|
|
584
|
-
cwd: str | None,
|
|
585
|
-
all_projects: bool,
|
|
586
|
-
file_path: Path | None,
|
|
587
|
-
query: str,
|
|
588
|
-
role: str,
|
|
589
|
-
in_channel: str,
|
|
590
|
-
since: datetime | None,
|
|
591
|
-
until: datetime | None,
|
|
592
|
-
project: str | None = None,
|
|
593
|
-
fmt: str = "text",
|
|
594
|
-
exclude_current: bool = False,
|
|
595
|
-
current_uuid: str | None = None,
|
|
596
|
-
):
|
|
597
|
-
"""Cross-scope search with role/in-channel filters."""
|
|
598
|
-
matches: list = []
|
|
599
|
-
if file_path:
|
|
600
|
-
matches = S.search_session(file_path, query, role, in_channel, since, until)
|
|
601
|
-
elif project:
|
|
602
|
-
for pd in _scoped_project_dirs(root, None, False, project):
|
|
603
|
-
matches.extend(S.search_project(pd, query, role, in_channel, since, until))
|
|
604
|
-
elif all_projects:
|
|
605
|
-
matches = list(S.search_all_projects(root, query, role, in_channel, since, until))
|
|
606
|
-
elif cwd:
|
|
607
|
-
pd = P.find_project_dir(cwd, root)
|
|
608
|
-
matches = list(S.search_project(pd, query, role, in_channel, since, until))
|
|
609
|
-
else:
|
|
610
|
-
return "Provide --file, --cwd, --project, or --all-projects"
|
|
611
|
-
|
|
612
|
-
if exclude_current and current_uuid:
|
|
613
|
-
matches = [m for m in matches if m.session_path.stem != current_uuid]
|
|
614
|
-
|
|
615
|
-
if fmt == "json":
|
|
616
|
-
return [
|
|
617
|
-
{
|
|
618
|
-
"session": str(m.session_path),
|
|
619
|
-
"mtime_iso": _fmt_mtime(m.mtime),
|
|
620
|
-
"role": m.role,
|
|
621
|
-
"where": m.where,
|
|
622
|
-
"timestamp": _ts_iso(m.timestamp),
|
|
623
|
-
"window": m.window_text,
|
|
624
|
-
}
|
|
625
|
-
for m in matches
|
|
626
|
-
]
|
|
627
|
-
|
|
628
|
-
if not matches:
|
|
629
|
-
return f"No matches for '{query}'."
|
|
630
|
-
|
|
631
|
-
# Group by session for readable output
|
|
632
|
-
by_session: dict[Path, list] = {}
|
|
633
|
-
for m in matches:
|
|
634
|
-
by_session.setdefault(m.session_path, []).append(m)
|
|
635
|
-
out = []
|
|
636
|
-
for sp, ms in by_session.items():
|
|
637
|
-
out.append(f"\n{'=' * 60}\nSession: {sp.name} ({_fmt_mtime(ms[0].mtime)})\n{'=' * 60}")
|
|
638
|
-
for i, m in enumerate(ms, 1):
|
|
639
|
-
label = f"--- Match #{i} [{m.role}/{m.where}] ---"
|
|
640
|
-
out.append(f"{label}\n{m.window_text[:1500]}")
|
|
641
|
-
return "\n\n".join(out)
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
def mode_subagent_list(path: Path, fmt: str = "text"):
|
|
645
|
-
subs = P.list_subagents(path)
|
|
646
|
-
if fmt == "json":
|
|
647
|
-
out = []
|
|
648
|
-
for sa in subs:
|
|
649
|
-
meta = SA.load_meta(sa)
|
|
650
|
-
out.append({
|
|
651
|
-
"id": sa.stem,
|
|
652
|
-
"agentType": meta.get("agentType", "unknown"),
|
|
653
|
-
"description": meta.get("description", ""),
|
|
654
|
-
"path": str(sa),
|
|
655
|
-
"size_kb": round(sa.stat().st_size / 1024, 1),
|
|
656
|
-
"mtime_iso": _fmt_mtime(sa.stat().st_mtime),
|
|
657
|
-
})
|
|
658
|
-
return out
|
|
659
|
-
if not subs:
|
|
660
|
-
return "No subagent transcripts found."
|
|
661
|
-
out = [f"Subagents for {path.name}:"]
|
|
662
|
-
for sa in subs:
|
|
663
|
-
meta = SA.load_meta(sa)
|
|
664
|
-
size_kb = sa.stat().st_size / 1024
|
|
665
|
-
mtime = _fmt_mtime(sa.stat().st_mtime)
|
|
666
|
-
out.append(
|
|
667
|
-
f" {mtime} {size_kb:5.0f}KB {sa.stem} "
|
|
668
|
-
f"type={meta['agentType']} \"{meta['description'][:60]}\""
|
|
669
|
-
)
|
|
670
|
-
return "\n".join(out)
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
def mode_subagent_finals(path: Path, fmt: str = "text"):
|
|
674
|
-
finals = SA.agent_finals(path)
|
|
675
|
-
if fmt == "json":
|
|
676
|
-
return [
|
|
677
|
-
{"id": agent_id, "agentType": meta.get("agentType", "unknown"), "text": text}
|
|
678
|
-
for agent_id, meta, text in finals
|
|
679
|
-
]
|
|
680
|
-
if not finals:
|
|
681
|
-
return "No subagent transcripts found."
|
|
682
|
-
blocks = []
|
|
683
|
-
for agent_id, meta, text in finals:
|
|
684
|
-
atype = meta.get("agentType", "unknown")
|
|
685
|
-
header = f"=== {agent_id} ({atype}) ==="
|
|
686
|
-
blocks.append(f"{header}\n\n{text or '(no assistant output)'}")
|
|
687
|
-
return "\n\n".join(blocks)
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
def mode_resume_prev(cwd: str, root: Path, n: int = 10, fmt: str = "text"):
|
|
691
|
-
pd = P.find_project_dir(cwd, root)
|
|
692
|
-
files = P.list_transcripts(pd)
|
|
693
|
-
if not files:
|
|
694
|
-
return {"error": "No prior sessions."} if fmt == "json" else "No prior sessions."
|
|
695
|
-
f = files[0]
|
|
696
|
-
lines = PR.parse_lines(f)
|
|
697
|
-
if fmt == "json":
|
|
698
|
-
messages = [m for m in PR.get_messages(lines) if m["texts"]][-n:]
|
|
699
|
-
return {
|
|
700
|
-
"session": f.stem,
|
|
701
|
-
"path": str(f),
|
|
702
|
-
"mtime_iso": _fmt_mtime(f.stat().st_mtime),
|
|
703
|
-
"messages": _messages_json(messages),
|
|
704
|
-
}
|
|
705
|
-
banner = f"--- Resuming from {f.stem} ({_fmt_mtime(f.stat().st_mtime)}) ---\n"
|
|
706
|
-
return banner + mode_dump(lines, n)
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
def mode_count(
|
|
710
|
-
root: Path,
|
|
711
|
-
cwd: str | None,
|
|
712
|
-
all_projects: bool,
|
|
713
|
-
query: str,
|
|
714
|
-
role: str,
|
|
715
|
-
in_channel: str,
|
|
716
|
-
since: datetime | None,
|
|
717
|
-
until: datetime | None,
|
|
718
|
-
project: str | None = None,
|
|
719
|
-
exclude_current: bool = False,
|
|
720
|
-
current_uuid: str | None = None,
|
|
721
|
-
) -> dict:
|
|
722
|
-
sessions = 0
|
|
723
|
-
matches = 0
|
|
724
|
-
total_msgs = 0
|
|
725
|
-
sources: list[Path] = []
|
|
726
|
-
project_dirs = _scoped_project_dirs(root, cwd, all_projects, project)
|
|
727
|
-
for pd in (project_dirs or []):
|
|
728
|
-
sources.extend(P.list_transcripts(pd, since=since, until=until))
|
|
729
|
-
if exclude_current and current_uuid:
|
|
730
|
-
sources = [f for f in sources if f.stem != current_uuid]
|
|
731
|
-
for f in sources:
|
|
732
|
-
ms = S.search_session(f, query, role, in_channel, since, until)
|
|
733
|
-
total_msgs += len(PR.get_messages(PR.parse_lines(f)))
|
|
734
|
-
if ms:
|
|
735
|
-
sessions += 1
|
|
736
|
-
matches += len(ms)
|
|
737
|
-
return {"sessions": sessions, "messages": total_msgs, "matches": matches}
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
def mode_journal(
|
|
741
|
-
root: Path,
|
|
742
|
-
cwd: str | None,
|
|
743
|
-
all_projects: bool,
|
|
744
|
-
since: datetime | None,
|
|
745
|
-
until: datetime | None,
|
|
746
|
-
current_uuid: str | None,
|
|
747
|
-
project: str | None = None,
|
|
748
|
-
fmt: str = "text",
|
|
749
|
-
exclude_current: bool = False,
|
|
750
|
-
):
|
|
751
|
-
pds = _scoped_project_dirs(root, cwd, all_projects, project)
|
|
752
|
-
if pds is None:
|
|
753
|
-
return "--cwd, --project, or --all-projects required"
|
|
754
|
-
blocks = []
|
|
755
|
-
rows = []
|
|
756
|
-
for pd in pds:
|
|
757
|
-
for f in P.list_transcripts(pd, since=since, until=until):
|
|
758
|
-
summary = PR.session_summary(f, current_session_id=current_uuid)
|
|
759
|
-
if exclude_current and summary.get("is_current"):
|
|
760
|
-
continue
|
|
761
|
-
rows.append(summary)
|
|
762
|
-
rows.sort(key=lambda s: s["mtime"], reverse=True)
|
|
763
|
-
if fmt == "json":
|
|
764
|
-
return [_summary_json(s) for s in rows]
|
|
765
|
-
for s in rows:
|
|
766
|
-
day = PR.epoch_to_display(s["mtime"]).strftime("%Y-%m-%d")
|
|
767
|
-
blocks.append(
|
|
768
|
-
f"=== {day} · {s['uuid'][:8]} · {s['decoded_project']} ===\n"
|
|
769
|
-
f" prompt: {s['first_prompt'] or '(none)'}\n"
|
|
770
|
-
f" ended: {s['last_assistant'] or '(none)'}\n"
|
|
771
|
-
f" edits: {s['edit_count']} files\n"
|
|
772
|
-
f" tools: {sum(s['tool_counts'].values())} calls "
|
|
773
|
-
f"({', '.join(f'{k}={v}' for k, v in sorted(s['tool_counts'].items(), key=lambda x: -x[1])[:5])})"
|
|
774
|
-
)
|
|
775
|
-
return "\n\n".join(blocks) if blocks else "No sessions in range."
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
def mode_diff(file_a: Path, file_b: Path, fmt: str = "text"):
|
|
779
|
-
"""Timestamp-interleaved diff of two sessions."""
|
|
780
|
-
msgs_a = [(m, "A") for m in PR.get_messages(PR.parse_lines(file_a))]
|
|
781
|
-
msgs_b = [(m, "B") for m in PR.get_messages(PR.parse_lines(file_b))]
|
|
782
|
-
combined = msgs_a + msgs_b
|
|
783
|
-
|
|
784
|
-
def sort_key(item):
|
|
785
|
-
m, _ = item
|
|
786
|
-
ts = PR._parse_timestamp(m.get("timestamp"))
|
|
787
|
-
if ts and ts.tzinfo is not None:
|
|
788
|
-
ts = ts.replace(tzinfo=None)
|
|
789
|
-
return ts or datetime.min
|
|
790
|
-
|
|
791
|
-
combined.sort(key=sort_key)
|
|
792
|
-
if fmt == "json":
|
|
793
|
-
return {
|
|
794
|
-
"a": str(file_a),
|
|
795
|
-
"b": str(file_b),
|
|
796
|
-
"messages": [
|
|
797
|
-
{
|
|
798
|
-
"source": tag,
|
|
799
|
-
"role": m["role"],
|
|
800
|
-
"timestamp": _ts_iso(m.get("timestamp")),
|
|
801
|
-
"text": " | ".join(m["texts"]),
|
|
802
|
-
}
|
|
803
|
-
for m, tag in combined
|
|
804
|
-
if m["texts"]
|
|
805
|
-
],
|
|
806
|
-
}
|
|
807
|
-
out = [f"--- A: {file_a.name}\n--- B: {file_b.name}\n"]
|
|
808
|
-
for m, tag in combined:
|
|
809
|
-
if not m["texts"]:
|
|
810
|
-
continue
|
|
811
|
-
text = " | ".join(m["texts"])[:300]
|
|
812
|
-
role = m["role"][0].upper()
|
|
813
|
-
out.append(f"{tag}> [{role}] {text}")
|
|
814
|
-
return "\n".join(out)
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
# ─────────────────────────────────────────────────────────────────────────────
|
|
818
|
-
# Timeline mode
|
|
819
|
-
# ─────────────────────────────────────────────────────────────────────────────
|
|
820
|
-
|
|
821
|
-
def _fmt_dur(td: timedelta) -> str:
|
|
822
|
-
mins = int(td.total_seconds() // 60)
|
|
823
|
-
if mins < 1:
|
|
824
|
-
return "<1m"
|
|
825
|
-
h, m = divmod(mins, 60)
|
|
826
|
-
return f"{h}h{m:02d}m" if h else f"{m}m"
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
_GAP_RE = re.compile(r"^(\d+)\s*(m|h)?$", re.IGNORECASE)
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
def _parse_gap(spec: str | None, default: int = 15) -> int:
|
|
833
|
-
"""Parse a gap/break spec ('15m', '1h', '20') into minutes."""
|
|
834
|
-
if not spec:
|
|
835
|
-
return default
|
|
836
|
-
m = _GAP_RE.match(spec.strip())
|
|
837
|
-
if not m:
|
|
838
|
-
raise ValueError(f"Unrecognized gap spec: {spec!r}. Use forms like 15m or 1h.")
|
|
839
|
-
n = int(m.group(1))
|
|
840
|
-
return n * 60 if (m.group(2) or "m").lower() == "h" else n
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
def build_timeline(
|
|
844
|
-
project_dirs: list[Path],
|
|
845
|
-
since: datetime,
|
|
846
|
-
until: datetime,
|
|
847
|
-
gap_minutes: int,
|
|
848
|
-
current_uuid: str | None,
|
|
849
|
-
exclude_current: bool = False,
|
|
850
|
-
) -> dict:
|
|
851
|
-
"""Cross-session activity blocks for a time window.
|
|
852
|
-
|
|
853
|
-
Every signal-message timestamp in [since, until) is an activity event.
|
|
854
|
-
Events across all sessions are merged chronologically and grouped into
|
|
855
|
-
blocks separated by gaps > gap_minutes.
|
|
856
|
-
"""
|
|
857
|
-
sessions: dict[Path, dict] = {}
|
|
858
|
-
events: list[tuple[datetime, Path]] = []
|
|
859
|
-
for pd in project_dirs:
|
|
860
|
-
# Filter by mtime >= since only; a session still active after `until`
|
|
861
|
-
# may contain events inside the window, so no upper mtime bound.
|
|
862
|
-
for f in P.list_transcripts(pd, since=since):
|
|
863
|
-
if exclude_current and current_uuid and f.stem == current_uuid:
|
|
864
|
-
continue
|
|
865
|
-
stamps = []
|
|
866
|
-
for m in PR.get_messages(PR.parse_lines(f)):
|
|
867
|
-
ts = PR._parse_timestamp(m.get("timestamp"))
|
|
868
|
-
if ts is None or ts < since or ts >= until:
|
|
869
|
-
continue
|
|
870
|
-
stamps.append(ts)
|
|
871
|
-
if not stamps:
|
|
872
|
-
continue
|
|
873
|
-
sessions[f] = PR.session_summary(f, current_session_id=current_uuid)
|
|
874
|
-
events.extend((ts, f) for ts in stamps)
|
|
875
|
-
events.sort(key=lambda e: e[0])
|
|
876
|
-
|
|
877
|
-
blocks: list[dict] = []
|
|
878
|
-
cur: dict | None = None
|
|
879
|
-
gap = timedelta(minutes=gap_minutes)
|
|
880
|
-
for ts, f in events:
|
|
881
|
-
if cur is None or ts - cur["end"] > gap:
|
|
882
|
-
cur = {"start": ts, "end": ts, "counts": {}}
|
|
883
|
-
blocks.append(cur)
|
|
884
|
-
if ts > cur["end"]:
|
|
885
|
-
cur["end"] = ts
|
|
886
|
-
cur["counts"][f] = cur["counts"].get(f, 0) + 1
|
|
887
|
-
return {
|
|
888
|
-
"since": since,
|
|
889
|
-
"until": until,
|
|
890
|
-
"gap_minutes": gap_minutes,
|
|
891
|
-
"blocks": blocks,
|
|
892
|
-
"sessions": sessions,
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
def _session_label(s: dict) -> str:
|
|
897
|
-
title = s.get("title") or s.get("first_prompt") or "(untitled)"
|
|
898
|
-
return f"{s['decoded_project']} · {title[:60]} [{s['uuid'][:8]}]"
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
def render_timeline(data: dict, tz_label: str) -> str:
|
|
902
|
-
since, until = data["since"], data["until"]
|
|
903
|
-
blocks, sessions = data["blocks"], data["sessions"]
|
|
904
|
-
multi_day = (until - since) > timedelta(days=1)
|
|
905
|
-
tfmt = "%Y-%m-%d %H:%M" if multi_day else "%H:%M"
|
|
906
|
-
head = (
|
|
907
|
-
f"=== Timeline {since:%Y-%m-%d %H:%M} → {until:%Y-%m-%d %H:%M} "
|
|
908
|
-
f"(times: {tz_label}, gap={data['gap_minutes']}m) ==="
|
|
909
|
-
)
|
|
910
|
-
if not blocks:
|
|
911
|
-
return head + "\n\n(no activity in range)"
|
|
912
|
-
out = [head, ""]
|
|
913
|
-
prev_end: datetime | None = None
|
|
914
|
-
for b in blocks:
|
|
915
|
-
if prev_end is not None:
|
|
916
|
-
out.append(f" ── idle {_fmt_dur(b['start'] - prev_end)} ──")
|
|
917
|
-
dur = b["end"] - b["start"]
|
|
918
|
-
out.append(f"{b['start'].strftime(tfmt)}–{b['end'].strftime('%H:%M')} ({_fmt_dur(dur)})")
|
|
919
|
-
for f, n in sorted(b["counts"].items(), key=lambda x: -x[1]):
|
|
920
|
-
out.append(f" · {_session_label(sessions[f])} — {n} msgs")
|
|
921
|
-
prev_end = b["end"]
|
|
922
|
-
span = blocks[-1]["end"] - blocks[0]["start"]
|
|
923
|
-
out.append("")
|
|
924
|
-
# Timeline is a map of WHEN sessions were active (Claude included) — it makes
|
|
925
|
-
# no claim about user attention time. For that, use --mode engagement.
|
|
926
|
-
out.append(
|
|
927
|
-
f"Total: {len(blocks)} block(s) across a {_fmt_dur(span)} span "
|
|
928
|
-
f"({blocks[0]['start'].strftime(tfmt)}–{blocks[-1]['end'].strftime('%H:%M')}), "
|
|
929
|
-
f"{len(sessions)} session(s)"
|
|
930
|
-
)
|
|
931
|
-
return "\n".join(out)
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
def timeline_json(data: dict) -> dict:
|
|
935
|
-
sessions = data["sessions"]
|
|
936
|
-
blocks_out = []
|
|
937
|
-
for b in data["blocks"]:
|
|
938
|
-
dur_min = int((b["end"] - b["start"]).total_seconds() // 60)
|
|
939
|
-
blocks_out.append({
|
|
940
|
-
"start": b["start"].isoformat(),
|
|
941
|
-
"end": b["end"].isoformat(),
|
|
942
|
-
"duration_minutes": dur_min,
|
|
943
|
-
"sessions": [
|
|
944
|
-
{
|
|
945
|
-
"uuid": sessions[f]["uuid"],
|
|
946
|
-
"project": sessions[f]["decoded_project"],
|
|
947
|
-
"title": sessions[f].get("title") or sessions[f].get("first_prompt") or "",
|
|
948
|
-
"path": str(f),
|
|
949
|
-
"events": n,
|
|
950
|
-
}
|
|
951
|
-
for f, n in sorted(b["counts"].items(), key=lambda x: -x[1])
|
|
952
|
-
],
|
|
953
|
-
})
|
|
954
|
-
span_min = 0
|
|
955
|
-
if data["blocks"]:
|
|
956
|
-
span_min = int(
|
|
957
|
-
(data["blocks"][-1]["end"] - data["blocks"][0]["start"]).total_seconds() // 60
|
|
958
|
-
)
|
|
959
|
-
return {
|
|
960
|
-
"since": data["since"].isoformat(),
|
|
961
|
-
"until": data["until"].isoformat(),
|
|
962
|
-
"gap_minutes": data["gap_minutes"],
|
|
963
|
-
"blocks": blocks_out,
|
|
964
|
-
"totals": {
|
|
965
|
-
"blocks": len(blocks_out),
|
|
966
|
-
"span_minutes": span_min,
|
|
967
|
-
"sessions": len(sessions),
|
|
968
|
-
},
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
# ─────────────────────────────────────────────────────────────────────────────
|
|
973
|
-
# Engagement mode — user attention time, not session activity
|
|
974
|
-
# ─────────────────────────────────────────────────────────────────────────────
|
|
975
|
-
|
|
976
|
-
def _is_real_user_prompt(obj: dict) -> bool:
|
|
977
|
-
"""True only for an actual human action: typed prompt or slash command.
|
|
978
|
-
|
|
979
|
-
Excludes tool results (user-role, no text blocks), hook/skill injections
|
|
980
|
-
(isMeta), and compact continuations (classified upstream).
|
|
981
|
-
"""
|
|
982
|
-
if obj.get("isMeta"):
|
|
983
|
-
return False
|
|
984
|
-
content = obj.get("message", {}).get("content", "")
|
|
985
|
-
if isinstance(content, str):
|
|
986
|
-
return bool(content.strip())
|
|
987
|
-
if isinstance(content, list):
|
|
988
|
-
return any(
|
|
989
|
-
isinstance(b, dict) and b.get("type") == "text" and b.get("text", "").strip()
|
|
990
|
-
for b in content
|
|
991
|
-
)
|
|
992
|
-
return False
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
def _engagement_event_streams(
|
|
996
|
-
path: Path, since: datetime | None, until: datetime | None
|
|
997
|
-
) -> tuple[list[datetime], list[datetime]]:
|
|
998
|
-
"""One session's (user_events, claude_events) inside [since, until).
|
|
999
|
-
|
|
1000
|
-
user_events — real user prompts only (see _is_real_user_prompt).
|
|
1001
|
-
claude_events — assistant messages and tool results: evidence Claude was
|
|
1002
|
-
working. Used only to grant waiting-on-Claude credit for long gaps.
|
|
1003
|
-
"""
|
|
1004
|
-
user_ev: list[datetime] = []
|
|
1005
|
-
claude_ev: list[datetime] = []
|
|
1006
|
-
for obj in PR.parse_lines(path):
|
|
1007
|
-
cls = PR.classify_entry(obj)
|
|
1008
|
-
if cls in ("noise", "title", "compact"):
|
|
1009
|
-
continue
|
|
1010
|
-
ts = PR._parse_timestamp(obj.get("timestamp"))
|
|
1011
|
-
if ts is None or (since and ts < since) or (until and ts >= until):
|
|
1012
|
-
continue
|
|
1013
|
-
if cls == "user":
|
|
1014
|
-
if obj.get("isMeta"):
|
|
1015
|
-
continue
|
|
1016
|
-
if _is_real_user_prompt(obj):
|
|
1017
|
-
user_ev.append(ts)
|
|
1018
|
-
else:
|
|
1019
|
-
claude_ev.append(ts) # tool_result entries
|
|
1020
|
-
else: # assistant
|
|
1021
|
-
claude_ev.append(ts)
|
|
1022
|
-
user_ev.sort()
|
|
1023
|
-
claude_ev.sort()
|
|
1024
|
-
return user_ev, claude_ev
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
def build_engagement(
|
|
1028
|
-
root: Path,
|
|
1029
|
-
report_dirs: list[Path] | None,
|
|
1030
|
-
report_file: Path | None,
|
|
1031
|
-
since: datetime,
|
|
1032
|
-
until: datetime,
|
|
1033
|
-
break_minutes: int,
|
|
1034
|
-
current_uuid: str | None,
|
|
1035
|
-
exclude_current: bool = False,
|
|
1036
|
-
) -> dict:
|
|
1037
|
-
"""Attention-time accounting over ONE merged user-prompt stream.
|
|
1038
|
-
|
|
1039
|
-
Real user prompts from EVERY project are merged into a single global
|
|
1040
|
-
stream, so a moment of wall-clock time is never counted twice across
|
|
1041
|
-
parallel chats. Three rules:
|
|
1042
|
-
|
|
1043
|
-
1. A gap between consecutive prompts ≤ break_minutes counts fully as
|
|
1044
|
-
active time, attributed to the session of the LATER prompt (that's
|
|
1045
|
-
the chat being read/typed in).
|
|
1046
|
-
2. A longer gap still counts in full if Claude was working in the later
|
|
1047
|
-
prompt's session during the gap AND the user replied within
|
|
1048
|
-
break_minutes of Claude's last event (sitting-there-waiting credit).
|
|
1049
|
-
3. Anything else is a break: contributes nothing.
|
|
1050
|
-
|
|
1051
|
-
report_dirs/report_file only filter which sessions are REPORTED — the
|
|
1052
|
-
stream itself always spans all projects under root for correctness.
|
|
1053
|
-
"""
|
|
1054
|
-
import bisect
|
|
1055
|
-
|
|
1056
|
-
user_events: dict[Path, list[datetime]] = {}
|
|
1057
|
-
claude_events: dict[Path, list[datetime]] = {}
|
|
1058
|
-
walk_dirs = P.list_projects(root)
|
|
1059
|
-
files: list[Path] = []
|
|
1060
|
-
for pd in walk_dirs:
|
|
1061
|
-
# mtime >= since only; a session still active after `until` may hold
|
|
1062
|
-
# events inside the window (same reasoning as timeline).
|
|
1063
|
-
files.extend(P.list_transcripts(pd, since=since))
|
|
1064
|
-
if report_file:
|
|
1065
|
-
report_file = report_file.resolve()
|
|
1066
|
-
files = [f.resolve() for f in files]
|
|
1067
|
-
if report_file not in files:
|
|
1068
|
-
files.append(report_file) # e.g. --file under a different root
|
|
1069
|
-
for f in files:
|
|
1070
|
-
if exclude_current and current_uuid and f.stem == current_uuid:
|
|
1071
|
-
continue
|
|
1072
|
-
u, c = _engagement_event_streams(f, since, until)
|
|
1073
|
-
if u or c:
|
|
1074
|
-
user_events[f] = u
|
|
1075
|
-
claude_events[f] = c
|
|
1076
|
-
|
|
1077
|
-
stream = sorted(
|
|
1078
|
-
(ts, f) for f, evs in user_events.items() for ts in evs
|
|
1079
|
-
)
|
|
1080
|
-
|
|
1081
|
-
brk = timedelta(minutes=break_minutes)
|
|
1082
|
-
active: dict[Path, timedelta] = {}
|
|
1083
|
-
breaks: list[tuple[datetime, datetime]] = []
|
|
1084
|
-
for (t0, _f0), (t1, f1) in zip(stream, stream[1:]):
|
|
1085
|
-
gap = t1 - t0
|
|
1086
|
-
if gap <= brk:
|
|
1087
|
-
active[f1] = active.get(f1, timedelta()) + gap
|
|
1088
|
-
continue
|
|
1089
|
-
# Waiting-on-Claude credit: last Claude event in f1 inside the gap.
|
|
1090
|
-
cl = claude_events.get(f1, [])
|
|
1091
|
-
i = bisect.bisect_left(cl, t1)
|
|
1092
|
-
t_done = cl[i - 1] if i > 0 and cl[i - 1] > t0 else None
|
|
1093
|
-
if t_done is not None and (t1 - t_done) <= brk:
|
|
1094
|
-
active[f1] = active.get(f1, timedelta()) + gap
|
|
1095
|
-
else:
|
|
1096
|
-
breaks.append((t0, t1))
|
|
1097
|
-
|
|
1098
|
-
# Reporting scope
|
|
1099
|
-
report_dir_set = {d.resolve() for d in report_dirs} if report_dirs else None
|
|
1100
|
-
sessions: dict[Path, dict] = {}
|
|
1101
|
-
for f, evs in user_events.items():
|
|
1102
|
-
if not evs:
|
|
1103
|
-
continue
|
|
1104
|
-
if report_file and f != report_file:
|
|
1105
|
-
continue
|
|
1106
|
-
if report_dir_set is not None and f.parent.resolve() not in report_dir_set:
|
|
1107
|
-
continue
|
|
1108
|
-
sessions[f] = {
|
|
1109
|
-
"summary": PR.session_summary(f, current_session_id=current_uuid),
|
|
1110
|
-
"first": evs[0],
|
|
1111
|
-
"last": evs[-1],
|
|
1112
|
-
"user_messages": len(evs),
|
|
1113
|
-
"active": active.get(f, timedelta()),
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
return {
|
|
1117
|
-
"since": since,
|
|
1118
|
-
"until": until,
|
|
1119
|
-
"break_minutes": break_minutes,
|
|
1120
|
-
"sessions": sessions,
|
|
1121
|
-
"breaks": breaks,
|
|
1122
|
-
"stream_events": len(stream),
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
def _gap_percentiles(evs: list[datetime]) -> tuple[int, int] | None:
|
|
1127
|
-
"""(median, p90) of intra-session user-prompt gaps, in whole minutes."""
|
|
1128
|
-
if len(evs) < 2:
|
|
1129
|
-
return None
|
|
1130
|
-
gaps = sorted(
|
|
1131
|
-
(b - a).total_seconds() / 60 for a, b in zip(evs, evs[1:])
|
|
1132
|
-
)
|
|
1133
|
-
median = gaps[len(gaps) // 2]
|
|
1134
|
-
p90 = gaps[min(len(gaps) - 1, int(len(gaps) * 0.9))]
|
|
1135
|
-
return int(median), int(p90)
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
def render_engagement(data: dict, tz_label: str) -> str:
|
|
1139
|
-
since, until = data["since"], data["until"]
|
|
1140
|
-
sessions = data["sessions"]
|
|
1141
|
-
multi_day = (until - since) > timedelta(days=1)
|
|
1142
|
-
tfmt = "%Y-%m-%d %H:%M" if multi_day else "%H:%M"
|
|
1143
|
-
head = (
|
|
1144
|
-
f"=== Engagement {since:%Y-%m-%d %H:%M} → {until:%Y-%m-%d %H:%M} "
|
|
1145
|
-
f"(times: {tz_label}, break={data['break_minutes']}m) ==="
|
|
1146
|
-
)
|
|
1147
|
-
if not sessions:
|
|
1148
|
-
return head + "\n\n(no user messages in range)"
|
|
1149
|
-
out = [head, ""]
|
|
1150
|
-
rows = sorted(sessions.items(), key=lambda kv: -kv[1]["active"].total_seconds())
|
|
1151
|
-
for f, s in rows:
|
|
1152
|
-
elapsed = s["last"] - s["first"]
|
|
1153
|
-
# Composing time leading into a chat's first prompt is credited to it,
|
|
1154
|
-
# so active can slightly exceed first–last; cap the ratio at 1.0.
|
|
1155
|
-
ratio = (
|
|
1156
|
-
f"{min(1.0, s['active'].total_seconds() / elapsed.total_seconds()):.2f}"
|
|
1157
|
-
if elapsed.total_seconds() > 0 else " — "
|
|
1158
|
-
)
|
|
1159
|
-
out.append(
|
|
1160
|
-
f"{_fmt_dur(s['active']):>7} ratio {ratio} msgs {s['user_messages']:<4} "
|
|
1161
|
-
f"{s['first'].strftime(tfmt)}–{s['last'].strftime('%H:%M')} "
|
|
1162
|
-
f"{_session_label(s['summary'])}"
|
|
1163
|
-
)
|
|
1164
|
-
total_active = sum((s["active"] for s in sessions.values()), timedelta())
|
|
1165
|
-
first = min(s["first"] for s in sessions.values())
|
|
1166
|
-
last = max(s["last"] for s in sessions.values())
|
|
1167
|
-
out.append("")
|
|
1168
|
-
out.append(
|
|
1169
|
-
f"Total: {_fmt_dur(total_active)} active across {len(sessions)} session(s), "
|
|
1170
|
-
f"{first.strftime(tfmt)}–{last.strftime('%H:%M')} span ({_fmt_dur(last - first)})"
|
|
1171
|
-
)
|
|
1172
|
-
breaks = data["breaks"]
|
|
1173
|
-
if breaks:
|
|
1174
|
-
shown = breaks[:6]
|
|
1175
|
-
items = ", ".join(
|
|
1176
|
-
f"{a.strftime(tfmt)}→{b.strftime('%H:%M')} ({_fmt_dur(b - a)})"
|
|
1177
|
-
for a, b in shown
|
|
1178
|
-
)
|
|
1179
|
-
more = f" (+{len(breaks) - len(shown)} more)" if len(breaks) > len(shown) else ""
|
|
1180
|
-
out.append(f"Breaks >{data['break_minutes']}m in the merged stream: "
|
|
1181
|
-
f"{len(breaks)} — {items}{more}")
|
|
1182
|
-
# Single-session detail: prompt-gap percentiles
|
|
1183
|
-
if len(sessions) == 1:
|
|
1184
|
-
(f, s), = sessions.items()
|
|
1185
|
-
# recompute the session's own user events from the stored bounds is not
|
|
1186
|
-
# enough — pull them again (cached parse, cheap)
|
|
1187
|
-
evs, _ = _engagement_event_streams(f, data["since"], data["until"])
|
|
1188
|
-
pct = _gap_percentiles(evs)
|
|
1189
|
-
if pct:
|
|
1190
|
-
out.append(f"Prompt gaps: median {pct[0]}m, p90 {pct[1]}m")
|
|
1191
|
-
out.append(
|
|
1192
|
-
"(active time = your message cadence merged across ALL projects; "
|
|
1193
|
-
"parallel chats split the clock, never double-count. "
|
|
1194
|
-
"Long gaps count only when you replied right after Claude finished.)"
|
|
1195
|
-
)
|
|
1196
|
-
return "\n".join(out)
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
def engagement_json(data: dict) -> dict:
|
|
1200
|
-
sessions_out = []
|
|
1201
|
-
rows = sorted(
|
|
1202
|
-
data["sessions"].items(), key=lambda kv: -kv[1]["active"].total_seconds()
|
|
1203
|
-
)
|
|
1204
|
-
total_active = timedelta()
|
|
1205
|
-
for f, s in rows:
|
|
1206
|
-
elapsed = s["last"] - s["first"]
|
|
1207
|
-
active = s["active"]
|
|
1208
|
-
total_active += active
|
|
1209
|
-
summary = s["summary"]
|
|
1210
|
-
sessions_out.append({
|
|
1211
|
-
"uuid": summary["uuid"],
|
|
1212
|
-
"project": summary["decoded_project"],
|
|
1213
|
-
"title": summary.get("title") or summary.get("first_prompt") or "",
|
|
1214
|
-
"path": str(f),
|
|
1215
|
-
"first": s["first"].isoformat(),
|
|
1216
|
-
"last": s["last"].isoformat(),
|
|
1217
|
-
"elapsed_minutes": int(elapsed.total_seconds() // 60),
|
|
1218
|
-
"active_minutes": int(active.total_seconds() // 60),
|
|
1219
|
-
"active_seconds": int(active.total_seconds()),
|
|
1220
|
-
"ratio": (
|
|
1221
|
-
min(1.0, round(active.total_seconds() / elapsed.total_seconds(), 2))
|
|
1222
|
-
if elapsed.total_seconds() > 0 else None
|
|
1223
|
-
),
|
|
1224
|
-
"user_messages": s["user_messages"],
|
|
1225
|
-
})
|
|
1226
|
-
span_min = 0
|
|
1227
|
-
if data["sessions"]:
|
|
1228
|
-
first = min(s["first"] for s in data["sessions"].values())
|
|
1229
|
-
last = max(s["last"] for s in data["sessions"].values())
|
|
1230
|
-
span_min = int((last - first).total_seconds() // 60)
|
|
1231
|
-
return {
|
|
1232
|
-
"since": data["since"].isoformat(),
|
|
1233
|
-
"until": data["until"].isoformat(),
|
|
1234
|
-
"break_minutes": data["break_minutes"],
|
|
1235
|
-
"sessions": sessions_out,
|
|
1236
|
-
"totals": {
|
|
1237
|
-
"sessions": len(sessions_out),
|
|
1238
|
-
"active_minutes": int(total_active.total_seconds() // 60),
|
|
1239
|
-
"active_seconds": int(total_active.total_seconds()),
|
|
1240
|
-
"span_minutes": span_min,
|
|
1241
|
-
},
|
|
1242
|
-
"stream_breaks": [
|
|
1243
|
-
{
|
|
1244
|
-
"start": a.isoformat(),
|
|
1245
|
-
"end": b.isoformat(),
|
|
1246
|
-
"minutes": int((b - a).total_seconds() // 60),
|
|
1247
|
-
}
|
|
1248
|
-
for a, b in data["breaks"]
|
|
1249
|
-
],
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
# ─────────────────────────────────────────────────────────────────────────────
|
|
1254
|
-
# JSON builders for legacy single-file modes
|
|
1255
|
-
# ─────────────────────────────────────────────────────────────────────────────
|
|
1256
|
-
|
|
1257
|
-
def json_last(lines: list[dict], n: int) -> list[dict]:
|
|
1258
|
-
messages = PR.get_messages(lines)
|
|
1259
|
-
assistant_msgs = [m for m in messages if m["role"] == "assistant" and m["texts"]]
|
|
1260
|
-
recent = assistant_msgs[-n:]
|
|
1261
|
-
return [
|
|
1262
|
-
{
|
|
1263
|
-
"n_from_end": len(recent) - i,
|
|
1264
|
-
"timestamp": _ts_iso(m.get("timestamp")),
|
|
1265
|
-
"text": "\n".join(m["texts"]),
|
|
1266
|
-
}
|
|
1267
|
-
for i, m in enumerate(recent)
|
|
1268
|
-
]
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
def json_advisor(lines: list[dict]) -> list[str]:
|
|
1272
|
-
results = []
|
|
1273
|
-
for obj in lines:
|
|
1274
|
-
if obj.get("type") in PR.NOISE_TYPES:
|
|
1275
|
-
continue
|
|
1276
|
-
msg = obj.get("message", {})
|
|
1277
|
-
if not isinstance(msg.get("content"), list):
|
|
1278
|
-
continue
|
|
1279
|
-
for block in msg["content"]:
|
|
1280
|
-
if block.get("type") == "advisor_tool_result":
|
|
1281
|
-
inner = block.get("content", {})
|
|
1282
|
-
if isinstance(inner, dict) and inner.get("text"):
|
|
1283
|
-
results.append(inner["text"])
|
|
1284
|
-
return results
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
def json_pre_compact(lines: list[dict], window: int = 40) -> dict:
|
|
1288
|
-
messages = PR.get_messages(lines)
|
|
1289
|
-
compact_idx = None
|
|
1290
|
-
for i, m in enumerate(messages):
|
|
1291
|
-
if m["is_compact"]:
|
|
1292
|
-
compact_idx = i
|
|
1293
|
-
if compact_idx is None:
|
|
1294
|
-
return {"found_compact": False, "messages": _messages_json(messages[-10:])}
|
|
1295
|
-
start = max(0, compact_idx - window)
|
|
1296
|
-
return {
|
|
1297
|
-
"found_compact": True,
|
|
1298
|
-
"messages": _messages_json(messages[start:compact_idx]),
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
def json_dump(lines: list[dict], limit: int = 80) -> list[dict]:
|
|
1303
|
-
messages = [m for m in PR.get_messages(lines) if m["texts"] or m["is_compact"]]
|
|
1304
|
-
return _messages_json(messages[-limit:])
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
def json_debug(lines: list[dict]) -> dict:
|
|
1308
|
-
type_counts: dict[str, int] = {}
|
|
1309
|
-
for obj in lines:
|
|
1310
|
-
t = obj.get("type", "<missing>")
|
|
1311
|
-
type_counts[t] = type_counts.get(t, 0) + 1
|
|
1312
|
-
block_type_counts: dict[str, int] = {}
|
|
1313
|
-
advisor_blocks = 0
|
|
1314
|
-
for obj in lines:
|
|
1315
|
-
if obj.get("type") in PR.NOISE_TYPES:
|
|
1316
|
-
continue
|
|
1317
|
-
content = obj.get("message", {}).get("content", [])
|
|
1318
|
-
if isinstance(content, list):
|
|
1319
|
-
for block in content:
|
|
1320
|
-
if isinstance(block, dict):
|
|
1321
|
-
bt = block.get("type", "<missing>")
|
|
1322
|
-
block_type_counts[bt] = block_type_counts.get(bt, 0) + 1
|
|
1323
|
-
if bt == "advisor_tool_result":
|
|
1324
|
-
advisor_blocks += 1
|
|
1325
|
-
compact_markers = sum(1 for m in PR.get_messages(lines) if m["is_compact"])
|
|
1326
|
-
return {
|
|
1327
|
-
"entry_types": type_counts,
|
|
1328
|
-
"block_types": block_type_counts,
|
|
1329
|
-
"advisor_blocks": advisor_blocks,
|
|
1330
|
-
"compact_markers": compact_markers,
|
|
1331
|
-
}
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
# ─────────────────────────────────────────────────────────────────────────────
|
|
1335
|
-
# CLI dispatch
|
|
1336
|
-
# ─────────────────────────────────────────────────────────────────────────────
|
|
1337
|
-
|
|
1338
|
-
LEGACY_MODES = {"last", "advisor", "pre-compact", "dump", "search", "debug"}
|
|
1339
|
-
|
|
1340
|
-
NEW_MODES = {
|
|
1341
|
-
"list", "lookup", "find", "resume-cmd", "brief",
|
|
1342
|
-
"changelog", "file-edits", "tool-calls",
|
|
1343
|
-
"subagent-list", "subagent-finals", "subagent-tools", "subagent-files",
|
|
1344
|
-
"resume-prev", "count", "journal", "diff", "timeline", "engagement",
|
|
1345
|
-
}
|
|
1346
|
-
|
|
1347
|
-
ALL_MODES = LEGACY_MODES | NEW_MODES
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
def build_parser() -> argparse.ArgumentParser:
|
|
1351
|
-
p = argparse.ArgumentParser(
|
|
1352
|
-
description="Extract content from Claude Code transcript files",
|
|
1353
|
-
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1354
|
-
)
|
|
1355
|
-
# Targeting flags
|
|
1356
|
-
p.add_argument("--file", help="Path to .jsonl transcript file")
|
|
1357
|
-
p.add_argument("--cwd", help="Project working directory (to auto-find transcripts)")
|
|
1358
|
-
p.add_argument("--all-projects", action="store_true", help="Walk every project under --root")
|
|
1359
|
-
p.add_argument("--project", help="Filter projects by name substring (e.g. 'keel')")
|
|
1360
|
-
|
|
1361
|
-
# Root selector
|
|
1362
|
-
p.add_argument(
|
|
1363
|
-
"--root", default="live",
|
|
1364
|
-
help="One of {live, mirror, snapshot-24h, snapshot-1w, snapshot-1mo} or an absolute path",
|
|
1365
|
-
)
|
|
1366
|
-
|
|
1367
|
-
# Time bounds
|
|
1368
|
-
p.add_argument("--since", help="Lower time bound (ISO date / 7d / yesterday / now)")
|
|
1369
|
-
p.add_argument("--until", help="Upper time bound (same forms as --since)")
|
|
1370
|
-
p.add_argument("--date", help="Single-day window for timeline mode (ISO date / yesterday / today)")
|
|
1371
|
-
p.add_argument("--gap", help="Idle-gap threshold for timeline blocks (e.g. 15m, 1h; default 15m)")
|
|
1372
|
-
p.add_argument("--break", dest="break_spec",
|
|
1373
|
-
help="Break threshold for engagement mode (e.g. 5m, 20m; default 10m)")
|
|
1374
|
-
p.add_argument(
|
|
1375
|
-
"--tz", default=None,
|
|
1376
|
-
help="Display timezone override: IANA name (America/New_York), UTC, or offset (+5, -4). "
|
|
1377
|
-
"Default: system local time.",
|
|
1378
|
-
)
|
|
1379
|
-
|
|
1380
|
-
# Mode
|
|
1381
|
-
p.add_argument(
|
|
1382
|
-
"--mode", choices=sorted(ALL_MODES), default="last",
|
|
1383
|
-
help="Operation mode (see SKILL.md or references/modes.md)",
|
|
1384
|
-
)
|
|
1385
|
-
|
|
1386
|
-
# Mode-specific flags
|
|
1387
|
-
p.add_argument("--query", help="Search query (for search/count modes)")
|
|
1388
|
-
p.add_argument("--uuid", help="UUID prefix (for lookup/resume-cmd modes)")
|
|
1389
|
-
p.add_argument("--title", help="Title substring (for find mode)")
|
|
1390
|
-
p.add_argument("--first-prompt", dest="first_prompt", help="First-prompt substring (for find mode)")
|
|
1391
|
-
p.add_argument("--role", default="both", choices=["user", "assistant", "both"])
|
|
1392
|
-
p.add_argument("--in", dest="in_channel", default="text",
|
|
1393
|
-
choices=["text", "tool_use", "tool_result", "thinking", "all"])
|
|
1394
|
-
p.add_argument("--tool", help="Comma-separated tool names (for tool-calls)")
|
|
1395
|
-
p.add_argument("--subagent", help="Subagent file path (for subagent-tools/files)")
|
|
1396
|
-
p.add_argument("--file-a", dest="file_a", help="First file for diff mode")
|
|
1397
|
-
p.add_argument("--file-b", dest="file_b", help="Second file for diff mode")
|
|
1398
|
-
p.add_argument("--subagents-of", dest="subagents_of", help="Parent session for sibling diff")
|
|
1399
|
-
|
|
1400
|
-
# Behavior flags
|
|
1401
|
-
p.add_argument("--exclude-current", action="store_true",
|
|
1402
|
-
help="Drop the current session (via CLAUDE_SESSION_ID) from output")
|
|
1403
|
-
p.add_argument("--include-subagents", action="store_true",
|
|
1404
|
-
help="Fold subagent finals into brief/last/dump output")
|
|
1405
|
-
p.add_argument("--force-dump", action="store_true",
|
|
1406
|
-
help="Bypass the 5MB dump-size guard")
|
|
1407
|
-
p.add_argument("--format", default="text", choices=["text", "json"],
|
|
1408
|
-
help="Output format (json works on every mode except the legacy aliases)")
|
|
1409
|
-
p.add_argument("--json", action="store_true", help="Alias for --format json")
|
|
1410
|
-
p.add_argument("-n", type=int, default=5, help="Count modifier (last/dump/resume-prev)")
|
|
1411
|
-
|
|
1412
|
-
# Legacy alias flags
|
|
1413
|
-
p.add_argument("--list", action="store_true", help="List transcripts (legacy alias for --mode list)")
|
|
1414
|
-
p.add_argument("--list-subagents", action="store_true",
|
|
1415
|
-
help="List subagent files (legacy alias for --mode subagent-list)")
|
|
1416
|
-
return p
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
def _resolve_time(spec: str | None) -> datetime | None:
|
|
1420
|
-
if not spec:
|
|
1421
|
-
return None
|
|
1422
|
-
return P.parse_timespec(spec)
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
def main() -> int:
|
|
1426
|
-
parser = build_parser()
|
|
1427
|
-
args = parser.parse_args()
|
|
1428
|
-
|
|
1429
|
-
# Display timezone: default is system local time; --tz overrides.
|
|
1430
|
-
# Must run before anything formats a timestamp.
|
|
1431
|
-
try:
|
|
1432
|
-
PR.set_timezone(args.tz)
|
|
1433
|
-
except ValueError as e:
|
|
1434
|
-
print(str(e), file=sys.stderr)
|
|
1435
|
-
return 1
|
|
1436
|
-
|
|
1437
|
-
fmt = "json" if (args.format == "json" or args.json) else "text"
|
|
1438
|
-
|
|
1439
|
-
# Legacy alias translation — do NOT modify output for these paths.
|
|
1440
|
-
if args.list:
|
|
1441
|
-
if not args.cwd:
|
|
1442
|
-
print("--cwd required with --list", file=sys.stderr)
|
|
1443
|
-
return 1
|
|
1444
|
-
root = P.resolve_root(args.root)
|
|
1445
|
-
print(mode_list_legacy(args.cwd, root))
|
|
1446
|
-
return 0
|
|
1447
|
-
|
|
1448
|
-
if args.list_subagents:
|
|
1449
|
-
if not args.file:
|
|
1450
|
-
print("--file required with --list-subagents", file=sys.stderr)
|
|
1451
|
-
return 1
|
|
1452
|
-
path = Path(args.file)
|
|
1453
|
-
subs = P.list_subagents(path)
|
|
1454
|
-
if not subs:
|
|
1455
|
-
print("No subagent transcripts found.")
|
|
1456
|
-
return 0
|
|
1457
|
-
print(f"Subagents for {path.name}:")
|
|
1458
|
-
for f in subs:
|
|
1459
|
-
size_kb = f.stat().st_size / 1024
|
|
1460
|
-
print(f" {size_kb:5.0f}KB {f.name}")
|
|
1461
|
-
return 0
|
|
1462
|
-
|
|
1463
|
-
root = P.resolve_root(args.root)
|
|
1464
|
-
current_uuid = P.current_session_id()
|
|
1465
|
-
since = _resolve_time(args.since)
|
|
1466
|
-
until = _resolve_time(args.until)
|
|
1467
|
-
|
|
1468
|
-
# Legacy --mode search with --cwd (no --file) preserved byte-for-byte.
|
|
1469
|
-
if args.mode == "search" and args.cwd and not args.file and not args.all_projects \
|
|
1470
|
-
and not args.project and fmt == "text" \
|
|
1471
|
-
and args.role == "both" and args.in_channel == "text":
|
|
1472
|
-
if not args.query:
|
|
1473
|
-
print("--query required with --mode search", file=sys.stderr)
|
|
1474
|
-
return 1
|
|
1475
|
-
files = P.list_transcripts(P.find_project_dir(args.cwd, root))
|
|
1476
|
-
if not files:
|
|
1477
|
-
print("No transcript files found.")
|
|
1478
|
-
return 0
|
|
1479
|
-
total_matches = 0
|
|
1480
|
-
for i, f in enumerate(files, 1):
|
|
1481
|
-
print(f"Searching {i}/{len(files)}: {f.name}...", file=sys.stderr, end="\r")
|
|
1482
|
-
try:
|
|
1483
|
-
lines = PR.parse_lines(f)
|
|
1484
|
-
result = mode_search_legacy(lines, args.query)
|
|
1485
|
-
except Exception as e:
|
|
1486
|
-
print(f"\nError reading {f.name}: {e}", file=sys.stderr)
|
|
1487
|
-
continue
|
|
1488
|
-
if result is not None:
|
|
1489
|
-
mtime = _fmt_mtime(f.stat().st_mtime)
|
|
1490
|
-
print(f"\n{'=' * 60}")
|
|
1491
|
-
print(f"Session: {f.name} ({mtime})")
|
|
1492
|
-
print("=" * 60)
|
|
1493
|
-
print(result)
|
|
1494
|
-
total_matches += 1
|
|
1495
|
-
print(file=sys.stderr)
|
|
1496
|
-
if total_matches == 0:
|
|
1497
|
-
print(f"No matches for '{args.query}' found across {len(files)} session(s).")
|
|
1498
|
-
else:
|
|
1499
|
-
print(f"\n--- Found matches in {total_matches}/{len(files)} session(s) ---")
|
|
1500
|
-
return 0
|
|
1501
|
-
|
|
1502
|
-
mode = args.mode
|
|
1503
|
-
|
|
1504
|
-
# Discovery modes — don't need --file
|
|
1505
|
-
if mode == "list":
|
|
1506
|
-
_emit(mode_list(root, args.cwd, args.all_projects, since, until,
|
|
1507
|
-
args.exclude_current, current_uuid,
|
|
1508
|
-
project=args.project, fmt=fmt))
|
|
1509
|
-
return 0
|
|
1510
|
-
if mode == "lookup":
|
|
1511
|
-
code, out = mode_lookup(args.uuid or "", root, fmt=fmt)
|
|
1512
|
-
_emit(out)
|
|
1513
|
-
return code
|
|
1514
|
-
if mode == "find":
|
|
1515
|
-
_emit(mode_find(root, args.title, args.first_prompt, current_uuid,
|
|
1516
|
-
project=args.project, fmt=fmt))
|
|
1517
|
-
return 0
|
|
1518
|
-
if mode == "resume-cmd":
|
|
1519
|
-
code, out = mode_resume_cmd(args.uuid or "", root, fmt=fmt)
|
|
1520
|
-
_emit(out)
|
|
1521
|
-
return code
|
|
1522
|
-
if mode == "resume-prev":
|
|
1523
|
-
if not args.cwd:
|
|
1524
|
-
print("--cwd required for resume-prev", file=sys.stderr)
|
|
1525
|
-
return 1
|
|
1526
|
-
_emit(mode_resume_prev(args.cwd, root, args.n, fmt=fmt))
|
|
1527
|
-
return 0
|
|
1528
|
-
if mode == "count":
|
|
1529
|
-
if not args.query:
|
|
1530
|
-
print("--query required for count", file=sys.stderr)
|
|
1531
|
-
return 1
|
|
1532
|
-
counts = mode_count(root, args.cwd, args.all_projects, args.query,
|
|
1533
|
-
args.role, args.in_channel, since, until,
|
|
1534
|
-
project=args.project,
|
|
1535
|
-
exclude_current=args.exclude_current,
|
|
1536
|
-
current_uuid=current_uuid)
|
|
1537
|
-
if fmt == "json":
|
|
1538
|
-
_print_json(counts)
|
|
1539
|
-
else:
|
|
1540
|
-
print(
|
|
1541
|
-
f"{counts['sessions']} sessions, {counts['messages']} total messages, "
|
|
1542
|
-
f"{counts['matches']} matches",
|
|
1543
|
-
file=sys.stderr,
|
|
1544
|
-
)
|
|
1545
|
-
print(counts["sessions"])
|
|
1546
|
-
return 0
|
|
1547
|
-
if mode == "journal":
|
|
1548
|
-
_emit(mode_journal(root, args.cwd, args.all_projects, since, until,
|
|
1549
|
-
current_uuid, project=args.project, fmt=fmt,
|
|
1550
|
-
exclude_current=args.exclude_current))
|
|
1551
|
-
return 0
|
|
1552
|
-
if mode == "timeline":
|
|
1553
|
-
try:
|
|
1554
|
-
if args.date:
|
|
1555
|
-
day = P.parse_timespec(args.date).replace(
|
|
1556
|
-
hour=0, minute=0, second=0, microsecond=0
|
|
1557
|
-
)
|
|
1558
|
-
t_since, t_until = day, day + timedelta(days=1)
|
|
1559
|
-
else:
|
|
1560
|
-
t_since = since or P.parse_timespec("today")
|
|
1561
|
-
t_until = until or P.parse_timespec("now")
|
|
1562
|
-
gap_minutes = _parse_gap(args.gap)
|
|
1563
|
-
except ValueError as e:
|
|
1564
|
-
print(str(e), file=sys.stderr)
|
|
1565
|
-
return 1
|
|
1566
|
-
# Timeline is inherently cross-project — default to all projects.
|
|
1567
|
-
project_dirs = _scoped_project_dirs(
|
|
1568
|
-
root, args.cwd, args.all_projects, args.project, default_all=True
|
|
1569
|
-
)
|
|
1570
|
-
data = build_timeline(project_dirs, t_since, t_until, gap_minutes, current_uuid,
|
|
1571
|
-
exclude_current=args.exclude_current)
|
|
1572
|
-
if fmt == "json":
|
|
1573
|
-
_print_json(timeline_json(data))
|
|
1574
|
-
else:
|
|
1575
|
-
print(render_timeline(data, tz_label=args.tz or "local"))
|
|
1576
|
-
return 0
|
|
1577
|
-
if mode == "engagement":
|
|
1578
|
-
try:
|
|
1579
|
-
break_minutes = _parse_gap(args.break_spec, default=10)
|
|
1580
|
-
if args.date:
|
|
1581
|
-
day = P.parse_timespec(args.date).replace(
|
|
1582
|
-
hour=0, minute=0, second=0, microsecond=0
|
|
1583
|
-
)
|
|
1584
|
-
e_since, e_until = day, day + timedelta(days=1)
|
|
1585
|
-
else:
|
|
1586
|
-
e_since, e_until = since, until
|
|
1587
|
-
except ValueError as e:
|
|
1588
|
-
print(str(e), file=sys.stderr)
|
|
1589
|
-
return 1
|
|
1590
|
-
report_file = Path(args.file) if args.file else None
|
|
1591
|
-
if report_file and not report_file.exists():
|
|
1592
|
-
print(f"File not found: {report_file}", file=sys.stderr)
|
|
1593
|
-
return 1
|
|
1594
|
-
if report_file and e_since is None:
|
|
1595
|
-
# Window defaults to the file's own first→last user prompt.
|
|
1596
|
-
evs, _ = _engagement_event_streams(report_file, None, None)
|
|
1597
|
-
if not evs:
|
|
1598
|
-
print("(no user messages in this session)")
|
|
1599
|
-
return 0
|
|
1600
|
-
e_since = evs[0]
|
|
1601
|
-
e_until = e_until or evs[-1] + timedelta(seconds=1)
|
|
1602
|
-
else:
|
|
1603
|
-
e_since = e_since or P.parse_timespec("today")
|
|
1604
|
-
e_until = e_until or P.parse_timespec("now")
|
|
1605
|
-
# Scope filters reporting only; the attention stream is always global.
|
|
1606
|
-
report_dirs = None
|
|
1607
|
-
if not report_file:
|
|
1608
|
-
report_dirs = _scoped_project_dirs(
|
|
1609
|
-
root, args.cwd, args.all_projects, args.project, default_all=True
|
|
1610
|
-
)
|
|
1611
|
-
data = build_engagement(
|
|
1612
|
-
root, report_dirs, report_file, e_since, e_until, break_minutes,
|
|
1613
|
-
current_uuid, exclude_current=args.exclude_current,
|
|
1614
|
-
)
|
|
1615
|
-
if fmt == "json":
|
|
1616
|
-
_print_json(engagement_json(data))
|
|
1617
|
-
else:
|
|
1618
|
-
print(render_engagement(data, tz_label=args.tz or "local"))
|
|
1619
|
-
return 0
|
|
1620
|
-
if mode == "diff":
|
|
1621
|
-
if args.subagents_of:
|
|
1622
|
-
parent = Path(args.subagents_of)
|
|
1623
|
-
subs = P.list_subagents(parent)
|
|
1624
|
-
if len(subs) < 2:
|
|
1625
|
-
print("Need ≥2 subagents to diff.")
|
|
1626
|
-
return 1
|
|
1627
|
-
_emit(mode_diff(subs[0], subs[1], fmt=fmt))
|
|
1628
|
-
return 0
|
|
1629
|
-
if not (args.file_a and args.file_b):
|
|
1630
|
-
print("--file-a and --file-b required for diff (or --subagents-of)", file=sys.stderr)
|
|
1631
|
-
return 1
|
|
1632
|
-
_emit(mode_diff(Path(args.file_a), Path(args.file_b), fmt=fmt))
|
|
1633
|
-
return 0
|
|
1634
|
-
# (cwd-scoped searches with non-default role/in/json land here — the
|
|
1635
|
-
# byte-compat legacy path above already handled the default-flag case.)
|
|
1636
|
-
if mode == "search" and (args.file or args.all_projects or args.project or args.cwd):
|
|
1637
|
-
if not args.query:
|
|
1638
|
-
print("--query required", file=sys.stderr)
|
|
1639
|
-
return 1
|
|
1640
|
-
fp = Path(args.file) if args.file else None
|
|
1641
|
-
_emit(mode_search_v2(root, args.cwd, args.all_projects, fp, args.query,
|
|
1642
|
-
args.role, args.in_channel, since, until,
|
|
1643
|
-
project=args.project, fmt=fmt,
|
|
1644
|
-
exclude_current=args.exclude_current,
|
|
1645
|
-
current_uuid=current_uuid))
|
|
1646
|
-
return 0
|
|
1647
|
-
|
|
1648
|
-
# File-required modes
|
|
1649
|
-
if mode == "subagent-tools":
|
|
1650
|
-
if not args.subagent:
|
|
1651
|
-
print("--subagent required", file=sys.stderr)
|
|
1652
|
-
return 1
|
|
1653
|
-
sp = Path(args.subagent)
|
|
1654
|
-
_emit(mode_tool_calls(sp, _split_tools(args.tool), fmt=fmt))
|
|
1655
|
-
return 0
|
|
1656
|
-
if mode == "subagent-files":
|
|
1657
|
-
if not args.subagent:
|
|
1658
|
-
print("--subagent required", file=sys.stderr)
|
|
1659
|
-
return 1
|
|
1660
|
-
sp = Path(args.subagent)
|
|
1661
|
-
_emit(mode_file_edits(sp, fmt=fmt))
|
|
1662
|
-
return 0
|
|
1663
|
-
|
|
1664
|
-
if not args.file:
|
|
1665
|
-
print("--file required (or use a discovery mode)", file=sys.stderr)
|
|
1666
|
-
return 1
|
|
1667
|
-
|
|
1668
|
-
path = Path(args.file)
|
|
1669
|
-
if not path.exists():
|
|
1670
|
-
print(f"File not found: {path}", file=sys.stderr)
|
|
1671
|
-
return 1
|
|
1672
|
-
|
|
1673
|
-
if mode == "brief":
|
|
1674
|
-
_emit(mode_brief(path, args.include_subagents, current_uuid, fmt=fmt))
|
|
1675
|
-
return 0
|
|
1676
|
-
if mode == "subagent-list":
|
|
1677
|
-
_emit(mode_subagent_list(path, fmt=fmt))
|
|
1678
|
-
return 0
|
|
1679
|
-
if mode == "subagent-finals":
|
|
1680
|
-
_emit(mode_subagent_finals(path, fmt=fmt))
|
|
1681
|
-
return 0
|
|
1682
|
-
if mode == "changelog":
|
|
1683
|
-
_emit(mode_changelog(path, fmt=fmt))
|
|
1684
|
-
return 0
|
|
1685
|
-
if mode == "file-edits":
|
|
1686
|
-
_emit(mode_file_edits(path, fmt=fmt))
|
|
1687
|
-
return 0
|
|
1688
|
-
if mode == "tool-calls":
|
|
1689
|
-
_emit(mode_tool_calls(path, _split_tools(args.tool), fmt=fmt))
|
|
1690
|
-
return 0
|
|
1691
|
-
|
|
1692
|
-
# Legacy single-file modes
|
|
1693
|
-
lines = PR.parse_lines(path)
|
|
1694
|
-
|
|
1695
|
-
if fmt == "json":
|
|
1696
|
-
if mode == "last":
|
|
1697
|
-
_print_json(json_last(lines, args.n))
|
|
1698
|
-
elif mode == "advisor":
|
|
1699
|
-
_print_json(json_advisor(lines))
|
|
1700
|
-
elif mode == "pre-compact":
|
|
1701
|
-
_print_json(json_pre_compact(lines))
|
|
1702
|
-
elif mode == "dump":
|
|
1703
|
-
_print_json(json_dump(lines, max(args.n, 80) if args.n != 5 else 80))
|
|
1704
|
-
elif mode == "debug":
|
|
1705
|
-
_print_json(json_debug(lines))
|
|
1706
|
-
return 0
|
|
1707
|
-
|
|
1708
|
-
print(f"[{path.name} — {len(lines)} entries]\n")
|
|
1709
|
-
|
|
1710
|
-
if mode == "last":
|
|
1711
|
-
body = mode_last(lines, args.n)
|
|
1712
|
-
if args.include_subagents:
|
|
1713
|
-
body += _append_subagents(path)
|
|
1714
|
-
print(body)
|
|
1715
|
-
elif mode == "advisor":
|
|
1716
|
-
print(mode_advisor(lines))
|
|
1717
|
-
elif mode == "pre-compact":
|
|
1718
|
-
print(mode_pre_compact(lines))
|
|
1719
|
-
elif mode == "dump":
|
|
1720
|
-
size = path.stat().st_size
|
|
1721
|
-
if size > PR.LARGE_FILE_THRESHOLD and not args.force_dump:
|
|
1722
|
-
has_compact = any(m["is_compact"] for m in PR.get_messages(lines))
|
|
1723
|
-
fallback = "pre-compact" if has_compact else "last"
|
|
1724
|
-
mb = size / (1024 * 1024)
|
|
1725
|
-
print(
|
|
1726
|
-
f"[note: transcript is {mb:.1f}MB — degraded to {fallback}. "
|
|
1727
|
-
f"Override with --force-dump.]",
|
|
1728
|
-
file=sys.stderr,
|
|
1729
|
-
)
|
|
1730
|
-
if fallback == "pre-compact":
|
|
1731
|
-
print(mode_pre_compact(lines))
|
|
1732
|
-
else:
|
|
1733
|
-
print(mode_last(lines, 10))
|
|
1734
|
-
else:
|
|
1735
|
-
body = mode_dump(lines, max(args.n, 80) if args.n != 5 else 80)
|
|
1736
|
-
if args.include_subagents:
|
|
1737
|
-
body += _append_subagents(path)
|
|
1738
|
-
print(body)
|
|
1739
|
-
elif mode == "search":
|
|
1740
|
-
if not args.query:
|
|
1741
|
-
print("--query required with --mode search", file=sys.stderr)
|
|
1742
|
-
return 1
|
|
1743
|
-
result = mode_search_legacy(lines, args.query)
|
|
1744
|
-
print(result if result is not None
|
|
1745
|
-
else f"No assistant messages containing '{args.query}' found.")
|
|
1746
|
-
elif mode == "debug":
|
|
1747
|
-
print(mode_debug(lines))
|
|
1748
|
-
|
|
1749
|
-
return 0
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
def _split_tools(s: str | None) -> set[str] | None:
|
|
1753
|
-
if not s:
|
|
1754
|
-
return None
|
|
1755
|
-
return {t.strip() for t in s.split(",") if t.strip()}
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
def _append_subagents(parent_path: Path) -> str:
|
|
1759
|
-
finals = SA.agent_finals(parent_path)
|
|
1760
|
-
if not finals:
|
|
1761
|
-
return ""
|
|
1762
|
-
parts = ["\n"]
|
|
1763
|
-
for agent_id, meta, text in finals:
|
|
1764
|
-
atype = meta.get("agentType", "unknown")
|
|
1765
|
-
short = agent_id.replace("agent-", "")[:8]
|
|
1766
|
-
tail = (text[:1500] + "…") if len(text) > 1500 else text
|
|
1767
|
-
parts.append(f"\n[subagent {short} · {atype}]\n{tail}")
|
|
1768
|
-
return "\n".join(parts)
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
if __name__ == "__main__":
|
|
1772
|
-
try:
|
|
1773
|
-
sys.exit(main())
|
|
1774
|
-
except FileNotFoundError as e:
|
|
1775
|
-
print(str(e), file=sys.stderr)
|
|
1776
|
-
sys.exit(1)
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract signal from Claude Code session transcripts.
|
|
3
|
+
|
|
4
|
+
See SKILL.md for the full mode reference. Legacy flags from the v1 script
|
|
5
|
+
(``--list``, ``--list-subagents``, ``--mode {last,advisor,pre-compact,dump,search,debug}``)
|
|
6
|
+
remain byte-compatible.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
from datetime import datetime, timedelta
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
# Force UTF-8 output on Windows for emoji and non-ASCII content.
|
|
19
|
+
if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
|
|
20
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
21
|
+
if sys.stderr.encoding and sys.stderr.encoding.lower() != "utf-8":
|
|
22
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
23
|
+
|
|
24
|
+
# Make `from lib.* import …` work when run as a script.
|
|
25
|
+
_THIS_DIR = Path(__file__).resolve().parent
|
|
26
|
+
if str(_THIS_DIR) not in sys.path:
|
|
27
|
+
sys.path.insert(0, str(_THIS_DIR))
|
|
28
|
+
|
|
29
|
+
from lib import paths as P # noqa: E402
|
|
30
|
+
from lib import parser as PR # noqa: E402
|
|
31
|
+
from lib import tools as T # noqa: E402
|
|
32
|
+
from lib import search as S # noqa: E402
|
|
33
|
+
from lib import subagents as SA # noqa: E402
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
37
|
+
# Legacy mode implementations (kept byte-identical to v1 for backwards-compat)
|
|
38
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
def mode_last(lines, n=5):
|
|
41
|
+
messages = PR.get_messages(lines)
|
|
42
|
+
assistant_msgs = [m for m in messages if m["role"] == "assistant" and m["texts"]]
|
|
43
|
+
recent = assistant_msgs[-n:]
|
|
44
|
+
output = []
|
|
45
|
+
for i, m in enumerate(recent, 1):
|
|
46
|
+
output.append(f"=== Assistant message -{len(recent) - i + 1} from end ===")
|
|
47
|
+
output.append("\n".join(m["texts"]))
|
|
48
|
+
return "\n\n".join(output) if output else "No assistant messages found."
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def mode_advisor(lines):
|
|
52
|
+
results = []
|
|
53
|
+
for obj in lines:
|
|
54
|
+
if obj.get("type") in PR.NOISE_TYPES:
|
|
55
|
+
continue
|
|
56
|
+
msg = obj.get("message", {})
|
|
57
|
+
if not isinstance(msg.get("content"), list):
|
|
58
|
+
continue
|
|
59
|
+
for block in msg["content"]:
|
|
60
|
+
if block.get("type") == "advisor_tool_result":
|
|
61
|
+
inner = block.get("content", {})
|
|
62
|
+
if isinstance(inner, dict) and inner.get("text"):
|
|
63
|
+
results.append(inner["text"])
|
|
64
|
+
if not results:
|
|
65
|
+
return "No advisor calls found in this transcript."
|
|
66
|
+
output = []
|
|
67
|
+
for i, r in enumerate(results, 1):
|
|
68
|
+
output.append(f"=== Advisor response #{i} ===\n{r}")
|
|
69
|
+
return "\n\n".join(output)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def mode_pre_compact(lines, window=40):
|
|
73
|
+
messages = PR.get_messages(lines)
|
|
74
|
+
compact_idx = None
|
|
75
|
+
for i, m in enumerate(messages):
|
|
76
|
+
if m["is_compact"]:
|
|
77
|
+
compact_idx = i
|
|
78
|
+
if compact_idx is None:
|
|
79
|
+
return (
|
|
80
|
+
"No /compact found in this transcript. Showing last messages instead.\n\n"
|
|
81
|
+
+ mode_last(lines, 10)
|
|
82
|
+
)
|
|
83
|
+
start = max(0, compact_idx - window)
|
|
84
|
+
pre = messages[start:compact_idx]
|
|
85
|
+
output = [f"--- Pre-compact content ({len(pre)} exchanges before /compact) ---\n"]
|
|
86
|
+
for m in pre:
|
|
87
|
+
if m["texts"]:
|
|
88
|
+
role_label = "USER" if m["role"] == "user" else "ASSISTANT"
|
|
89
|
+
output.append(f"[{role_label}]\n" + "\n".join(m["texts"]))
|
|
90
|
+
return "\n\n".join(output) if output else "No content found before compact."
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def mode_dump(lines, limit=80):
|
|
94
|
+
messages = PR.get_messages(lines)
|
|
95
|
+
with_text = [m for m in messages if m["texts"]]
|
|
96
|
+
recent = with_text[-limit:]
|
|
97
|
+
output = [f"--- Conversation dump (last {len(recent)} messages with text) ---\n"]
|
|
98
|
+
for m in recent:
|
|
99
|
+
if m["is_compact"]:
|
|
100
|
+
output.append("\n--- /COMPACT ---\n")
|
|
101
|
+
continue
|
|
102
|
+
role_label = "USER" if m["role"] == "user" else "ASSISTANT"
|
|
103
|
+
text = "\n".join(m["texts"])
|
|
104
|
+
if len(text) > 1500:
|
|
105
|
+
text = text[:1500] + "\n[...truncated...]"
|
|
106
|
+
output.append(f"[{role_label}]\n{text}")
|
|
107
|
+
return "\n\n".join(output)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def mode_search_legacy(lines, query: str):
|
|
111
|
+
"""Legacy single-file search: assistant text only, case-insensitive."""
|
|
112
|
+
messages = PR.get_messages(lines)
|
|
113
|
+
results = []
|
|
114
|
+
q = query.lower()
|
|
115
|
+
for m in messages:
|
|
116
|
+
if m["role"] == "assistant":
|
|
117
|
+
combined = " ".join(m["texts"])
|
|
118
|
+
if q in combined.lower():
|
|
119
|
+
results.append(combined)
|
|
120
|
+
if not results:
|
|
121
|
+
return None
|
|
122
|
+
output = [f"=== {len(results)} match(es) for '{query}' ===\n"]
|
|
123
|
+
for i, r in enumerate(results, 1):
|
|
124
|
+
output.append(f"--- Match #{i} ---\n{r[:1500]}")
|
|
125
|
+
return "\n\n".join(output)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def mode_debug(lines):
|
|
129
|
+
output = []
|
|
130
|
+
type_counts: dict[str, int] = {}
|
|
131
|
+
for obj in lines:
|
|
132
|
+
t = obj.get("type", "<missing>")
|
|
133
|
+
type_counts[t] = type_counts.get(t, 0) + 1
|
|
134
|
+
output.append("=== Entry type distribution ===")
|
|
135
|
+
for t, count in sorted(type_counts.items(), key=lambda x: -x[1]):
|
|
136
|
+
marker = (
|
|
137
|
+
" [NOISE - skipped]" if t in PR.NOISE_TYPES
|
|
138
|
+
else " [SIGNAL]" if t in ("user", "assistant") else ""
|
|
139
|
+
)
|
|
140
|
+
output.append(f" {count:4d} {t}{marker}")
|
|
141
|
+
|
|
142
|
+
block_type_counts: dict[str, int] = {}
|
|
143
|
+
signal_entries = [o for o in lines if o.get("type") not in PR.NOISE_TYPES]
|
|
144
|
+
for obj in signal_entries:
|
|
145
|
+
content = obj.get("message", {}).get("content", [])
|
|
146
|
+
if isinstance(content, list):
|
|
147
|
+
for block in content:
|
|
148
|
+
if isinstance(block, dict):
|
|
149
|
+
bt = block.get("type", "<missing>")
|
|
150
|
+
block_type_counts[bt] = block_type_counts.get(bt, 0) + 1
|
|
151
|
+
|
|
152
|
+
output.append("\n=== Content block types (across all signal messages) ===")
|
|
153
|
+
if block_type_counts:
|
|
154
|
+
for bt, count in sorted(block_type_counts.items(), key=lambda x: -x[1]):
|
|
155
|
+
note = ""
|
|
156
|
+
if bt == "advisor_tool_result":
|
|
157
|
+
note = " ← advisor responses live here"
|
|
158
|
+
elif bt == "text":
|
|
159
|
+
note = " ← assistant/user text"
|
|
160
|
+
elif bt == "tool_use":
|
|
161
|
+
note = " ← regular tool calls"
|
|
162
|
+
elif bt == "server_tool_use":
|
|
163
|
+
note = " ← server-side tools (advisor calls)"
|
|
164
|
+
output.append(f" {count:4d} {bt}{note}")
|
|
165
|
+
else:
|
|
166
|
+
output.append(" (no block-structured content found — content may be plain strings)")
|
|
167
|
+
|
|
168
|
+
output.append("\n=== Advisor result probe ===")
|
|
169
|
+
advisor_found = []
|
|
170
|
+
for obj in lines:
|
|
171
|
+
msg = obj.get("message", {})
|
|
172
|
+
content = msg.get("content", [])
|
|
173
|
+
if not isinstance(content, list):
|
|
174
|
+
continue
|
|
175
|
+
for block in content:
|
|
176
|
+
if not isinstance(block, dict):
|
|
177
|
+
continue
|
|
178
|
+
if block.get("type") == "advisor_tool_result":
|
|
179
|
+
inner = block.get("content", {})
|
|
180
|
+
has_text = isinstance(inner, dict) and bool(inner.get("text"))
|
|
181
|
+
advisor_found.append({
|
|
182
|
+
"outer_keys": list(block.keys()),
|
|
183
|
+
"inner_type": type(inner).__name__,
|
|
184
|
+
"inner_keys": list(inner.keys()) if isinstance(inner, dict) else "N/A",
|
|
185
|
+
"has_text": has_text,
|
|
186
|
+
})
|
|
187
|
+
if advisor_found:
|
|
188
|
+
output.append(f" Found {len(advisor_found)} advisor_tool_result block(s)")
|
|
189
|
+
for i, a in enumerate(advisor_found[:3], 1):
|
|
190
|
+
output.append(
|
|
191
|
+
f" Block #{i}: outer_keys={a['outer_keys']}, "
|
|
192
|
+
f"inner={a['inner_type']}({a['inner_keys']}), has_text={a['has_text']}"
|
|
193
|
+
)
|
|
194
|
+
else:
|
|
195
|
+
output.append(" No advisor_tool_result blocks found.")
|
|
196
|
+
output.append(" → If you expected advisor output, the block type name may have changed.")
|
|
197
|
+
|
|
198
|
+
output.append("\n=== Compact marker probe ===")
|
|
199
|
+
compact_hits = []
|
|
200
|
+
for obj in lines:
|
|
201
|
+
msg = obj.get("message", {})
|
|
202
|
+
if msg.get("role") != "user":
|
|
203
|
+
continue
|
|
204
|
+
content = msg.get("content", "")
|
|
205
|
+
text = content if isinstance(content, str) else " ".join(
|
|
206
|
+
b.get("text", "") for b in content
|
|
207
|
+
if isinstance(b, dict) and b.get("type") == "text"
|
|
208
|
+
)
|
|
209
|
+
if PR.COMPACT_MARKER in text:
|
|
210
|
+
compact_hits.append(obj.get("type", "?"))
|
|
211
|
+
if compact_hits:
|
|
212
|
+
output.append(f" Found {len(compact_hits)} compact marker(s) in entry type(s): {compact_hits}")
|
|
213
|
+
else:
|
|
214
|
+
output.append(" No compact markers found in this transcript.")
|
|
215
|
+
|
|
216
|
+
output.append("\n=== Sample assistant messages (first 3 with text) ===")
|
|
217
|
+
messages = PR.get_messages(lines)
|
|
218
|
+
samples = [m for m in messages if m["role"] == "assistant" and m["texts"]][:3]
|
|
219
|
+
if samples:
|
|
220
|
+
for i, m in enumerate(samples, 1):
|
|
221
|
+
preview = m["texts"][0][:200].replace("\n", " ")
|
|
222
|
+
output.append(
|
|
223
|
+
f" [{i}] \"{preview}{'...' if len(m['texts'][0]) > 200 else ''}\""
|
|
224
|
+
)
|
|
225
|
+
else:
|
|
226
|
+
output.append(" No assistant messages with text found.")
|
|
227
|
+
output.append(" → Check that get_messages() is correctly identifying signal entries.")
|
|
228
|
+
|
|
229
|
+
return "\n".join(output)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
233
|
+
# New v2 modes
|
|
234
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
def _fmt_mtime(mtime: float) -> str:
|
|
237
|
+
return PR.epoch_to_display(mtime).strftime("%Y-%m-%d %H:%M")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _print_json(data) -> None:
|
|
241
|
+
print(json.dumps(data, indent=2, ensure_ascii=False, default=str))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _emit(result) -> None:
|
|
245
|
+
"""Print a mode result: strings as-is, anything else as JSON."""
|
|
246
|
+
if isinstance(result, str):
|
|
247
|
+
print(result)
|
|
248
|
+
else:
|
|
249
|
+
_print_json(result)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _summary_json(s: dict) -> dict:
|
|
253
|
+
"""JSON-safe copy of a session_summary dict."""
|
|
254
|
+
out = dict(s)
|
|
255
|
+
out["path"] = str(s.get("path", ""))
|
|
256
|
+
out["files_touched"] = [str(p) for p in s.get("files_touched", [])]
|
|
257
|
+
if s.get("mtime"):
|
|
258
|
+
out["mtime_iso"] = _fmt_mtime(s["mtime"])
|
|
259
|
+
return out
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _ts_iso(raw) -> str | None:
|
|
263
|
+
"""Raw JSONL timestamp (UTC) → display-timezone ISO string."""
|
|
264
|
+
ts = PR._parse_timestamp(raw)
|
|
265
|
+
if ts is not None:
|
|
266
|
+
return ts.isoformat()
|
|
267
|
+
return raw if isinstance(raw, str) else None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _messages_json(messages: list[dict]) -> list[dict]:
|
|
271
|
+
return [
|
|
272
|
+
{
|
|
273
|
+
"role": m["role"],
|
|
274
|
+
"timestamp": _ts_iso(m.get("timestamp")),
|
|
275
|
+
"is_compact": m["is_compact"],
|
|
276
|
+
"text": "\n".join(m["texts"]),
|
|
277
|
+
}
|
|
278
|
+
for m in messages
|
|
279
|
+
if m["texts"] or m["is_compact"]
|
|
280
|
+
]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
_STATUS_GLYPH = {
|
|
284
|
+
"clean": "✓", "interrupted": "!", "pending-user": "?", "active": "●",
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def list_session_row(
|
|
289
|
+
summary: dict, show_project: bool = False, current_uuid: str | None = None
|
|
290
|
+
) -> str:
|
|
291
|
+
mtime = _fmt_mtime(summary["mtime"])
|
|
292
|
+
size_kb = summary["size"] / 1024
|
|
293
|
+
uuid_short = summary["uuid"][:8]
|
|
294
|
+
msg_n = summary.get("msg_count", 0)
|
|
295
|
+
flags = ""
|
|
296
|
+
if summary.get("has_compact"):
|
|
297
|
+
flags += "[C]"
|
|
298
|
+
if summary.get("has_subagents"):
|
|
299
|
+
flags += "[S]"
|
|
300
|
+
flags = flags or " "
|
|
301
|
+
status = _STATUS_GLYPH.get(summary.get("status", "clean"), "?")
|
|
302
|
+
marker = "[*]" if summary.get("is_current") else " "
|
|
303
|
+
proj = ""
|
|
304
|
+
if show_project:
|
|
305
|
+
proj = f" {summary.get('decoded_project', summary.get('cwd', ''))}"
|
|
306
|
+
title = summary.get("title") or summary.get("first_prompt", "")
|
|
307
|
+
title = title[:80]
|
|
308
|
+
return (
|
|
309
|
+
f"{marker} {mtime} {size_kb:6.0f}KB {uuid_short} msgs={msg_n:<4} "
|
|
310
|
+
f"{flags} {status}{proj} {title}"
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def mode_list_legacy(cwd: str, root: Path) -> str:
|
|
315
|
+
"""Original v1 list output — preserved byte-identically."""
|
|
316
|
+
project_dir = P.find_project_dir(cwd, root)
|
|
317
|
+
files = P.list_transcripts(project_dir)
|
|
318
|
+
if not files:
|
|
319
|
+
return "No transcript files found."
|
|
320
|
+
out = [f"Transcripts for {cwd}:"]
|
|
321
|
+
for f in files:
|
|
322
|
+
size_kb = f.stat().st_size / 1024
|
|
323
|
+
mtime = _fmt_mtime(f.stat().st_mtime)
|
|
324
|
+
out.append(f" {mtime} {size_kb:6.0f}KB {f.name}")
|
|
325
|
+
return "\n".join(out)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _scoped_project_dirs(
|
|
329
|
+
root: Path,
|
|
330
|
+
cwd: str | None,
|
|
331
|
+
all_projects: bool,
|
|
332
|
+
project: str | None,
|
|
333
|
+
default_all: bool = False,
|
|
334
|
+
) -> list[Path] | None:
|
|
335
|
+
"""Resolve --cwd / --all-projects / --project into project directories.
|
|
336
|
+
|
|
337
|
+
--project wins (name filter across all projects under root); then --cwd;
|
|
338
|
+
then --all-projects (or default_all). Returns None when no scope was given.
|
|
339
|
+
"""
|
|
340
|
+
if project:
|
|
341
|
+
dirs = P.filter_projects(root, project)
|
|
342
|
+
if not dirs:
|
|
343
|
+
raise FileNotFoundError(f"No project directory matches --project {project!r}")
|
|
344
|
+
return dirs
|
|
345
|
+
if cwd:
|
|
346
|
+
return [P.find_project_dir(cwd, root)]
|
|
347
|
+
if all_projects or default_all:
|
|
348
|
+
return P.list_projects(root)
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def mode_list(
|
|
353
|
+
root: Path,
|
|
354
|
+
cwd: str | None,
|
|
355
|
+
all_projects: bool,
|
|
356
|
+
since: datetime | None,
|
|
357
|
+
until: datetime | None,
|
|
358
|
+
exclude_current: bool,
|
|
359
|
+
current_uuid: str | None,
|
|
360
|
+
project: str | None = None,
|
|
361
|
+
fmt: str = "text",
|
|
362
|
+
):
|
|
363
|
+
"""Enriched v2 list — columns: marker, mtime, size, uuid-short, msgs, flags, status, project, title."""
|
|
364
|
+
project_dirs = _scoped_project_dirs(root, cwd, all_projects, project)
|
|
365
|
+
if project_dirs is None:
|
|
366
|
+
return "--cwd, --project, or --all-projects required"
|
|
367
|
+
rows = []
|
|
368
|
+
for pd in project_dirs:
|
|
369
|
+
for f in P.list_transcripts(pd, since=since, until=until):
|
|
370
|
+
summary = PR.session_summary(f, current_session_id=current_uuid)
|
|
371
|
+
if exclude_current and summary.get("is_current"):
|
|
372
|
+
continue
|
|
373
|
+
rows.append(summary)
|
|
374
|
+
rows.sort(key=lambda s: s["mtime"], reverse=True)
|
|
375
|
+
if fmt == "json":
|
|
376
|
+
return [_summary_json(r) for r in rows]
|
|
377
|
+
if not rows:
|
|
378
|
+
return "No transcript files found."
|
|
379
|
+
show_proj = all_projects or bool(project) or len(project_dirs) > 1
|
|
380
|
+
return "\n".join(list_session_row(r, show_proj, current_uuid) for r in rows)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def mode_lookup(uuid_prefix: str, root: Path, fmt: str = "text") -> tuple[int, object]:
|
|
384
|
+
"""Resolve a UUID prefix to an absolute path. Returns (exit_code, output)."""
|
|
385
|
+
if not uuid_prefix:
|
|
386
|
+
return 1, ({"error": "--uuid required"} if fmt == "json" else "--uuid required")
|
|
387
|
+
matches: list[Path] = []
|
|
388
|
+
for pd in P.list_projects(root):
|
|
389
|
+
for f in P.list_transcripts(pd):
|
|
390
|
+
if f.stem.startswith(uuid_prefix):
|
|
391
|
+
matches.append(f)
|
|
392
|
+
if fmt == "json":
|
|
393
|
+
code = 0 if len(matches) == 1 else (1 if not matches else 2)
|
|
394
|
+
return code, {
|
|
395
|
+
"prefix": uuid_prefix,
|
|
396
|
+
"path": str(matches[0]) if len(matches) == 1 else None,
|
|
397
|
+
"matches": [str(m) for m in matches],
|
|
398
|
+
}
|
|
399
|
+
if not matches:
|
|
400
|
+
return 1, f"No session found with UUID prefix: {uuid_prefix}"
|
|
401
|
+
if len(matches) > 1:
|
|
402
|
+
return 2, (
|
|
403
|
+
f"Ambiguous prefix {uuid_prefix!r} matches {len(matches)} sessions:\n"
|
|
404
|
+
+ "\n".join(str(m) for m in matches)
|
|
405
|
+
)
|
|
406
|
+
return 0, str(matches[0])
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def mode_find(
|
|
410
|
+
root: Path,
|
|
411
|
+
title_q: str | None,
|
|
412
|
+
first_prompt_q: str | None,
|
|
413
|
+
current_uuid: str | None,
|
|
414
|
+
project: str | None = None,
|
|
415
|
+
fmt: str = "text",
|
|
416
|
+
):
|
|
417
|
+
"""Search session metadata by title or first prompt."""
|
|
418
|
+
if not (title_q or first_prompt_q):
|
|
419
|
+
return "--title or --first-prompt required"
|
|
420
|
+
project_dirs = _scoped_project_dirs(root, None, False, project, default_all=True)
|
|
421
|
+
rows = []
|
|
422
|
+
for pd in project_dirs:
|
|
423
|
+
for f in P.list_transcripts(pd):
|
|
424
|
+
summary = PR.session_summary(f, current_session_id=current_uuid)
|
|
425
|
+
hit = False
|
|
426
|
+
if title_q and title_q.lower() in (summary.get("title", "") or "").lower():
|
|
427
|
+
hit = True
|
|
428
|
+
if first_prompt_q and first_prompt_q.lower() in (summary.get("first_prompt", "") or "").lower():
|
|
429
|
+
hit = True
|
|
430
|
+
if hit:
|
|
431
|
+
rows.append(summary)
|
|
432
|
+
rows.sort(key=lambda s: s["mtime"], reverse=True)
|
|
433
|
+
if fmt == "json":
|
|
434
|
+
return [_summary_json(r) for r in rows]
|
|
435
|
+
if not rows:
|
|
436
|
+
return "No sessions matched."
|
|
437
|
+
return "\n".join(list_session_row(r, show_project=True) for r in rows)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def mode_resume_cmd(uuid_prefix: str, root: Path, fmt: str = "text") -> tuple[int, object]:
|
|
441
|
+
"""Generate `cd <cwd>; claude --resume <uuid>` for a UUID prefix."""
|
|
442
|
+
code, out = mode_lookup(uuid_prefix, root)
|
|
443
|
+
if code != 0:
|
|
444
|
+
if fmt == "json":
|
|
445
|
+
return code, {"error": out}
|
|
446
|
+
return code, out
|
|
447
|
+
path = Path(out)
|
|
448
|
+
encoded = path.parent.name
|
|
449
|
+
# Best-effort decode for cwd guess: we have the encoded form, the raw cwd
|
|
450
|
+
# cannot be unambiguously recovered, so emit a comment with the encoded name.
|
|
451
|
+
decoded = P.decode_project_name(encoded)
|
|
452
|
+
if fmt == "json":
|
|
453
|
+
return 0, {
|
|
454
|
+
"uuid": path.stem,
|
|
455
|
+
"path": str(path),
|
|
456
|
+
"project": decoded,
|
|
457
|
+
"encoded": encoded,
|
|
458
|
+
"command": f'cd "<original cwd>"; claude --resume {path.stem}',
|
|
459
|
+
}
|
|
460
|
+
return 0, (
|
|
461
|
+
f'# project: {decoded}\n'
|
|
462
|
+
f'# encoded: {encoded}\n'
|
|
463
|
+
f'cd "<original cwd>"; claude --resume {path.stem}'
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def mode_brief(
|
|
468
|
+
path: Path, include_subagents: bool, current_uuid: str | None, fmt: str = "text"
|
|
469
|
+
):
|
|
470
|
+
"""6-line single-session summary for fan-out triage."""
|
|
471
|
+
summary = PR.session_summary(path, current_session_id=current_uuid)
|
|
472
|
+
if fmt == "json":
|
|
473
|
+
data = _summary_json(summary)
|
|
474
|
+
if include_subagents and summary.get("subagent_count"):
|
|
475
|
+
data["subagent_finals"] = [
|
|
476
|
+
{"id": agent_id, "agentType": meta.get("agentType", "unknown"), "text": text}
|
|
477
|
+
for agent_id, meta, text in SA.agent_finals(path)
|
|
478
|
+
]
|
|
479
|
+
return data
|
|
480
|
+
if not summary.get("exists"):
|
|
481
|
+
return f"File not found: {path}"
|
|
482
|
+
star = " [*]" if summary["is_current"] else ""
|
|
483
|
+
status = summary["status"]
|
|
484
|
+
line1 = f"{summary['uuid']} · {summary['decoded_project']} · {_fmt_mtime(summary['mtime'])} · {status}{star}"
|
|
485
|
+
line2 = f"intent: {summary['first_prompt'] or '(no user prompts)'}"
|
|
486
|
+
line3 = f"last: {summary['last_assistant'] or '(no assistant messages)'}"
|
|
487
|
+
files = summary["files_touched"][:3]
|
|
488
|
+
files_str = ", ".join(str(f) for f in files) or "(none)"
|
|
489
|
+
line4 = f"edits: {summary['edit_count']} files — {files_str}"
|
|
490
|
+
tools = summary["tool_counts"]
|
|
491
|
+
tools_str = " ".join(f"{k}={v}" for k, v in sorted(tools.items(), key=lambda x: -x[1])) or "(none)"
|
|
492
|
+
line5 = f"tools: {tools_str}"
|
|
493
|
+
sa_types = summary["subagent_types"]
|
|
494
|
+
sa_types_str = ""
|
|
495
|
+
if sa_types:
|
|
496
|
+
sa_types_str = " [" + " ".join(f"{k}={v}" for k, v in sorted(sa_types.items(), key=lambda x: -x[1])) + "]"
|
|
497
|
+
line6 = f"subagents: {summary['subagent_count']} spawned{sa_types_str}"
|
|
498
|
+
out = "\n".join([line1, line2, line3, line4, line5, line6])
|
|
499
|
+
|
|
500
|
+
if include_subagents and summary["subagent_count"]:
|
|
501
|
+
out += "\n"
|
|
502
|
+
for agent_id, meta, text in SA.agent_finals(path):
|
|
503
|
+
atype = meta.get("agentType", "unknown")
|
|
504
|
+
short = agent_id.replace("agent-", "")[:8]
|
|
505
|
+
tail = (text[:1500] + "…") if len(text) > 1500 else text
|
|
506
|
+
out += f"\n[subagent {short} · {atype}]\n{tail}\n"
|
|
507
|
+
return out
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _tool_calls_json(path: Path, tool_filter: set[str] | None, include_input: bool) -> list[dict]:
|
|
511
|
+
lines = PR.parse_lines(path)
|
|
512
|
+
calls = T.extract_tool_calls(lines, tool_filter)
|
|
513
|
+
out = []
|
|
514
|
+
for c in calls:
|
|
515
|
+
ts = PR._parse_timestamp(c.get("timestamp"))
|
|
516
|
+
row = {
|
|
517
|
+
"timestamp": ts.isoformat() if ts else None,
|
|
518
|
+
"tool": c["name"],
|
|
519
|
+
"summary": T.format_tool_call(c),
|
|
520
|
+
}
|
|
521
|
+
if include_input:
|
|
522
|
+
row["input"] = c.get("input", {})
|
|
523
|
+
out.append(row)
|
|
524
|
+
return out
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def mode_changelog(path: Path, fmt: str = "text"):
|
|
528
|
+
"""`HH:MM:SS TOOL one-line-summary`, day-grouped."""
|
|
529
|
+
if fmt == "json":
|
|
530
|
+
return _tool_calls_json(path, None, include_input=False)
|
|
531
|
+
lines = PR.parse_lines(path)
|
|
532
|
+
calls = T.extract_tool_calls(lines)
|
|
533
|
+
if not calls:
|
|
534
|
+
return "No tool calls found in this session."
|
|
535
|
+
out: list[str] = []
|
|
536
|
+
last_day = None
|
|
537
|
+
for c in calls:
|
|
538
|
+
ts = PR._parse_timestamp(c.get("timestamp"))
|
|
539
|
+
day = ts.strftime("%Y-%m-%d") if ts else "unknown-date"
|
|
540
|
+
time = ts.strftime("%H:%M:%S") if ts else "??:??:??"
|
|
541
|
+
if day != last_day:
|
|
542
|
+
out.append(f"\n=== {day} ===")
|
|
543
|
+
last_day = day
|
|
544
|
+
out.append(f" {time} {T.format_tool_call(c)}")
|
|
545
|
+
return "\n".join(out).lstrip("\n")
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def mode_file_edits(path: Path, fmt: str = "text"):
|
|
549
|
+
lines = PR.parse_lines(path)
|
|
550
|
+
files = T.files_touched(lines)
|
|
551
|
+
if fmt == "json":
|
|
552
|
+
return [
|
|
553
|
+
{"path": str(fp), "ops": ops}
|
|
554
|
+
for fp, ops in sorted(files.items(), key=lambda x: str(x[0]))
|
|
555
|
+
]
|
|
556
|
+
if not files:
|
|
557
|
+
return "No file operations found."
|
|
558
|
+
out = []
|
|
559
|
+
for fp, ops in sorted(files.items(), key=lambda x: str(x[0])):
|
|
560
|
+
# Count repeats — ops is a list of operation names
|
|
561
|
+
n = len(ops)
|
|
562
|
+
suffix = f" ({n}x)" if n > 1 else ""
|
|
563
|
+
out.append(f"{fp}{suffix} [{', '.join(ops)}]")
|
|
564
|
+
return "\n".join(out)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def mode_tool_calls(path: Path, tool_filter: set[str] | None, fmt: str = "text"):
|
|
568
|
+
if fmt == "json":
|
|
569
|
+
return _tool_calls_json(path, tool_filter, include_input=True)
|
|
570
|
+
lines = PR.parse_lines(path)
|
|
571
|
+
calls = T.extract_tool_calls(lines, tool_filter)
|
|
572
|
+
if not calls:
|
|
573
|
+
return "No tool calls found."
|
|
574
|
+
out = []
|
|
575
|
+
for c in calls:
|
|
576
|
+
ts = PR._parse_timestamp(c.get("timestamp"))
|
|
577
|
+
time = ts.strftime("%Y-%m-%d %H:%M:%S") if ts else "?"
|
|
578
|
+
out.append(f"\n[{time}]\n {T.format_tool_call(c)}")
|
|
579
|
+
return "\n".join(out).lstrip("\n")
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def mode_search_v2(
|
|
583
|
+
root: Path,
|
|
584
|
+
cwd: str | None,
|
|
585
|
+
all_projects: bool,
|
|
586
|
+
file_path: Path | None,
|
|
587
|
+
query: str,
|
|
588
|
+
role: str,
|
|
589
|
+
in_channel: str,
|
|
590
|
+
since: datetime | None,
|
|
591
|
+
until: datetime | None,
|
|
592
|
+
project: str | None = None,
|
|
593
|
+
fmt: str = "text",
|
|
594
|
+
exclude_current: bool = False,
|
|
595
|
+
current_uuid: str | None = None,
|
|
596
|
+
):
|
|
597
|
+
"""Cross-scope search with role/in-channel filters."""
|
|
598
|
+
matches: list = []
|
|
599
|
+
if file_path:
|
|
600
|
+
matches = S.search_session(file_path, query, role, in_channel, since, until)
|
|
601
|
+
elif project:
|
|
602
|
+
for pd in _scoped_project_dirs(root, None, False, project):
|
|
603
|
+
matches.extend(S.search_project(pd, query, role, in_channel, since, until))
|
|
604
|
+
elif all_projects:
|
|
605
|
+
matches = list(S.search_all_projects(root, query, role, in_channel, since, until))
|
|
606
|
+
elif cwd:
|
|
607
|
+
pd = P.find_project_dir(cwd, root)
|
|
608
|
+
matches = list(S.search_project(pd, query, role, in_channel, since, until))
|
|
609
|
+
else:
|
|
610
|
+
return "Provide --file, --cwd, --project, or --all-projects"
|
|
611
|
+
|
|
612
|
+
if exclude_current and current_uuid:
|
|
613
|
+
matches = [m for m in matches if m.session_path.stem != current_uuid]
|
|
614
|
+
|
|
615
|
+
if fmt == "json":
|
|
616
|
+
return [
|
|
617
|
+
{
|
|
618
|
+
"session": str(m.session_path),
|
|
619
|
+
"mtime_iso": _fmt_mtime(m.mtime),
|
|
620
|
+
"role": m.role,
|
|
621
|
+
"where": m.where,
|
|
622
|
+
"timestamp": _ts_iso(m.timestamp),
|
|
623
|
+
"window": m.window_text,
|
|
624
|
+
}
|
|
625
|
+
for m in matches
|
|
626
|
+
]
|
|
627
|
+
|
|
628
|
+
if not matches:
|
|
629
|
+
return f"No matches for '{query}'."
|
|
630
|
+
|
|
631
|
+
# Group by session for readable output
|
|
632
|
+
by_session: dict[Path, list] = {}
|
|
633
|
+
for m in matches:
|
|
634
|
+
by_session.setdefault(m.session_path, []).append(m)
|
|
635
|
+
out = []
|
|
636
|
+
for sp, ms in by_session.items():
|
|
637
|
+
out.append(f"\n{'=' * 60}\nSession: {sp.name} ({_fmt_mtime(ms[0].mtime)})\n{'=' * 60}")
|
|
638
|
+
for i, m in enumerate(ms, 1):
|
|
639
|
+
label = f"--- Match #{i} [{m.role}/{m.where}] ---"
|
|
640
|
+
out.append(f"{label}\n{m.window_text[:1500]}")
|
|
641
|
+
return "\n\n".join(out)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def mode_subagent_list(path: Path, fmt: str = "text"):
|
|
645
|
+
subs = P.list_subagents(path)
|
|
646
|
+
if fmt == "json":
|
|
647
|
+
out = []
|
|
648
|
+
for sa in subs:
|
|
649
|
+
meta = SA.load_meta(sa)
|
|
650
|
+
out.append({
|
|
651
|
+
"id": sa.stem,
|
|
652
|
+
"agentType": meta.get("agentType", "unknown"),
|
|
653
|
+
"description": meta.get("description", ""),
|
|
654
|
+
"path": str(sa),
|
|
655
|
+
"size_kb": round(sa.stat().st_size / 1024, 1),
|
|
656
|
+
"mtime_iso": _fmt_mtime(sa.stat().st_mtime),
|
|
657
|
+
})
|
|
658
|
+
return out
|
|
659
|
+
if not subs:
|
|
660
|
+
return "No subagent transcripts found."
|
|
661
|
+
out = [f"Subagents for {path.name}:"]
|
|
662
|
+
for sa in subs:
|
|
663
|
+
meta = SA.load_meta(sa)
|
|
664
|
+
size_kb = sa.stat().st_size / 1024
|
|
665
|
+
mtime = _fmt_mtime(sa.stat().st_mtime)
|
|
666
|
+
out.append(
|
|
667
|
+
f" {mtime} {size_kb:5.0f}KB {sa.stem} "
|
|
668
|
+
f"type={meta['agentType']} \"{meta['description'][:60]}\""
|
|
669
|
+
)
|
|
670
|
+
return "\n".join(out)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def mode_subagent_finals(path: Path, fmt: str = "text"):
|
|
674
|
+
finals = SA.agent_finals(path)
|
|
675
|
+
if fmt == "json":
|
|
676
|
+
return [
|
|
677
|
+
{"id": agent_id, "agentType": meta.get("agentType", "unknown"), "text": text}
|
|
678
|
+
for agent_id, meta, text in finals
|
|
679
|
+
]
|
|
680
|
+
if not finals:
|
|
681
|
+
return "No subagent transcripts found."
|
|
682
|
+
blocks = []
|
|
683
|
+
for agent_id, meta, text in finals:
|
|
684
|
+
atype = meta.get("agentType", "unknown")
|
|
685
|
+
header = f"=== {agent_id} ({atype}) ==="
|
|
686
|
+
blocks.append(f"{header}\n\n{text or '(no assistant output)'}")
|
|
687
|
+
return "\n\n".join(blocks)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def mode_resume_prev(cwd: str, root: Path, n: int = 10, fmt: str = "text"):
|
|
691
|
+
pd = P.find_project_dir(cwd, root)
|
|
692
|
+
files = P.list_transcripts(pd)
|
|
693
|
+
if not files:
|
|
694
|
+
return {"error": "No prior sessions."} if fmt == "json" else "No prior sessions."
|
|
695
|
+
f = files[0]
|
|
696
|
+
lines = PR.parse_lines(f)
|
|
697
|
+
if fmt == "json":
|
|
698
|
+
messages = [m for m in PR.get_messages(lines) if m["texts"]][-n:]
|
|
699
|
+
return {
|
|
700
|
+
"session": f.stem,
|
|
701
|
+
"path": str(f),
|
|
702
|
+
"mtime_iso": _fmt_mtime(f.stat().st_mtime),
|
|
703
|
+
"messages": _messages_json(messages),
|
|
704
|
+
}
|
|
705
|
+
banner = f"--- Resuming from {f.stem} ({_fmt_mtime(f.stat().st_mtime)}) ---\n"
|
|
706
|
+
return banner + mode_dump(lines, n)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def mode_count(
|
|
710
|
+
root: Path,
|
|
711
|
+
cwd: str | None,
|
|
712
|
+
all_projects: bool,
|
|
713
|
+
query: str,
|
|
714
|
+
role: str,
|
|
715
|
+
in_channel: str,
|
|
716
|
+
since: datetime | None,
|
|
717
|
+
until: datetime | None,
|
|
718
|
+
project: str | None = None,
|
|
719
|
+
exclude_current: bool = False,
|
|
720
|
+
current_uuid: str | None = None,
|
|
721
|
+
) -> dict:
|
|
722
|
+
sessions = 0
|
|
723
|
+
matches = 0
|
|
724
|
+
total_msgs = 0
|
|
725
|
+
sources: list[Path] = []
|
|
726
|
+
project_dirs = _scoped_project_dirs(root, cwd, all_projects, project)
|
|
727
|
+
for pd in (project_dirs or []):
|
|
728
|
+
sources.extend(P.list_transcripts(pd, since=since, until=until))
|
|
729
|
+
if exclude_current and current_uuid:
|
|
730
|
+
sources = [f for f in sources if f.stem != current_uuid]
|
|
731
|
+
for f in sources:
|
|
732
|
+
ms = S.search_session(f, query, role, in_channel, since, until)
|
|
733
|
+
total_msgs += len(PR.get_messages(PR.parse_lines(f)))
|
|
734
|
+
if ms:
|
|
735
|
+
sessions += 1
|
|
736
|
+
matches += len(ms)
|
|
737
|
+
return {"sessions": sessions, "messages": total_msgs, "matches": matches}
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def mode_journal(
|
|
741
|
+
root: Path,
|
|
742
|
+
cwd: str | None,
|
|
743
|
+
all_projects: bool,
|
|
744
|
+
since: datetime | None,
|
|
745
|
+
until: datetime | None,
|
|
746
|
+
current_uuid: str | None,
|
|
747
|
+
project: str | None = None,
|
|
748
|
+
fmt: str = "text",
|
|
749
|
+
exclude_current: bool = False,
|
|
750
|
+
):
|
|
751
|
+
pds = _scoped_project_dirs(root, cwd, all_projects, project)
|
|
752
|
+
if pds is None:
|
|
753
|
+
return "--cwd, --project, or --all-projects required"
|
|
754
|
+
blocks = []
|
|
755
|
+
rows = []
|
|
756
|
+
for pd in pds:
|
|
757
|
+
for f in P.list_transcripts(pd, since=since, until=until):
|
|
758
|
+
summary = PR.session_summary(f, current_session_id=current_uuid)
|
|
759
|
+
if exclude_current and summary.get("is_current"):
|
|
760
|
+
continue
|
|
761
|
+
rows.append(summary)
|
|
762
|
+
rows.sort(key=lambda s: s["mtime"], reverse=True)
|
|
763
|
+
if fmt == "json":
|
|
764
|
+
return [_summary_json(s) for s in rows]
|
|
765
|
+
for s in rows:
|
|
766
|
+
day = PR.epoch_to_display(s["mtime"]).strftime("%Y-%m-%d")
|
|
767
|
+
blocks.append(
|
|
768
|
+
f"=== {day} · {s['uuid'][:8]} · {s['decoded_project']} ===\n"
|
|
769
|
+
f" prompt: {s['first_prompt'] or '(none)'}\n"
|
|
770
|
+
f" ended: {s['last_assistant'] or '(none)'}\n"
|
|
771
|
+
f" edits: {s['edit_count']} files\n"
|
|
772
|
+
f" tools: {sum(s['tool_counts'].values())} calls "
|
|
773
|
+
f"({', '.join(f'{k}={v}' for k, v in sorted(s['tool_counts'].items(), key=lambda x: -x[1])[:5])})"
|
|
774
|
+
)
|
|
775
|
+
return "\n\n".join(blocks) if blocks else "No sessions in range."
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def mode_diff(file_a: Path, file_b: Path, fmt: str = "text"):
|
|
779
|
+
"""Timestamp-interleaved diff of two sessions."""
|
|
780
|
+
msgs_a = [(m, "A") for m in PR.get_messages(PR.parse_lines(file_a))]
|
|
781
|
+
msgs_b = [(m, "B") for m in PR.get_messages(PR.parse_lines(file_b))]
|
|
782
|
+
combined = msgs_a + msgs_b
|
|
783
|
+
|
|
784
|
+
def sort_key(item):
|
|
785
|
+
m, _ = item
|
|
786
|
+
ts = PR._parse_timestamp(m.get("timestamp"))
|
|
787
|
+
if ts and ts.tzinfo is not None:
|
|
788
|
+
ts = ts.replace(tzinfo=None)
|
|
789
|
+
return ts or datetime.min
|
|
790
|
+
|
|
791
|
+
combined.sort(key=sort_key)
|
|
792
|
+
if fmt == "json":
|
|
793
|
+
return {
|
|
794
|
+
"a": str(file_a),
|
|
795
|
+
"b": str(file_b),
|
|
796
|
+
"messages": [
|
|
797
|
+
{
|
|
798
|
+
"source": tag,
|
|
799
|
+
"role": m["role"],
|
|
800
|
+
"timestamp": _ts_iso(m.get("timestamp")),
|
|
801
|
+
"text": " | ".join(m["texts"]),
|
|
802
|
+
}
|
|
803
|
+
for m, tag in combined
|
|
804
|
+
if m["texts"]
|
|
805
|
+
],
|
|
806
|
+
}
|
|
807
|
+
out = [f"--- A: {file_a.name}\n--- B: {file_b.name}\n"]
|
|
808
|
+
for m, tag in combined:
|
|
809
|
+
if not m["texts"]:
|
|
810
|
+
continue
|
|
811
|
+
text = " | ".join(m["texts"])[:300]
|
|
812
|
+
role = m["role"][0].upper()
|
|
813
|
+
out.append(f"{tag}> [{role}] {text}")
|
|
814
|
+
return "\n".join(out)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
818
|
+
# Timeline mode
|
|
819
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
820
|
+
|
|
821
|
+
def _fmt_dur(td: timedelta) -> str:
|
|
822
|
+
mins = int(td.total_seconds() // 60)
|
|
823
|
+
if mins < 1:
|
|
824
|
+
return "<1m"
|
|
825
|
+
h, m = divmod(mins, 60)
|
|
826
|
+
return f"{h}h{m:02d}m" if h else f"{m}m"
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
_GAP_RE = re.compile(r"^(\d+)\s*(m|h)?$", re.IGNORECASE)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _parse_gap(spec: str | None, default: int = 15) -> int:
|
|
833
|
+
"""Parse a gap/break spec ('15m', '1h', '20') into minutes."""
|
|
834
|
+
if not spec:
|
|
835
|
+
return default
|
|
836
|
+
m = _GAP_RE.match(spec.strip())
|
|
837
|
+
if not m:
|
|
838
|
+
raise ValueError(f"Unrecognized gap spec: {spec!r}. Use forms like 15m or 1h.")
|
|
839
|
+
n = int(m.group(1))
|
|
840
|
+
return n * 60 if (m.group(2) or "m").lower() == "h" else n
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def build_timeline(
|
|
844
|
+
project_dirs: list[Path],
|
|
845
|
+
since: datetime,
|
|
846
|
+
until: datetime,
|
|
847
|
+
gap_minutes: int,
|
|
848
|
+
current_uuid: str | None,
|
|
849
|
+
exclude_current: bool = False,
|
|
850
|
+
) -> dict:
|
|
851
|
+
"""Cross-session activity blocks for a time window.
|
|
852
|
+
|
|
853
|
+
Every signal-message timestamp in [since, until) is an activity event.
|
|
854
|
+
Events across all sessions are merged chronologically and grouped into
|
|
855
|
+
blocks separated by gaps > gap_minutes.
|
|
856
|
+
"""
|
|
857
|
+
sessions: dict[Path, dict] = {}
|
|
858
|
+
events: list[tuple[datetime, Path]] = []
|
|
859
|
+
for pd in project_dirs:
|
|
860
|
+
# Filter by mtime >= since only; a session still active after `until`
|
|
861
|
+
# may contain events inside the window, so no upper mtime bound.
|
|
862
|
+
for f in P.list_transcripts(pd, since=since):
|
|
863
|
+
if exclude_current and current_uuid and f.stem == current_uuid:
|
|
864
|
+
continue
|
|
865
|
+
stamps = []
|
|
866
|
+
for m in PR.get_messages(PR.parse_lines(f)):
|
|
867
|
+
ts = PR._parse_timestamp(m.get("timestamp"))
|
|
868
|
+
if ts is None or ts < since or ts >= until:
|
|
869
|
+
continue
|
|
870
|
+
stamps.append(ts)
|
|
871
|
+
if not stamps:
|
|
872
|
+
continue
|
|
873
|
+
sessions[f] = PR.session_summary(f, current_session_id=current_uuid)
|
|
874
|
+
events.extend((ts, f) for ts in stamps)
|
|
875
|
+
events.sort(key=lambda e: e[0])
|
|
876
|
+
|
|
877
|
+
blocks: list[dict] = []
|
|
878
|
+
cur: dict | None = None
|
|
879
|
+
gap = timedelta(minutes=gap_minutes)
|
|
880
|
+
for ts, f in events:
|
|
881
|
+
if cur is None or ts - cur["end"] > gap:
|
|
882
|
+
cur = {"start": ts, "end": ts, "counts": {}}
|
|
883
|
+
blocks.append(cur)
|
|
884
|
+
if ts > cur["end"]:
|
|
885
|
+
cur["end"] = ts
|
|
886
|
+
cur["counts"][f] = cur["counts"].get(f, 0) + 1
|
|
887
|
+
return {
|
|
888
|
+
"since": since,
|
|
889
|
+
"until": until,
|
|
890
|
+
"gap_minutes": gap_minutes,
|
|
891
|
+
"blocks": blocks,
|
|
892
|
+
"sessions": sessions,
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def _session_label(s: dict) -> str:
|
|
897
|
+
title = s.get("title") or s.get("first_prompt") or "(untitled)"
|
|
898
|
+
return f"{s['decoded_project']} · {title[:60]} [{s['uuid'][:8]}]"
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def render_timeline(data: dict, tz_label: str) -> str:
|
|
902
|
+
since, until = data["since"], data["until"]
|
|
903
|
+
blocks, sessions = data["blocks"], data["sessions"]
|
|
904
|
+
multi_day = (until - since) > timedelta(days=1)
|
|
905
|
+
tfmt = "%Y-%m-%d %H:%M" if multi_day else "%H:%M"
|
|
906
|
+
head = (
|
|
907
|
+
f"=== Timeline {since:%Y-%m-%d %H:%M} → {until:%Y-%m-%d %H:%M} "
|
|
908
|
+
f"(times: {tz_label}, gap={data['gap_minutes']}m) ==="
|
|
909
|
+
)
|
|
910
|
+
if not blocks:
|
|
911
|
+
return head + "\n\n(no activity in range)"
|
|
912
|
+
out = [head, ""]
|
|
913
|
+
prev_end: datetime | None = None
|
|
914
|
+
for b in blocks:
|
|
915
|
+
if prev_end is not None:
|
|
916
|
+
out.append(f" ── idle {_fmt_dur(b['start'] - prev_end)} ──")
|
|
917
|
+
dur = b["end"] - b["start"]
|
|
918
|
+
out.append(f"{b['start'].strftime(tfmt)}–{b['end'].strftime('%H:%M')} ({_fmt_dur(dur)})")
|
|
919
|
+
for f, n in sorted(b["counts"].items(), key=lambda x: -x[1]):
|
|
920
|
+
out.append(f" · {_session_label(sessions[f])} — {n} msgs")
|
|
921
|
+
prev_end = b["end"]
|
|
922
|
+
span = blocks[-1]["end"] - blocks[0]["start"]
|
|
923
|
+
out.append("")
|
|
924
|
+
# Timeline is a map of WHEN sessions were active (Claude included) — it makes
|
|
925
|
+
# no claim about user attention time. For that, use --mode engagement.
|
|
926
|
+
out.append(
|
|
927
|
+
f"Total: {len(blocks)} block(s) across a {_fmt_dur(span)} span "
|
|
928
|
+
f"({blocks[0]['start'].strftime(tfmt)}–{blocks[-1]['end'].strftime('%H:%M')}), "
|
|
929
|
+
f"{len(sessions)} session(s)"
|
|
930
|
+
)
|
|
931
|
+
return "\n".join(out)
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def timeline_json(data: dict) -> dict:
|
|
935
|
+
sessions = data["sessions"]
|
|
936
|
+
blocks_out = []
|
|
937
|
+
for b in data["blocks"]:
|
|
938
|
+
dur_min = int((b["end"] - b["start"]).total_seconds() // 60)
|
|
939
|
+
blocks_out.append({
|
|
940
|
+
"start": b["start"].isoformat(),
|
|
941
|
+
"end": b["end"].isoformat(),
|
|
942
|
+
"duration_minutes": dur_min,
|
|
943
|
+
"sessions": [
|
|
944
|
+
{
|
|
945
|
+
"uuid": sessions[f]["uuid"],
|
|
946
|
+
"project": sessions[f]["decoded_project"],
|
|
947
|
+
"title": sessions[f].get("title") or sessions[f].get("first_prompt") or "",
|
|
948
|
+
"path": str(f),
|
|
949
|
+
"events": n,
|
|
950
|
+
}
|
|
951
|
+
for f, n in sorted(b["counts"].items(), key=lambda x: -x[1])
|
|
952
|
+
],
|
|
953
|
+
})
|
|
954
|
+
span_min = 0
|
|
955
|
+
if data["blocks"]:
|
|
956
|
+
span_min = int(
|
|
957
|
+
(data["blocks"][-1]["end"] - data["blocks"][0]["start"]).total_seconds() // 60
|
|
958
|
+
)
|
|
959
|
+
return {
|
|
960
|
+
"since": data["since"].isoformat(),
|
|
961
|
+
"until": data["until"].isoformat(),
|
|
962
|
+
"gap_minutes": data["gap_minutes"],
|
|
963
|
+
"blocks": blocks_out,
|
|
964
|
+
"totals": {
|
|
965
|
+
"blocks": len(blocks_out),
|
|
966
|
+
"span_minutes": span_min,
|
|
967
|
+
"sessions": len(sessions),
|
|
968
|
+
},
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
973
|
+
# Engagement mode — user attention time, not session activity
|
|
974
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
975
|
+
|
|
976
|
+
def _is_real_user_prompt(obj: dict) -> bool:
|
|
977
|
+
"""True only for an actual human action: typed prompt or slash command.
|
|
978
|
+
|
|
979
|
+
Excludes tool results (user-role, no text blocks), hook/skill injections
|
|
980
|
+
(isMeta), and compact continuations (classified upstream).
|
|
981
|
+
"""
|
|
982
|
+
if obj.get("isMeta"):
|
|
983
|
+
return False
|
|
984
|
+
content = obj.get("message", {}).get("content", "")
|
|
985
|
+
if isinstance(content, str):
|
|
986
|
+
return bool(content.strip())
|
|
987
|
+
if isinstance(content, list):
|
|
988
|
+
return any(
|
|
989
|
+
isinstance(b, dict) and b.get("type") == "text" and b.get("text", "").strip()
|
|
990
|
+
for b in content
|
|
991
|
+
)
|
|
992
|
+
return False
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _engagement_event_streams(
|
|
996
|
+
path: Path, since: datetime | None, until: datetime | None
|
|
997
|
+
) -> tuple[list[datetime], list[datetime]]:
|
|
998
|
+
"""One session's (user_events, claude_events) inside [since, until).
|
|
999
|
+
|
|
1000
|
+
user_events — real user prompts only (see _is_real_user_prompt).
|
|
1001
|
+
claude_events — assistant messages and tool results: evidence Claude was
|
|
1002
|
+
working. Used only to grant waiting-on-Claude credit for long gaps.
|
|
1003
|
+
"""
|
|
1004
|
+
user_ev: list[datetime] = []
|
|
1005
|
+
claude_ev: list[datetime] = []
|
|
1006
|
+
for obj in PR.parse_lines(path):
|
|
1007
|
+
cls = PR.classify_entry(obj)
|
|
1008
|
+
if cls in ("noise", "title", "compact"):
|
|
1009
|
+
continue
|
|
1010
|
+
ts = PR._parse_timestamp(obj.get("timestamp"))
|
|
1011
|
+
if ts is None or (since and ts < since) or (until and ts >= until):
|
|
1012
|
+
continue
|
|
1013
|
+
if cls == "user":
|
|
1014
|
+
if obj.get("isMeta"):
|
|
1015
|
+
continue
|
|
1016
|
+
if _is_real_user_prompt(obj):
|
|
1017
|
+
user_ev.append(ts)
|
|
1018
|
+
else:
|
|
1019
|
+
claude_ev.append(ts) # tool_result entries
|
|
1020
|
+
else: # assistant
|
|
1021
|
+
claude_ev.append(ts)
|
|
1022
|
+
user_ev.sort()
|
|
1023
|
+
claude_ev.sort()
|
|
1024
|
+
return user_ev, claude_ev
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def build_engagement(
|
|
1028
|
+
root: Path,
|
|
1029
|
+
report_dirs: list[Path] | None,
|
|
1030
|
+
report_file: Path | None,
|
|
1031
|
+
since: datetime,
|
|
1032
|
+
until: datetime,
|
|
1033
|
+
break_minutes: int,
|
|
1034
|
+
current_uuid: str | None,
|
|
1035
|
+
exclude_current: bool = False,
|
|
1036
|
+
) -> dict:
|
|
1037
|
+
"""Attention-time accounting over ONE merged user-prompt stream.
|
|
1038
|
+
|
|
1039
|
+
Real user prompts from EVERY project are merged into a single global
|
|
1040
|
+
stream, so a moment of wall-clock time is never counted twice across
|
|
1041
|
+
parallel chats. Three rules:
|
|
1042
|
+
|
|
1043
|
+
1. A gap between consecutive prompts ≤ break_minutes counts fully as
|
|
1044
|
+
active time, attributed to the session of the LATER prompt (that's
|
|
1045
|
+
the chat being read/typed in).
|
|
1046
|
+
2. A longer gap still counts in full if Claude was working in the later
|
|
1047
|
+
prompt's session during the gap AND the user replied within
|
|
1048
|
+
break_minutes of Claude's last event (sitting-there-waiting credit).
|
|
1049
|
+
3. Anything else is a break: contributes nothing.
|
|
1050
|
+
|
|
1051
|
+
report_dirs/report_file only filter which sessions are REPORTED — the
|
|
1052
|
+
stream itself always spans all projects under root for correctness.
|
|
1053
|
+
"""
|
|
1054
|
+
import bisect
|
|
1055
|
+
|
|
1056
|
+
user_events: dict[Path, list[datetime]] = {}
|
|
1057
|
+
claude_events: dict[Path, list[datetime]] = {}
|
|
1058
|
+
walk_dirs = P.list_projects(root)
|
|
1059
|
+
files: list[Path] = []
|
|
1060
|
+
for pd in walk_dirs:
|
|
1061
|
+
# mtime >= since only; a session still active after `until` may hold
|
|
1062
|
+
# events inside the window (same reasoning as timeline).
|
|
1063
|
+
files.extend(P.list_transcripts(pd, since=since))
|
|
1064
|
+
if report_file:
|
|
1065
|
+
report_file = report_file.resolve()
|
|
1066
|
+
files = [f.resolve() for f in files]
|
|
1067
|
+
if report_file not in files:
|
|
1068
|
+
files.append(report_file) # e.g. --file under a different root
|
|
1069
|
+
for f in files:
|
|
1070
|
+
if exclude_current and current_uuid and f.stem == current_uuid:
|
|
1071
|
+
continue
|
|
1072
|
+
u, c = _engagement_event_streams(f, since, until)
|
|
1073
|
+
if u or c:
|
|
1074
|
+
user_events[f] = u
|
|
1075
|
+
claude_events[f] = c
|
|
1076
|
+
|
|
1077
|
+
stream = sorted(
|
|
1078
|
+
(ts, f) for f, evs in user_events.items() for ts in evs
|
|
1079
|
+
)
|
|
1080
|
+
|
|
1081
|
+
brk = timedelta(minutes=break_minutes)
|
|
1082
|
+
active: dict[Path, timedelta] = {}
|
|
1083
|
+
breaks: list[tuple[datetime, datetime]] = []
|
|
1084
|
+
for (t0, _f0), (t1, f1) in zip(stream, stream[1:]):
|
|
1085
|
+
gap = t1 - t0
|
|
1086
|
+
if gap <= brk:
|
|
1087
|
+
active[f1] = active.get(f1, timedelta()) + gap
|
|
1088
|
+
continue
|
|
1089
|
+
# Waiting-on-Claude credit: last Claude event in f1 inside the gap.
|
|
1090
|
+
cl = claude_events.get(f1, [])
|
|
1091
|
+
i = bisect.bisect_left(cl, t1)
|
|
1092
|
+
t_done = cl[i - 1] if i > 0 and cl[i - 1] > t0 else None
|
|
1093
|
+
if t_done is not None and (t1 - t_done) <= brk:
|
|
1094
|
+
active[f1] = active.get(f1, timedelta()) + gap
|
|
1095
|
+
else:
|
|
1096
|
+
breaks.append((t0, t1))
|
|
1097
|
+
|
|
1098
|
+
# Reporting scope
|
|
1099
|
+
report_dir_set = {d.resolve() for d in report_dirs} if report_dirs else None
|
|
1100
|
+
sessions: dict[Path, dict] = {}
|
|
1101
|
+
for f, evs in user_events.items():
|
|
1102
|
+
if not evs:
|
|
1103
|
+
continue
|
|
1104
|
+
if report_file and f != report_file:
|
|
1105
|
+
continue
|
|
1106
|
+
if report_dir_set is not None and f.parent.resolve() not in report_dir_set:
|
|
1107
|
+
continue
|
|
1108
|
+
sessions[f] = {
|
|
1109
|
+
"summary": PR.session_summary(f, current_session_id=current_uuid),
|
|
1110
|
+
"first": evs[0],
|
|
1111
|
+
"last": evs[-1],
|
|
1112
|
+
"user_messages": len(evs),
|
|
1113
|
+
"active": active.get(f, timedelta()),
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
return {
|
|
1117
|
+
"since": since,
|
|
1118
|
+
"until": until,
|
|
1119
|
+
"break_minutes": break_minutes,
|
|
1120
|
+
"sessions": sessions,
|
|
1121
|
+
"breaks": breaks,
|
|
1122
|
+
"stream_events": len(stream),
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def _gap_percentiles(evs: list[datetime]) -> tuple[int, int] | None:
|
|
1127
|
+
"""(median, p90) of intra-session user-prompt gaps, in whole minutes."""
|
|
1128
|
+
if len(evs) < 2:
|
|
1129
|
+
return None
|
|
1130
|
+
gaps = sorted(
|
|
1131
|
+
(b - a).total_seconds() / 60 for a, b in zip(evs, evs[1:])
|
|
1132
|
+
)
|
|
1133
|
+
median = gaps[len(gaps) // 2]
|
|
1134
|
+
p90 = gaps[min(len(gaps) - 1, int(len(gaps) * 0.9))]
|
|
1135
|
+
return int(median), int(p90)
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def render_engagement(data: dict, tz_label: str) -> str:
|
|
1139
|
+
since, until = data["since"], data["until"]
|
|
1140
|
+
sessions = data["sessions"]
|
|
1141
|
+
multi_day = (until - since) > timedelta(days=1)
|
|
1142
|
+
tfmt = "%Y-%m-%d %H:%M" if multi_day else "%H:%M"
|
|
1143
|
+
head = (
|
|
1144
|
+
f"=== Engagement {since:%Y-%m-%d %H:%M} → {until:%Y-%m-%d %H:%M} "
|
|
1145
|
+
f"(times: {tz_label}, break={data['break_minutes']}m) ==="
|
|
1146
|
+
)
|
|
1147
|
+
if not sessions:
|
|
1148
|
+
return head + "\n\n(no user messages in range)"
|
|
1149
|
+
out = [head, ""]
|
|
1150
|
+
rows = sorted(sessions.items(), key=lambda kv: -kv[1]["active"].total_seconds())
|
|
1151
|
+
for f, s in rows:
|
|
1152
|
+
elapsed = s["last"] - s["first"]
|
|
1153
|
+
# Composing time leading into a chat's first prompt is credited to it,
|
|
1154
|
+
# so active can slightly exceed first–last; cap the ratio at 1.0.
|
|
1155
|
+
ratio = (
|
|
1156
|
+
f"{min(1.0, s['active'].total_seconds() / elapsed.total_seconds()):.2f}"
|
|
1157
|
+
if elapsed.total_seconds() > 0 else " — "
|
|
1158
|
+
)
|
|
1159
|
+
out.append(
|
|
1160
|
+
f"{_fmt_dur(s['active']):>7} ratio {ratio} msgs {s['user_messages']:<4} "
|
|
1161
|
+
f"{s['first'].strftime(tfmt)}–{s['last'].strftime('%H:%M')} "
|
|
1162
|
+
f"{_session_label(s['summary'])}"
|
|
1163
|
+
)
|
|
1164
|
+
total_active = sum((s["active"] for s in sessions.values()), timedelta())
|
|
1165
|
+
first = min(s["first"] for s in sessions.values())
|
|
1166
|
+
last = max(s["last"] for s in sessions.values())
|
|
1167
|
+
out.append("")
|
|
1168
|
+
out.append(
|
|
1169
|
+
f"Total: {_fmt_dur(total_active)} active across {len(sessions)} session(s), "
|
|
1170
|
+
f"{first.strftime(tfmt)}–{last.strftime('%H:%M')} span ({_fmt_dur(last - first)})"
|
|
1171
|
+
)
|
|
1172
|
+
breaks = data["breaks"]
|
|
1173
|
+
if breaks:
|
|
1174
|
+
shown = breaks[:6]
|
|
1175
|
+
items = ", ".join(
|
|
1176
|
+
f"{a.strftime(tfmt)}→{b.strftime('%H:%M')} ({_fmt_dur(b - a)})"
|
|
1177
|
+
for a, b in shown
|
|
1178
|
+
)
|
|
1179
|
+
more = f" (+{len(breaks) - len(shown)} more)" if len(breaks) > len(shown) else ""
|
|
1180
|
+
out.append(f"Breaks >{data['break_minutes']}m in the merged stream: "
|
|
1181
|
+
f"{len(breaks)} — {items}{more}")
|
|
1182
|
+
# Single-session detail: prompt-gap percentiles
|
|
1183
|
+
if len(sessions) == 1:
|
|
1184
|
+
(f, s), = sessions.items()
|
|
1185
|
+
# recompute the session's own user events from the stored bounds is not
|
|
1186
|
+
# enough — pull them again (cached parse, cheap)
|
|
1187
|
+
evs, _ = _engagement_event_streams(f, data["since"], data["until"])
|
|
1188
|
+
pct = _gap_percentiles(evs)
|
|
1189
|
+
if pct:
|
|
1190
|
+
out.append(f"Prompt gaps: median {pct[0]}m, p90 {pct[1]}m")
|
|
1191
|
+
out.append(
|
|
1192
|
+
"(active time = your message cadence merged across ALL projects; "
|
|
1193
|
+
"parallel chats split the clock, never double-count. "
|
|
1194
|
+
"Long gaps count only when you replied right after Claude finished.)"
|
|
1195
|
+
)
|
|
1196
|
+
return "\n".join(out)
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
def engagement_json(data: dict) -> dict:
|
|
1200
|
+
sessions_out = []
|
|
1201
|
+
rows = sorted(
|
|
1202
|
+
data["sessions"].items(), key=lambda kv: -kv[1]["active"].total_seconds()
|
|
1203
|
+
)
|
|
1204
|
+
total_active = timedelta()
|
|
1205
|
+
for f, s in rows:
|
|
1206
|
+
elapsed = s["last"] - s["first"]
|
|
1207
|
+
active = s["active"]
|
|
1208
|
+
total_active += active
|
|
1209
|
+
summary = s["summary"]
|
|
1210
|
+
sessions_out.append({
|
|
1211
|
+
"uuid": summary["uuid"],
|
|
1212
|
+
"project": summary["decoded_project"],
|
|
1213
|
+
"title": summary.get("title") or summary.get("first_prompt") or "",
|
|
1214
|
+
"path": str(f),
|
|
1215
|
+
"first": s["first"].isoformat(),
|
|
1216
|
+
"last": s["last"].isoformat(),
|
|
1217
|
+
"elapsed_minutes": int(elapsed.total_seconds() // 60),
|
|
1218
|
+
"active_minutes": int(active.total_seconds() // 60),
|
|
1219
|
+
"active_seconds": int(active.total_seconds()),
|
|
1220
|
+
"ratio": (
|
|
1221
|
+
min(1.0, round(active.total_seconds() / elapsed.total_seconds(), 2))
|
|
1222
|
+
if elapsed.total_seconds() > 0 else None
|
|
1223
|
+
),
|
|
1224
|
+
"user_messages": s["user_messages"],
|
|
1225
|
+
})
|
|
1226
|
+
span_min = 0
|
|
1227
|
+
if data["sessions"]:
|
|
1228
|
+
first = min(s["first"] for s in data["sessions"].values())
|
|
1229
|
+
last = max(s["last"] for s in data["sessions"].values())
|
|
1230
|
+
span_min = int((last - first).total_seconds() // 60)
|
|
1231
|
+
return {
|
|
1232
|
+
"since": data["since"].isoformat(),
|
|
1233
|
+
"until": data["until"].isoformat(),
|
|
1234
|
+
"break_minutes": data["break_minutes"],
|
|
1235
|
+
"sessions": sessions_out,
|
|
1236
|
+
"totals": {
|
|
1237
|
+
"sessions": len(sessions_out),
|
|
1238
|
+
"active_minutes": int(total_active.total_seconds() // 60),
|
|
1239
|
+
"active_seconds": int(total_active.total_seconds()),
|
|
1240
|
+
"span_minutes": span_min,
|
|
1241
|
+
},
|
|
1242
|
+
"stream_breaks": [
|
|
1243
|
+
{
|
|
1244
|
+
"start": a.isoformat(),
|
|
1245
|
+
"end": b.isoformat(),
|
|
1246
|
+
"minutes": int((b - a).total_seconds() // 60),
|
|
1247
|
+
}
|
|
1248
|
+
for a, b in data["breaks"]
|
|
1249
|
+
],
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1254
|
+
# JSON builders for legacy single-file modes
|
|
1255
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1256
|
+
|
|
1257
|
+
def json_last(lines: list[dict], n: int) -> list[dict]:
|
|
1258
|
+
messages = PR.get_messages(lines)
|
|
1259
|
+
assistant_msgs = [m for m in messages if m["role"] == "assistant" and m["texts"]]
|
|
1260
|
+
recent = assistant_msgs[-n:]
|
|
1261
|
+
return [
|
|
1262
|
+
{
|
|
1263
|
+
"n_from_end": len(recent) - i,
|
|
1264
|
+
"timestamp": _ts_iso(m.get("timestamp")),
|
|
1265
|
+
"text": "\n".join(m["texts"]),
|
|
1266
|
+
}
|
|
1267
|
+
for i, m in enumerate(recent)
|
|
1268
|
+
]
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
def json_advisor(lines: list[dict]) -> list[str]:
|
|
1272
|
+
results = []
|
|
1273
|
+
for obj in lines:
|
|
1274
|
+
if obj.get("type") in PR.NOISE_TYPES:
|
|
1275
|
+
continue
|
|
1276
|
+
msg = obj.get("message", {})
|
|
1277
|
+
if not isinstance(msg.get("content"), list):
|
|
1278
|
+
continue
|
|
1279
|
+
for block in msg["content"]:
|
|
1280
|
+
if block.get("type") == "advisor_tool_result":
|
|
1281
|
+
inner = block.get("content", {})
|
|
1282
|
+
if isinstance(inner, dict) and inner.get("text"):
|
|
1283
|
+
results.append(inner["text"])
|
|
1284
|
+
return results
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
def json_pre_compact(lines: list[dict], window: int = 40) -> dict:
|
|
1288
|
+
messages = PR.get_messages(lines)
|
|
1289
|
+
compact_idx = None
|
|
1290
|
+
for i, m in enumerate(messages):
|
|
1291
|
+
if m["is_compact"]:
|
|
1292
|
+
compact_idx = i
|
|
1293
|
+
if compact_idx is None:
|
|
1294
|
+
return {"found_compact": False, "messages": _messages_json(messages[-10:])}
|
|
1295
|
+
start = max(0, compact_idx - window)
|
|
1296
|
+
return {
|
|
1297
|
+
"found_compact": True,
|
|
1298
|
+
"messages": _messages_json(messages[start:compact_idx]),
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
def json_dump(lines: list[dict], limit: int = 80) -> list[dict]:
|
|
1303
|
+
messages = [m for m in PR.get_messages(lines) if m["texts"] or m["is_compact"]]
|
|
1304
|
+
return _messages_json(messages[-limit:])
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
def json_debug(lines: list[dict]) -> dict:
|
|
1308
|
+
type_counts: dict[str, int] = {}
|
|
1309
|
+
for obj in lines:
|
|
1310
|
+
t = obj.get("type", "<missing>")
|
|
1311
|
+
type_counts[t] = type_counts.get(t, 0) + 1
|
|
1312
|
+
block_type_counts: dict[str, int] = {}
|
|
1313
|
+
advisor_blocks = 0
|
|
1314
|
+
for obj in lines:
|
|
1315
|
+
if obj.get("type") in PR.NOISE_TYPES:
|
|
1316
|
+
continue
|
|
1317
|
+
content = obj.get("message", {}).get("content", [])
|
|
1318
|
+
if isinstance(content, list):
|
|
1319
|
+
for block in content:
|
|
1320
|
+
if isinstance(block, dict):
|
|
1321
|
+
bt = block.get("type", "<missing>")
|
|
1322
|
+
block_type_counts[bt] = block_type_counts.get(bt, 0) + 1
|
|
1323
|
+
if bt == "advisor_tool_result":
|
|
1324
|
+
advisor_blocks += 1
|
|
1325
|
+
compact_markers = sum(1 for m in PR.get_messages(lines) if m["is_compact"])
|
|
1326
|
+
return {
|
|
1327
|
+
"entry_types": type_counts,
|
|
1328
|
+
"block_types": block_type_counts,
|
|
1329
|
+
"advisor_blocks": advisor_blocks,
|
|
1330
|
+
"compact_markers": compact_markers,
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1335
|
+
# CLI dispatch
|
|
1336
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1337
|
+
|
|
1338
|
+
LEGACY_MODES = {"last", "advisor", "pre-compact", "dump", "search", "debug"}
|
|
1339
|
+
|
|
1340
|
+
NEW_MODES = {
|
|
1341
|
+
"list", "lookup", "find", "resume-cmd", "brief",
|
|
1342
|
+
"changelog", "file-edits", "tool-calls",
|
|
1343
|
+
"subagent-list", "subagent-finals", "subagent-tools", "subagent-files",
|
|
1344
|
+
"resume-prev", "count", "journal", "diff", "timeline", "engagement",
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
ALL_MODES = LEGACY_MODES | NEW_MODES
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1351
|
+
p = argparse.ArgumentParser(
|
|
1352
|
+
description="Extract content from Claude Code transcript files",
|
|
1353
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1354
|
+
)
|
|
1355
|
+
# Targeting flags
|
|
1356
|
+
p.add_argument("--file", help="Path to .jsonl transcript file")
|
|
1357
|
+
p.add_argument("--cwd", help="Project working directory (to auto-find transcripts)")
|
|
1358
|
+
p.add_argument("--all-projects", action="store_true", help="Walk every project under --root")
|
|
1359
|
+
p.add_argument("--project", help="Filter projects by name substring (e.g. 'keel')")
|
|
1360
|
+
|
|
1361
|
+
# Root selector
|
|
1362
|
+
p.add_argument(
|
|
1363
|
+
"--root", default="live",
|
|
1364
|
+
help="One of {live, mirror, snapshot-24h, snapshot-1w, snapshot-1mo} or an absolute path",
|
|
1365
|
+
)
|
|
1366
|
+
|
|
1367
|
+
# Time bounds
|
|
1368
|
+
p.add_argument("--since", help="Lower time bound (ISO date / 7d / yesterday / now)")
|
|
1369
|
+
p.add_argument("--until", help="Upper time bound (same forms as --since)")
|
|
1370
|
+
p.add_argument("--date", help="Single-day window for timeline mode (ISO date / yesterday / today)")
|
|
1371
|
+
p.add_argument("--gap", help="Idle-gap threshold for timeline blocks (e.g. 15m, 1h; default 15m)")
|
|
1372
|
+
p.add_argument("--break", dest="break_spec",
|
|
1373
|
+
help="Break threshold for engagement mode (e.g. 5m, 20m; default 10m)")
|
|
1374
|
+
p.add_argument(
|
|
1375
|
+
"--tz", default=None,
|
|
1376
|
+
help="Display timezone override: IANA name (America/New_York), UTC, or offset (+5, -4). "
|
|
1377
|
+
"Default: system local time.",
|
|
1378
|
+
)
|
|
1379
|
+
|
|
1380
|
+
# Mode
|
|
1381
|
+
p.add_argument(
|
|
1382
|
+
"--mode", choices=sorted(ALL_MODES), default="last",
|
|
1383
|
+
help="Operation mode (see SKILL.md or references/modes.md)",
|
|
1384
|
+
)
|
|
1385
|
+
|
|
1386
|
+
# Mode-specific flags
|
|
1387
|
+
p.add_argument("--query", help="Search query (for search/count modes)")
|
|
1388
|
+
p.add_argument("--uuid", help="UUID prefix (for lookup/resume-cmd modes)")
|
|
1389
|
+
p.add_argument("--title", help="Title substring (for find mode)")
|
|
1390
|
+
p.add_argument("--first-prompt", dest="first_prompt", help="First-prompt substring (for find mode)")
|
|
1391
|
+
p.add_argument("--role", default="both", choices=["user", "assistant", "both"])
|
|
1392
|
+
p.add_argument("--in", dest="in_channel", default="text",
|
|
1393
|
+
choices=["text", "tool_use", "tool_result", "thinking", "all"])
|
|
1394
|
+
p.add_argument("--tool", help="Comma-separated tool names (for tool-calls)")
|
|
1395
|
+
p.add_argument("--subagent", help="Subagent file path (for subagent-tools/files)")
|
|
1396
|
+
p.add_argument("--file-a", dest="file_a", help="First file for diff mode")
|
|
1397
|
+
p.add_argument("--file-b", dest="file_b", help="Second file for diff mode")
|
|
1398
|
+
p.add_argument("--subagents-of", dest="subagents_of", help="Parent session for sibling diff")
|
|
1399
|
+
|
|
1400
|
+
# Behavior flags
|
|
1401
|
+
p.add_argument("--exclude-current", action="store_true",
|
|
1402
|
+
help="Drop the current session (via CLAUDE_SESSION_ID) from output")
|
|
1403
|
+
p.add_argument("--include-subagents", action="store_true",
|
|
1404
|
+
help="Fold subagent finals into brief/last/dump output")
|
|
1405
|
+
p.add_argument("--force-dump", action="store_true",
|
|
1406
|
+
help="Bypass the 5MB dump-size guard")
|
|
1407
|
+
p.add_argument("--format", default="text", choices=["text", "json"],
|
|
1408
|
+
help="Output format (json works on every mode except the legacy aliases)")
|
|
1409
|
+
p.add_argument("--json", action="store_true", help="Alias for --format json")
|
|
1410
|
+
p.add_argument("-n", type=int, default=5, help="Count modifier (last/dump/resume-prev)")
|
|
1411
|
+
|
|
1412
|
+
# Legacy alias flags
|
|
1413
|
+
p.add_argument("--list", action="store_true", help="List transcripts (legacy alias for --mode list)")
|
|
1414
|
+
p.add_argument("--list-subagents", action="store_true",
|
|
1415
|
+
help="List subagent files (legacy alias for --mode subagent-list)")
|
|
1416
|
+
return p
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
def _resolve_time(spec: str | None) -> datetime | None:
|
|
1420
|
+
if not spec:
|
|
1421
|
+
return None
|
|
1422
|
+
return P.parse_timespec(spec)
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
def main() -> int:
|
|
1426
|
+
parser = build_parser()
|
|
1427
|
+
args = parser.parse_args()
|
|
1428
|
+
|
|
1429
|
+
# Display timezone: default is system local time; --tz overrides.
|
|
1430
|
+
# Must run before anything formats a timestamp.
|
|
1431
|
+
try:
|
|
1432
|
+
PR.set_timezone(args.tz)
|
|
1433
|
+
except ValueError as e:
|
|
1434
|
+
print(str(e), file=sys.stderr)
|
|
1435
|
+
return 1
|
|
1436
|
+
|
|
1437
|
+
fmt = "json" if (args.format == "json" or args.json) else "text"
|
|
1438
|
+
|
|
1439
|
+
# Legacy alias translation — do NOT modify output for these paths.
|
|
1440
|
+
if args.list:
|
|
1441
|
+
if not args.cwd:
|
|
1442
|
+
print("--cwd required with --list", file=sys.stderr)
|
|
1443
|
+
return 1
|
|
1444
|
+
root = P.resolve_root(args.root)
|
|
1445
|
+
print(mode_list_legacy(args.cwd, root))
|
|
1446
|
+
return 0
|
|
1447
|
+
|
|
1448
|
+
if args.list_subagents:
|
|
1449
|
+
if not args.file:
|
|
1450
|
+
print("--file required with --list-subagents", file=sys.stderr)
|
|
1451
|
+
return 1
|
|
1452
|
+
path = Path(args.file)
|
|
1453
|
+
subs = P.list_subagents(path)
|
|
1454
|
+
if not subs:
|
|
1455
|
+
print("No subagent transcripts found.")
|
|
1456
|
+
return 0
|
|
1457
|
+
print(f"Subagents for {path.name}:")
|
|
1458
|
+
for f in subs:
|
|
1459
|
+
size_kb = f.stat().st_size / 1024
|
|
1460
|
+
print(f" {size_kb:5.0f}KB {f.name}")
|
|
1461
|
+
return 0
|
|
1462
|
+
|
|
1463
|
+
root = P.resolve_root(args.root)
|
|
1464
|
+
current_uuid = P.current_session_id()
|
|
1465
|
+
since = _resolve_time(args.since)
|
|
1466
|
+
until = _resolve_time(args.until)
|
|
1467
|
+
|
|
1468
|
+
# Legacy --mode search with --cwd (no --file) preserved byte-for-byte.
|
|
1469
|
+
if args.mode == "search" and args.cwd and not args.file and not args.all_projects \
|
|
1470
|
+
and not args.project and fmt == "text" \
|
|
1471
|
+
and args.role == "both" and args.in_channel == "text":
|
|
1472
|
+
if not args.query:
|
|
1473
|
+
print("--query required with --mode search", file=sys.stderr)
|
|
1474
|
+
return 1
|
|
1475
|
+
files = P.list_transcripts(P.find_project_dir(args.cwd, root))
|
|
1476
|
+
if not files:
|
|
1477
|
+
print("No transcript files found.")
|
|
1478
|
+
return 0
|
|
1479
|
+
total_matches = 0
|
|
1480
|
+
for i, f in enumerate(files, 1):
|
|
1481
|
+
print(f"Searching {i}/{len(files)}: {f.name}...", file=sys.stderr, end="\r")
|
|
1482
|
+
try:
|
|
1483
|
+
lines = PR.parse_lines(f)
|
|
1484
|
+
result = mode_search_legacy(lines, args.query)
|
|
1485
|
+
except Exception as e:
|
|
1486
|
+
print(f"\nError reading {f.name}: {e}", file=sys.stderr)
|
|
1487
|
+
continue
|
|
1488
|
+
if result is not None:
|
|
1489
|
+
mtime = _fmt_mtime(f.stat().st_mtime)
|
|
1490
|
+
print(f"\n{'=' * 60}")
|
|
1491
|
+
print(f"Session: {f.name} ({mtime})")
|
|
1492
|
+
print("=" * 60)
|
|
1493
|
+
print(result)
|
|
1494
|
+
total_matches += 1
|
|
1495
|
+
print(file=sys.stderr)
|
|
1496
|
+
if total_matches == 0:
|
|
1497
|
+
print(f"No matches for '{args.query}' found across {len(files)} session(s).")
|
|
1498
|
+
else:
|
|
1499
|
+
print(f"\n--- Found matches in {total_matches}/{len(files)} session(s) ---")
|
|
1500
|
+
return 0
|
|
1501
|
+
|
|
1502
|
+
mode = args.mode
|
|
1503
|
+
|
|
1504
|
+
# Discovery modes — don't need --file
|
|
1505
|
+
if mode == "list":
|
|
1506
|
+
_emit(mode_list(root, args.cwd, args.all_projects, since, until,
|
|
1507
|
+
args.exclude_current, current_uuid,
|
|
1508
|
+
project=args.project, fmt=fmt))
|
|
1509
|
+
return 0
|
|
1510
|
+
if mode == "lookup":
|
|
1511
|
+
code, out = mode_lookup(args.uuid or "", root, fmt=fmt)
|
|
1512
|
+
_emit(out)
|
|
1513
|
+
return code
|
|
1514
|
+
if mode == "find":
|
|
1515
|
+
_emit(mode_find(root, args.title, args.first_prompt, current_uuid,
|
|
1516
|
+
project=args.project, fmt=fmt))
|
|
1517
|
+
return 0
|
|
1518
|
+
if mode == "resume-cmd":
|
|
1519
|
+
code, out = mode_resume_cmd(args.uuid or "", root, fmt=fmt)
|
|
1520
|
+
_emit(out)
|
|
1521
|
+
return code
|
|
1522
|
+
if mode == "resume-prev":
|
|
1523
|
+
if not args.cwd:
|
|
1524
|
+
print("--cwd required for resume-prev", file=sys.stderr)
|
|
1525
|
+
return 1
|
|
1526
|
+
_emit(mode_resume_prev(args.cwd, root, args.n, fmt=fmt))
|
|
1527
|
+
return 0
|
|
1528
|
+
if mode == "count":
|
|
1529
|
+
if not args.query:
|
|
1530
|
+
print("--query required for count", file=sys.stderr)
|
|
1531
|
+
return 1
|
|
1532
|
+
counts = mode_count(root, args.cwd, args.all_projects, args.query,
|
|
1533
|
+
args.role, args.in_channel, since, until,
|
|
1534
|
+
project=args.project,
|
|
1535
|
+
exclude_current=args.exclude_current,
|
|
1536
|
+
current_uuid=current_uuid)
|
|
1537
|
+
if fmt == "json":
|
|
1538
|
+
_print_json(counts)
|
|
1539
|
+
else:
|
|
1540
|
+
print(
|
|
1541
|
+
f"{counts['sessions']} sessions, {counts['messages']} total messages, "
|
|
1542
|
+
f"{counts['matches']} matches",
|
|
1543
|
+
file=sys.stderr,
|
|
1544
|
+
)
|
|
1545
|
+
print(counts["sessions"])
|
|
1546
|
+
return 0
|
|
1547
|
+
if mode == "journal":
|
|
1548
|
+
_emit(mode_journal(root, args.cwd, args.all_projects, since, until,
|
|
1549
|
+
current_uuid, project=args.project, fmt=fmt,
|
|
1550
|
+
exclude_current=args.exclude_current))
|
|
1551
|
+
return 0
|
|
1552
|
+
if mode == "timeline":
|
|
1553
|
+
try:
|
|
1554
|
+
if args.date:
|
|
1555
|
+
day = P.parse_timespec(args.date).replace(
|
|
1556
|
+
hour=0, minute=0, second=0, microsecond=0
|
|
1557
|
+
)
|
|
1558
|
+
t_since, t_until = day, day + timedelta(days=1)
|
|
1559
|
+
else:
|
|
1560
|
+
t_since = since or P.parse_timespec("today")
|
|
1561
|
+
t_until = until or P.parse_timespec("now")
|
|
1562
|
+
gap_minutes = _parse_gap(args.gap)
|
|
1563
|
+
except ValueError as e:
|
|
1564
|
+
print(str(e), file=sys.stderr)
|
|
1565
|
+
return 1
|
|
1566
|
+
# Timeline is inherently cross-project — default to all projects.
|
|
1567
|
+
project_dirs = _scoped_project_dirs(
|
|
1568
|
+
root, args.cwd, args.all_projects, args.project, default_all=True
|
|
1569
|
+
)
|
|
1570
|
+
data = build_timeline(project_dirs, t_since, t_until, gap_minutes, current_uuid,
|
|
1571
|
+
exclude_current=args.exclude_current)
|
|
1572
|
+
if fmt == "json":
|
|
1573
|
+
_print_json(timeline_json(data))
|
|
1574
|
+
else:
|
|
1575
|
+
print(render_timeline(data, tz_label=args.tz or "local"))
|
|
1576
|
+
return 0
|
|
1577
|
+
if mode == "engagement":
|
|
1578
|
+
try:
|
|
1579
|
+
break_minutes = _parse_gap(args.break_spec, default=10)
|
|
1580
|
+
if args.date:
|
|
1581
|
+
day = P.parse_timespec(args.date).replace(
|
|
1582
|
+
hour=0, minute=0, second=0, microsecond=0
|
|
1583
|
+
)
|
|
1584
|
+
e_since, e_until = day, day + timedelta(days=1)
|
|
1585
|
+
else:
|
|
1586
|
+
e_since, e_until = since, until
|
|
1587
|
+
except ValueError as e:
|
|
1588
|
+
print(str(e), file=sys.stderr)
|
|
1589
|
+
return 1
|
|
1590
|
+
report_file = Path(args.file) if args.file else None
|
|
1591
|
+
if report_file and not report_file.exists():
|
|
1592
|
+
print(f"File not found: {report_file}", file=sys.stderr)
|
|
1593
|
+
return 1
|
|
1594
|
+
if report_file and e_since is None:
|
|
1595
|
+
# Window defaults to the file's own first→last user prompt.
|
|
1596
|
+
evs, _ = _engagement_event_streams(report_file, None, None)
|
|
1597
|
+
if not evs:
|
|
1598
|
+
print("(no user messages in this session)")
|
|
1599
|
+
return 0
|
|
1600
|
+
e_since = evs[0]
|
|
1601
|
+
e_until = e_until or evs[-1] + timedelta(seconds=1)
|
|
1602
|
+
else:
|
|
1603
|
+
e_since = e_since or P.parse_timespec("today")
|
|
1604
|
+
e_until = e_until or P.parse_timespec("now")
|
|
1605
|
+
# Scope filters reporting only; the attention stream is always global.
|
|
1606
|
+
report_dirs = None
|
|
1607
|
+
if not report_file:
|
|
1608
|
+
report_dirs = _scoped_project_dirs(
|
|
1609
|
+
root, args.cwd, args.all_projects, args.project, default_all=True
|
|
1610
|
+
)
|
|
1611
|
+
data = build_engagement(
|
|
1612
|
+
root, report_dirs, report_file, e_since, e_until, break_minutes,
|
|
1613
|
+
current_uuid, exclude_current=args.exclude_current,
|
|
1614
|
+
)
|
|
1615
|
+
if fmt == "json":
|
|
1616
|
+
_print_json(engagement_json(data))
|
|
1617
|
+
else:
|
|
1618
|
+
print(render_engagement(data, tz_label=args.tz or "local"))
|
|
1619
|
+
return 0
|
|
1620
|
+
if mode == "diff":
|
|
1621
|
+
if args.subagents_of:
|
|
1622
|
+
parent = Path(args.subagents_of)
|
|
1623
|
+
subs = P.list_subagents(parent)
|
|
1624
|
+
if len(subs) < 2:
|
|
1625
|
+
print("Need ≥2 subagents to diff.")
|
|
1626
|
+
return 1
|
|
1627
|
+
_emit(mode_diff(subs[0], subs[1], fmt=fmt))
|
|
1628
|
+
return 0
|
|
1629
|
+
if not (args.file_a and args.file_b):
|
|
1630
|
+
print("--file-a and --file-b required for diff (or --subagents-of)", file=sys.stderr)
|
|
1631
|
+
return 1
|
|
1632
|
+
_emit(mode_diff(Path(args.file_a), Path(args.file_b), fmt=fmt))
|
|
1633
|
+
return 0
|
|
1634
|
+
# (cwd-scoped searches with non-default role/in/json land here — the
|
|
1635
|
+
# byte-compat legacy path above already handled the default-flag case.)
|
|
1636
|
+
if mode == "search" and (args.file or args.all_projects or args.project or args.cwd):
|
|
1637
|
+
if not args.query:
|
|
1638
|
+
print("--query required", file=sys.stderr)
|
|
1639
|
+
return 1
|
|
1640
|
+
fp = Path(args.file) if args.file else None
|
|
1641
|
+
_emit(mode_search_v2(root, args.cwd, args.all_projects, fp, args.query,
|
|
1642
|
+
args.role, args.in_channel, since, until,
|
|
1643
|
+
project=args.project, fmt=fmt,
|
|
1644
|
+
exclude_current=args.exclude_current,
|
|
1645
|
+
current_uuid=current_uuid))
|
|
1646
|
+
return 0
|
|
1647
|
+
|
|
1648
|
+
# File-required modes
|
|
1649
|
+
if mode == "subagent-tools":
|
|
1650
|
+
if not args.subagent:
|
|
1651
|
+
print("--subagent required", file=sys.stderr)
|
|
1652
|
+
return 1
|
|
1653
|
+
sp = Path(args.subagent)
|
|
1654
|
+
_emit(mode_tool_calls(sp, _split_tools(args.tool), fmt=fmt))
|
|
1655
|
+
return 0
|
|
1656
|
+
if mode == "subagent-files":
|
|
1657
|
+
if not args.subagent:
|
|
1658
|
+
print("--subagent required", file=sys.stderr)
|
|
1659
|
+
return 1
|
|
1660
|
+
sp = Path(args.subagent)
|
|
1661
|
+
_emit(mode_file_edits(sp, fmt=fmt))
|
|
1662
|
+
return 0
|
|
1663
|
+
|
|
1664
|
+
if not args.file:
|
|
1665
|
+
print("--file required (or use a discovery mode)", file=sys.stderr)
|
|
1666
|
+
return 1
|
|
1667
|
+
|
|
1668
|
+
path = Path(args.file)
|
|
1669
|
+
if not path.exists():
|
|
1670
|
+
print(f"File not found: {path}", file=sys.stderr)
|
|
1671
|
+
return 1
|
|
1672
|
+
|
|
1673
|
+
if mode == "brief":
|
|
1674
|
+
_emit(mode_brief(path, args.include_subagents, current_uuid, fmt=fmt))
|
|
1675
|
+
return 0
|
|
1676
|
+
if mode == "subagent-list":
|
|
1677
|
+
_emit(mode_subagent_list(path, fmt=fmt))
|
|
1678
|
+
return 0
|
|
1679
|
+
if mode == "subagent-finals":
|
|
1680
|
+
_emit(mode_subagent_finals(path, fmt=fmt))
|
|
1681
|
+
return 0
|
|
1682
|
+
if mode == "changelog":
|
|
1683
|
+
_emit(mode_changelog(path, fmt=fmt))
|
|
1684
|
+
return 0
|
|
1685
|
+
if mode == "file-edits":
|
|
1686
|
+
_emit(mode_file_edits(path, fmt=fmt))
|
|
1687
|
+
return 0
|
|
1688
|
+
if mode == "tool-calls":
|
|
1689
|
+
_emit(mode_tool_calls(path, _split_tools(args.tool), fmt=fmt))
|
|
1690
|
+
return 0
|
|
1691
|
+
|
|
1692
|
+
# Legacy single-file modes
|
|
1693
|
+
lines = PR.parse_lines(path)
|
|
1694
|
+
|
|
1695
|
+
if fmt == "json":
|
|
1696
|
+
if mode == "last":
|
|
1697
|
+
_print_json(json_last(lines, args.n))
|
|
1698
|
+
elif mode == "advisor":
|
|
1699
|
+
_print_json(json_advisor(lines))
|
|
1700
|
+
elif mode == "pre-compact":
|
|
1701
|
+
_print_json(json_pre_compact(lines))
|
|
1702
|
+
elif mode == "dump":
|
|
1703
|
+
_print_json(json_dump(lines, max(args.n, 80) if args.n != 5 else 80))
|
|
1704
|
+
elif mode == "debug":
|
|
1705
|
+
_print_json(json_debug(lines))
|
|
1706
|
+
return 0
|
|
1707
|
+
|
|
1708
|
+
print(f"[{path.name} — {len(lines)} entries]\n")
|
|
1709
|
+
|
|
1710
|
+
if mode == "last":
|
|
1711
|
+
body = mode_last(lines, args.n)
|
|
1712
|
+
if args.include_subagents:
|
|
1713
|
+
body += _append_subagents(path)
|
|
1714
|
+
print(body)
|
|
1715
|
+
elif mode == "advisor":
|
|
1716
|
+
print(mode_advisor(lines))
|
|
1717
|
+
elif mode == "pre-compact":
|
|
1718
|
+
print(mode_pre_compact(lines))
|
|
1719
|
+
elif mode == "dump":
|
|
1720
|
+
size = path.stat().st_size
|
|
1721
|
+
if size > PR.LARGE_FILE_THRESHOLD and not args.force_dump:
|
|
1722
|
+
has_compact = any(m["is_compact"] for m in PR.get_messages(lines))
|
|
1723
|
+
fallback = "pre-compact" if has_compact else "last"
|
|
1724
|
+
mb = size / (1024 * 1024)
|
|
1725
|
+
print(
|
|
1726
|
+
f"[note: transcript is {mb:.1f}MB — degraded to {fallback}. "
|
|
1727
|
+
f"Override with --force-dump.]",
|
|
1728
|
+
file=sys.stderr,
|
|
1729
|
+
)
|
|
1730
|
+
if fallback == "pre-compact":
|
|
1731
|
+
print(mode_pre_compact(lines))
|
|
1732
|
+
else:
|
|
1733
|
+
print(mode_last(lines, 10))
|
|
1734
|
+
else:
|
|
1735
|
+
body = mode_dump(lines, max(args.n, 80) if args.n != 5 else 80)
|
|
1736
|
+
if args.include_subagents:
|
|
1737
|
+
body += _append_subagents(path)
|
|
1738
|
+
print(body)
|
|
1739
|
+
elif mode == "search":
|
|
1740
|
+
if not args.query:
|
|
1741
|
+
print("--query required with --mode search", file=sys.stderr)
|
|
1742
|
+
return 1
|
|
1743
|
+
result = mode_search_legacy(lines, args.query)
|
|
1744
|
+
print(result if result is not None
|
|
1745
|
+
else f"No assistant messages containing '{args.query}' found.")
|
|
1746
|
+
elif mode == "debug":
|
|
1747
|
+
print(mode_debug(lines))
|
|
1748
|
+
|
|
1749
|
+
return 0
|
|
1750
|
+
|
|
1751
|
+
|
|
1752
|
+
def _split_tools(s: str | None) -> set[str] | None:
|
|
1753
|
+
if not s:
|
|
1754
|
+
return None
|
|
1755
|
+
return {t.strip() for t in s.split(",") if t.strip()}
|
|
1756
|
+
|
|
1757
|
+
|
|
1758
|
+
def _append_subagents(parent_path: Path) -> str:
|
|
1759
|
+
finals = SA.agent_finals(parent_path)
|
|
1760
|
+
if not finals:
|
|
1761
|
+
return ""
|
|
1762
|
+
parts = ["\n"]
|
|
1763
|
+
for agent_id, meta, text in finals:
|
|
1764
|
+
atype = meta.get("agentType", "unknown")
|
|
1765
|
+
short = agent_id.replace("agent-", "")[:8]
|
|
1766
|
+
tail = (text[:1500] + "…") if len(text) > 1500 else text
|
|
1767
|
+
parts.append(f"\n[subagent {short} · {atype}]\n{tail}")
|
|
1768
|
+
return "\n".join(parts)
|
|
1769
|
+
|
|
1770
|
+
|
|
1771
|
+
if __name__ == "__main__":
|
|
1772
|
+
try:
|
|
1773
|
+
sys.exit(main())
|
|
1774
|
+
except FileNotFoundError as e:
|
|
1775
|
+
print(str(e), file=sys.stderr)
|
|
1776
|
+
sys.exit(1)
|