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.
Files changed (128) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +5 -0
  3. package/bin/install.cjs +981 -950
  4. package/hooks/repo-search-nudge.js +32 -32
  5. package/package.json +1 -1
  6. package/skills/estack-active-learning-tutor/SKILL.md +339 -339
  7. package/skills/estack-better-title/SKILL.md +64 -64
  8. package/skills/estack-better-title/scripts/rename.sh +55 -55
  9. package/skills/estack-chris-voss/SKILL.md +80 -80
  10. package/skills/estack-chris-voss/references/elliot-notes.md +120 -120
  11. package/skills/estack-chris-voss/references/voss-principles.md +210 -210
  12. package/skills/estack-customer-discovery/SKILL.md +60 -60
  13. package/skills/estack-flight-planner/SKILL.md +332 -332
  14. package/skills/estack-flight-planner/references/config_schema.md +156 -156
  15. package/skills/estack-flight-planner/references/flight_history_schema.md +97 -97
  16. package/skills/estack-flight-planner/references/shuttle_schedules.md +98 -98
  17. package/skills/estack-flight-planner/scripts/check_setup.sh +89 -89
  18. package/skills/estack-flight-planner/scripts/fetch_flights.py +99 -99
  19. package/skills/estack-flight-planner/scripts/filter_flights.py +265 -265
  20. package/skills/estack-flight-planner/scripts/pair_shuttles.py +173 -173
  21. package/skills/estack-github-issue-tracker/SKILL.md +322 -322
  22. package/skills/estack-github-issue-tracker/bin/tracker-tools.cjs +1358 -1358
  23. package/skills/estack-github-issue-tracker/references/gh-cli-patterns.md +124 -124
  24. package/skills/estack-github-issue-tracker/references/result-file-schema.md +156 -156
  25. package/skills/estack-github-issue-tracker/references/tracker-schema.md +96 -96
  26. package/skills/estack-github-issue-tracker/tracker-template.md +58 -58
  27. package/skills/estack-leadership-coach/SKILL.md +235 -0
  28. package/skills/estack-leadership-coach/adding-references.md +280 -0
  29. package/skills/estack-leadership-coach/frameworks/delegation/flows/post-mortem.md +120 -0
  30. package/skills/estack-leadership-coach/frameworks/delegation/flows/pre-delegation.md +138 -0
  31. package/skills/estack-leadership-coach/frameworks/delegation/phases/1-intake.md +145 -0
  32. package/skills/estack-leadership-coach/frameworks/delegation/phases/2-trm-assessment.md +119 -0
  33. package/skills/estack-leadership-coach/frameworks/delegation/phases/3-enrollment.md +132 -0
  34. package/skills/estack-leadership-coach/frameworks/delegation/phases/4-build-brief.md +171 -0
  35. package/skills/estack-leadership-coach/frameworks/delegation/phases/5-monitoring.md +134 -0
  36. package/skills/estack-leadership-coach/frameworks/delegation/phases/6-reverse-delegation.md +118 -0
  37. package/skills/estack-leadership-coach/frameworks/delegation/phases/7-diagnose.md +200 -0
  38. 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
  39. 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
  40. package/skills/estack-leadership-coach/references/.source-files/deci-ryan_self-determination-theory__selfdeterminationtheory-org-theory-overview-page.md +61 -0
  41. package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-3-key-insights-into-the-global-workplace-2024.md +57 -0
  42. 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
  43. package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-state-of-the-global-workplace-2026-global-data-summary.md +73 -0
  44. package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-state-of-the-global-workplace-2026-report-landing.md +42 -0
  45. package/skills/estack-leadership-coach/references/.source-files/hormozi-leila_4-stages__leila-hormozi-the-art-of-delegation-blog-post.md +91 -0
  46. 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
  47. package/skills/estack-leadership-coach/references/.source-files/sanchez_main-street-millionaire__codie-sanchez-afford-anything-podcast-ep-565-show-notes.md +89 -0
  48. package/skills/estack-leadership-coach/references/.source-files/sullivan_who-not-how__dan-sullivan-impact-filter-tool-and-guide-booklet.md +565 -0
  49. 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
  50. package/skills/estack-leadership-coach/references/.source-files/van-edwards_cues__vanessa-van-edwards-roger-dooley-cues-interview.md +194 -0
  51. package/skills/estack-leadership-coach/references/deci-ryan_self-determination-theory.md +166 -0
  52. package/skills/estack-leadership-coach/references/doerr_measure-what-matters.md +154 -0
  53. package/skills/estack-leadership-coach/references/ferriss_4hww.md +189 -0
  54. package/skills/estack-leadership-coach/references/gallup_engagement-research.md +105 -0
  55. package/skills/estack-leadership-coach/references/gerber_e-myth-revisited.md +118 -0
  56. package/skills/estack-leadership-coach/references/grove_high-output-management.md +95 -0
  57. package/skills/estack-leadership-coach/references/hormozi-alex_followthrough.md +152 -0
  58. package/skills/estack-leadership-coach/references/hormozi-leila_4-stages.md +146 -0
  59. package/skills/estack-leadership-coach/references/oncken-wass_monkeys-hbr-1974.md +128 -0
  60. package/skills/estack-leadership-coach/references/sanchez_main-street-millionaire.md +196 -0
  61. package/skills/estack-leadership-coach/references/sullivan_who-not-how.md +137 -0
  62. package/skills/estack-leadership-coach/references/van-edwards_cues.md +189 -0
  63. package/skills/estack-migrate-claude-session-history/SKILL.md +226 -0
  64. package/skills/estack-migrate-claude-session-history/references/path-encoding.md +55 -0
  65. package/skills/estack-migrate-claude-session-history/references/troubleshooting.md +96 -0
  66. package/skills/estack-migrate-claude-session-history/scripts/migrate-claude-history.js +1123 -0
  67. package/skills/estack-migrate-claude-session-history/scripts/test-append-note.js +48 -0
  68. package/skills/estack-migrate-claude-session-history/scripts/test-validate-migration.py +326 -0
  69. package/skills/estack-migrate-claude-session-history/scripts/validate-migration.py +493 -0
  70. package/skills/estack-pdf-to-md/SKILL.md +180 -0
  71. package/skills/estack-pdf-to-md/scripts/pdf_to_md.py +596 -0
  72. package/skills/estack-productivity-prioritization-coach/SKILL.md +124 -0
  73. package/skills/estack-productivity-prioritization-coach/sources/01-tony-robbins-rpm.md +39 -0
  74. package/skills/estack-productivity-prioritization-coach/sources/02-justin-sung-task-prioritization.md +34 -0
  75. package/skills/estack-prompt-builder-coach/SKILL.md +81 -81
  76. package/skills/estack-prompt-builder-coach/definition-of-done-generator.md +42 -42
  77. package/skills/estack-prompt-builder-coach/prompt-builder.md +37 -37
  78. package/skills/estack-prompt-builder-coach/task-shaper.md +36 -36
  79. package/skills/estack-prompt-builder-coach/vague-ask-auditor.md +37 -37
  80. package/skills/estack-read-claude-session-history/SKILL.md +204 -204
  81. package/skills/estack-read-claude-session-history/references/jsonl-schema.md +126 -126
  82. package/skills/estack-read-claude-session-history/references/modes.md +423 -423
  83. package/skills/estack-read-claude-session-history/references/recipes.md +271 -271
  84. package/skills/estack-read-claude-session-history/scripts/lib/__init__.py +1 -1
  85. package/skills/estack-read-claude-session-history/scripts/lib/parser.py +460 -460
  86. package/skills/estack-read-claude-session-history/scripts/lib/paths.py +234 -234
  87. package/skills/estack-read-claude-session-history/scripts/lib/search.py +179 -179
  88. package/skills/estack-read-claude-session-history/scripts/lib/subagents.py +88 -88
  89. package/skills/estack-read-claude-session-history/scripts/lib/tools.py +144 -144
  90. package/skills/estack-read-claude-session-history/scripts/read_transcript.py +1776 -1776
  91. package/skills/estack-read-claude-session-history/scripts/tests/conftest.py +40 -40
  92. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/README.md +20 -20
  93. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/all-noise.jsonl +4 -4
  94. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/basic-session.jsonl +2 -2
  95. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-gaps.jsonl +9 -9
  96. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-noise.jsonl +7 -7
  97. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-a.jsonl +3 -3
  98. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-b.jsonl +3 -3
  99. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-waiting.jsonl +5 -5
  100. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/interrupted.jsonl +2 -2
  101. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/multi-compact.jsonl +8 -8
  102. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/pending-user.jsonl +2 -2
  103. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta/subagents/agent-aaa.jsonl +2 -2
  104. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta.jsonl +2 -2
  105. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.jsonl +2 -2
  106. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.meta.json +1 -1
  107. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent.jsonl +4 -4
  108. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/time-spread.jsonl +6 -6
  109. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/timeline-day-test.jsonl +5 -5
  110. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-zoo.jsonl +10 -10
  111. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/truncated.jsonl +2 -2
  112. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/unicode.jsonl +2 -2
  113. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-advisor.jsonl +3 -3
  114. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-compact.jsonl +5 -5
  115. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-thinking.jsonl +2 -2
  116. package/skills/estack-read-claude-session-history/scripts/tests/test_backup_roots.py +56 -56
  117. package/skills/estack-read-claude-session-history/scripts/tests/test_engagement.py +239 -239
  118. package/skills/estack-read-claude-session-history/scripts/tests/test_json_format.py +201 -201
  119. package/skills/estack-read-claude-session-history/scripts/tests/test_modes.py +199 -199
  120. package/skills/estack-read-claude-session-history/scripts/tests/test_parser.py +195 -195
  121. package/skills/estack-read-claude-session-history/scripts/tests/test_paths.py +133 -133
  122. package/skills/estack-read-claude-session-history/scripts/tests/test_search.py +78 -78
  123. package/skills/estack-read-claude-session-history/scripts/tests/test_subagents.py +43 -43
  124. package/skills/estack-read-claude-session-history/scripts/tests/test_timeline.py +179 -179
  125. package/skills/estack-read-claude-session-history/scripts/tests/test_timezone_and_project.py +212 -212
  126. package/skills/estack-read-claude-session-history/scripts/tests/test_tools.py +80 -80
  127. package/skills/estack-repo-search/SKILL.md +65 -65
  128. 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)