elliot-stack 1.0.38 → 1.0.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/estack-migrate-claude-session-history/SKILL.md +3 -2
- package/skills/estack-migrate-claude-session-history/scripts/__pycache__/validate-migration.cpython-313.pyc +0 -0
- package/skills/estack-read-claude-session-history/SKILL.md +30 -4
- package/skills/estack-read-claude-session-history/references/modes.md +65 -9
- package/skills/estack-read-claude-session-history/references/recipes.md +7 -1
- package/skills/estack-read-claude-session-history/scripts/__pycache__/read_transcript.cpython-313.pyc +0 -0
- package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/parser.cpython-313.pyc +0 -0
- package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/paths.cpython-313.pyc +0 -0
- package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/search.cpython-313.pyc +0 -0
- package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/subagents.cpython-313.pyc +0 -0
- package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/tools.cpython-313.pyc +0 -0
- package/skills/estack-read-claude-session-history/scripts/lib/parser.py +2 -1
- package/skills/estack-read-claude-session-history/scripts/lib/search.py +27 -9
- package/skills/estack-read-claude-session-history/scripts/read_transcript.py +267 -84
- package/skills/estack-migrate-claude-session-history/scripts/test-append-note.js +0 -48
- package/skills/estack-migrate-claude-session-history/scripts/test-validate-migration.py +0 -326
- package/skills/estack-read-claude-session-history/scripts/tests/conftest.py +0 -40
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/README.md +0 -20
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/all-noise.jsonl +0 -4
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/basic-session.jsonl +0 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-gaps.jsonl +0 -9
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-noise.jsonl +0 -7
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-a.jsonl +0 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-b.jsonl +0 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-waiting.jsonl +0 -5
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/interrupted.jsonl +0 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/multi-compact.jsonl +0 -8
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/pending-user.jsonl +0 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta/subagents/agent-aaa.jsonl +0 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta.jsonl +0 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.jsonl +0 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.meta.json +0 -1
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent.jsonl +0 -4
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/time-spread.jsonl +0 -6
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/timeline-day-test.jsonl +0 -5
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-usage-parent/subagents/agent-sub1.jsonl +0 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-usage-parent.jsonl +0 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-zoo.jsonl +0 -10
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/truncated.jsonl +0 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/unicode.jsonl +0 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-advisor.jsonl +0 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-compact.jsonl +0 -5
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-thinking.jsonl +0 -2
- package/skills/estack-read-claude-session-history/scripts/tests/test_backup_roots.py +0 -56
- package/skills/estack-read-claude-session-history/scripts/tests/test_engagement.py +0 -239
- package/skills/estack-read-claude-session-history/scripts/tests/test_json_format.py +0 -201
- package/skills/estack-read-claude-session-history/scripts/tests/test_modes.py +0 -323
- package/skills/estack-read-claude-session-history/scripts/tests/test_parser.py +0 -195
- package/skills/estack-read-claude-session-history/scripts/tests/test_paths.py +0 -133
- package/skills/estack-read-claude-session-history/scripts/tests/test_search.py +0 -78
- package/skills/estack-read-claude-session-history/scripts/tests/test_subagents.py +0 -43
- package/skills/estack-read-claude-session-history/scripts/tests/test_timeline.py +0 -179
- package/skills/estack-read-claude-session-history/scripts/tests/test_timezone_and_project.py +0 -212
- package/skills/estack-read-claude-session-history/scripts/tests/test_tools.py +0 -80
|
@@ -33,6 +33,16 @@ from lib import search as S # noqa: E402
|
|
|
33
33
|
from lib import subagents as SA # noqa: E402
|
|
34
34
|
|
|
35
35
|
|
|
36
|
+
# Wide-scope search output budget. Full match windows across many sessions can
|
|
37
|
+
# balloon past the harness's ~25k-token Read cap, forcing a write-then-can't-read
|
|
38
|
+
# round trip. We summarize by default and degrade --full back to a summary once
|
|
39
|
+
# the rendered text would exceed this many characters (~10k tokens at ~4 ch/tok).
|
|
40
|
+
SEARCH_CHAR_BUDGET = 40_000
|
|
41
|
+
# Cap session lines in the summary view so the summary itself stays bounded.
|
|
42
|
+
# Overflow is counted and noted, never silently dropped.
|
|
43
|
+
SEARCH_SUMMARY_SESSION_CAP = 200
|
|
44
|
+
|
|
45
|
+
|
|
36
46
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
37
47
|
# Legacy mode implementations (kept byte-identical to v1 for backwards-compat)
|
|
38
48
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -107,24 +117,6 @@ def mode_dump(lines, limit=80):
|
|
|
107
117
|
return "\n\n".join(output)
|
|
108
118
|
|
|
109
119
|
|
|
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
120
|
def mode_debug(lines):
|
|
129
121
|
output = []
|
|
130
122
|
type_counts: dict[str, int] = {}
|
|
@@ -579,6 +571,81 @@ def mode_tool_calls(path: Path, tool_filter: set[str] | None, fmt: str = "text")
|
|
|
579
571
|
return "\n".join(out).lstrip("\n")
|
|
580
572
|
|
|
581
573
|
|
|
574
|
+
def _one_line(text: str, n: int) -> str:
|
|
575
|
+
"""Collapse whitespace to a single line and truncate to n chars."""
|
|
576
|
+
s = " ".join((text or "").split())
|
|
577
|
+
return s[:n] + ("…" if len(s) > n else "")
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _sessions_by_mtime(matches) -> list:
|
|
581
|
+
"""Group matches by session, return (session_path, matches) sorted newest first."""
|
|
582
|
+
by_session: dict[Path, list] = {}
|
|
583
|
+
for m in matches:
|
|
584
|
+
by_session.setdefault(m.session_path, []).append(m)
|
|
585
|
+
return sorted(by_session.items(), key=lambda kv: kv[1][0].mtime, reverse=True)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _render_search_full(matches) -> str:
|
|
589
|
+
"""Full per-match windows grouped by session, newest first (the detailed view)."""
|
|
590
|
+
out = []
|
|
591
|
+
for sp, ms in _sessions_by_mtime(matches):
|
|
592
|
+
out.append(f"{'=' * 60}\nSession: {sp.name} ({_fmt_mtime(ms[0].mtime)})\n{'=' * 60}")
|
|
593
|
+
for i, m in enumerate(ms, 1):
|
|
594
|
+
label = f"--- Match #{i} [{m.role}/{m.where}] ---"
|
|
595
|
+
out.append(f"{label}\n{m.window_text[:1500]}")
|
|
596
|
+
return "\n\n".join(out)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _render_search_summary(query: str, matches, already_full: bool = False) -> str:
|
|
600
|
+
"""One line per session: mtime · uuid8 · project · hits · first snippet.
|
|
601
|
+
|
|
602
|
+
Every hit is counted in the header; sessions past the cap are counted in a
|
|
603
|
+
footer note, never silently dropped. When ``already_full`` is set (the
|
|
604
|
+
summary is a degraded ``--full`` result), the footer drops the "use --full"
|
|
605
|
+
suggestion the caller already tried.
|
|
606
|
+
"""
|
|
607
|
+
sessions = _sessions_by_mtime(matches)
|
|
608
|
+
total_hits = len(matches)
|
|
609
|
+
total_sessions = len(sessions)
|
|
610
|
+
shown = sessions[:SEARCH_SUMMARY_SESSION_CAP]
|
|
611
|
+
hit_w = "match" if total_hits == 1 else "matches"
|
|
612
|
+
sess_w = "session" if total_sessions == 1 else "sessions"
|
|
613
|
+
lines = [f'=== "{query}": {total_hits} {hit_w} across {total_sessions} {sess_w} ===']
|
|
614
|
+
for sp, ms in shown:
|
|
615
|
+
uuid8 = sp.stem[:8]
|
|
616
|
+
project = P.decode_project_name(sp.parent.name)
|
|
617
|
+
snippet = _one_line(ms[0].window_text, 120)
|
|
618
|
+
n = len(ms)
|
|
619
|
+
# Pad the unit so singular ("hit ") and plural ("hits") align the snippet column.
|
|
620
|
+
hits = f'{n:>4} hit' + ('s' if n != 1 else ' ')
|
|
621
|
+
lines.append(f'{_fmt_mtime(ms[0].mtime)} {uuid8} {project[:28]:<28} {hits} · "{snippet}"')
|
|
622
|
+
if total_sessions > len(shown):
|
|
623
|
+
lines.append(
|
|
624
|
+
f"… and {total_sessions - len(shown)} more session(s) not shown — "
|
|
625
|
+
f"narrow with --project or a tighter --query."
|
|
626
|
+
)
|
|
627
|
+
# On a degrade (already_full) the [note] prefix already carries the actionable
|
|
628
|
+
# hint, so don't append a second — possibly conflicting — guidance line.
|
|
629
|
+
if not already_full:
|
|
630
|
+
lines.append("Use --full for match windows, or narrow with --project / a tighter --query.")
|
|
631
|
+
return "\n".join(lines)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _search_summary_json(matches) -> list:
|
|
635
|
+
"""Compact per-session metadata (no windows) for structured consumers."""
|
|
636
|
+
return [
|
|
637
|
+
{
|
|
638
|
+
"session": str(sp),
|
|
639
|
+
"uuid": sp.stem,
|
|
640
|
+
"project": P.decode_project_name(sp.parent.name),
|
|
641
|
+
"mtime_iso": _fmt_mtime(ms[0].mtime),
|
|
642
|
+
"hits": len(ms),
|
|
643
|
+
"first_snippet": _one_line(ms[0].window_text, 120),
|
|
644
|
+
}
|
|
645
|
+
for sp, ms in _sessions_by_mtime(matches)
|
|
646
|
+
]
|
|
647
|
+
|
|
648
|
+
|
|
582
649
|
def mode_search_v2(
|
|
583
650
|
root: Path,
|
|
584
651
|
cwd: str | None,
|
|
@@ -593,6 +660,7 @@ def mode_search_v2(
|
|
|
593
660
|
fmt: str = "text",
|
|
594
661
|
exclude_current: bool = False,
|
|
595
662
|
current_uuid: str | None = None,
|
|
663
|
+
full: bool = False,
|
|
596
664
|
):
|
|
597
665
|
"""Cross-scope search with role/in-channel filters."""
|
|
598
666
|
matches: list = []
|
|
@@ -607,12 +675,21 @@ def mode_search_v2(
|
|
|
607
675
|
pd = P.find_project_dir(cwd, root)
|
|
608
676
|
matches = list(S.search_project(pd, query, role, in_channel, since, until))
|
|
609
677
|
else:
|
|
610
|
-
|
|
678
|
+
# Unreachable from the CLI (the dispatch guarantees a scope flag), but keep
|
|
679
|
+
# the direct-call contract honest: JSON callers get a JSON object.
|
|
680
|
+
msg = "Provide --file, --cwd, --project, or --all-projects"
|
|
681
|
+
return {"error": msg} if fmt == "json" else msg
|
|
611
682
|
|
|
612
683
|
if exclude_current and current_uuid:
|
|
613
684
|
matches = [m for m in matches if m.session_path.stem != current_uuid]
|
|
614
685
|
|
|
686
|
+
# Single-file scope is narrow and won't overflow — render full by default.
|
|
687
|
+
# Wide scope (cwd / project / all-projects) summarizes unless --full is set.
|
|
688
|
+
wide = file_path is None
|
|
689
|
+
|
|
615
690
|
if fmt == "json":
|
|
691
|
+
if wide and not full:
|
|
692
|
+
return _search_summary_json(matches)
|
|
616
693
|
return [
|
|
617
694
|
{
|
|
618
695
|
"session": str(m.session_path),
|
|
@@ -628,17 +705,25 @@ def mode_search_v2(
|
|
|
628
705
|
if not matches:
|
|
629
706
|
return f"No matches for '{query}'."
|
|
630
707
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
708
|
+
if wide and not full:
|
|
709
|
+
return _render_search_summary(query, matches)
|
|
710
|
+
|
|
711
|
+
# Full render (single-file, or wide + --full), bounded by the char budget so
|
|
712
|
+
# the output never exceeds what the reader accepts. Degrade to summary if it would.
|
|
713
|
+
full_text = _render_search_full(matches)
|
|
714
|
+
if len(full_text) > SEARCH_CHAR_BUDGET:
|
|
715
|
+
# Ceiling-round the size so it always reads as strictly over the budget.
|
|
716
|
+
size_k = (len(full_text) + 999) // 1000
|
|
717
|
+
if wide:
|
|
718
|
+
hint = "Use a tighter --query / --role / --in, or --file <session> to read one session in full."
|
|
719
|
+
else:
|
|
720
|
+
hint = "This one session has many matches; use a tighter --query / --role / --in to narrow."
|
|
721
|
+
note = (
|
|
722
|
+
f"[note: full output is ~{size_k}K chars (> "
|
|
723
|
+
f"{SEARCH_CHAR_BUDGET // 1000}K budget) — showing a summary instead. {hint}]"
|
|
724
|
+
)
|
|
725
|
+
return note + "\n" + _render_search_summary(query, matches, already_full=True)
|
|
726
|
+
return full_text
|
|
642
727
|
|
|
643
728
|
|
|
644
729
|
def mode_subagent_list(path: Path, fmt: str = "text"):
|
|
@@ -774,9 +859,10 @@ def _tally_tool_usage(
|
|
|
774
859
|
continue
|
|
775
860
|
if ts.tzinfo is not None:
|
|
776
861
|
ts = ts.replace(tzinfo=None)
|
|
862
|
+
# Half-open window [since, until): since inclusive, until exclusive.
|
|
777
863
|
if since is not None and ts < since:
|
|
778
864
|
continue
|
|
779
|
-
if until is not None and ts
|
|
865
|
+
if until is not None and ts >= until:
|
|
780
866
|
continue
|
|
781
867
|
name = c["name"] or "(unknown)"
|
|
782
868
|
tool_counts[name] = tool_counts.get(name, 0) + 1
|
|
@@ -1116,17 +1202,38 @@ def _is_real_user_prompt(obj: dict) -> bool:
|
|
|
1116
1202
|
return False
|
|
1117
1203
|
|
|
1118
1204
|
|
|
1205
|
+
def _assistant_has_text(obj: dict) -> bool:
|
|
1206
|
+
"""True if an assistant entry carries human-visible text, not just tool_use.
|
|
1207
|
+
|
|
1208
|
+
A turn that only fires tools (no text block) is plumbing, not a reply the
|
|
1209
|
+
user reads, so it does not count as an assistant message.
|
|
1210
|
+
"""
|
|
1211
|
+
content = obj.get("message", {}).get("content", "")
|
|
1212
|
+
if isinstance(content, str):
|
|
1213
|
+
return bool(content.strip())
|
|
1214
|
+
if isinstance(content, list):
|
|
1215
|
+
return any(
|
|
1216
|
+
isinstance(b, dict) and b.get("type") == "text" and b.get("text", "").strip()
|
|
1217
|
+
for b in content
|
|
1218
|
+
)
|
|
1219
|
+
return False
|
|
1220
|
+
|
|
1221
|
+
|
|
1119
1222
|
def _engagement_event_streams(
|
|
1120
1223
|
path: Path, since: datetime | None, until: datetime | None
|
|
1121
|
-
) -> tuple[list[datetime], list[datetime]]:
|
|
1122
|
-
"""One session's (user_events, claude_events)
|
|
1224
|
+
) -> tuple[list[datetime], list[datetime], list[datetime]]:
|
|
1225
|
+
"""One session's (user_events, claude_events, assistant_events) in [since, until).
|
|
1123
1226
|
|
|
1124
1227
|
user_events — real user prompts only (see _is_real_user_prompt).
|
|
1125
1228
|
claude_events — assistant messages and tool results: evidence Claude was
|
|
1126
1229
|
working. Used only to grant waiting-on-Claude credit for long gaps.
|
|
1230
|
+
assistant_events — assistant turns bearing visible text (see
|
|
1231
|
+
_assistant_has_text): the replies the user actually reads, counted clean of
|
|
1232
|
+
tool-only turns and tool-result envelopes.
|
|
1127
1233
|
"""
|
|
1128
1234
|
user_ev: list[datetime] = []
|
|
1129
1235
|
claude_ev: list[datetime] = []
|
|
1236
|
+
assistant_ev: list[datetime] = []
|
|
1130
1237
|
for obj in PR.parse_lines(path):
|
|
1131
1238
|
cls = PR.classify_entry(obj)
|
|
1132
1239
|
if cls in ("noise", "title", "compact"):
|
|
@@ -1143,9 +1250,12 @@ def _engagement_event_streams(
|
|
|
1143
1250
|
claude_ev.append(ts) # tool_result entries
|
|
1144
1251
|
else: # assistant
|
|
1145
1252
|
claude_ev.append(ts)
|
|
1253
|
+
if _assistant_has_text(obj):
|
|
1254
|
+
assistant_ev.append(ts)
|
|
1146
1255
|
user_ev.sort()
|
|
1147
1256
|
claude_ev.sort()
|
|
1148
|
-
|
|
1257
|
+
assistant_ev.sort()
|
|
1258
|
+
return user_ev, claude_ev, assistant_ev
|
|
1149
1259
|
|
|
1150
1260
|
|
|
1151
1261
|
def build_engagement(
|
|
@@ -1179,6 +1289,7 @@ def build_engagement(
|
|
|
1179
1289
|
|
|
1180
1290
|
user_events: dict[Path, list[datetime]] = {}
|
|
1181
1291
|
claude_events: dict[Path, list[datetime]] = {}
|
|
1292
|
+
assistant_events: dict[Path, list[datetime]] = {}
|
|
1182
1293
|
walk_dirs = P.list_projects(root)
|
|
1183
1294
|
files: list[Path] = []
|
|
1184
1295
|
for pd in walk_dirs:
|
|
@@ -1193,10 +1304,11 @@ def build_engagement(
|
|
|
1193
1304
|
for f in files:
|
|
1194
1305
|
if exclude_current and current_uuid and f.stem == current_uuid:
|
|
1195
1306
|
continue
|
|
1196
|
-
u, c = _engagement_event_streams(f, since, until)
|
|
1307
|
+
u, c, a = _engagement_event_streams(f, since, until)
|
|
1197
1308
|
if u or c:
|
|
1198
1309
|
user_events[f] = u
|
|
1199
1310
|
claude_events[f] = c
|
|
1311
|
+
assistant_events[f] = a
|
|
1200
1312
|
|
|
1201
1313
|
stream = sorted(
|
|
1202
1314
|
(ts, f) for f, evs in user_events.items() for ts in evs
|
|
@@ -1234,6 +1346,7 @@ def build_engagement(
|
|
|
1234
1346
|
"first": evs[0],
|
|
1235
1347
|
"last": evs[-1],
|
|
1236
1348
|
"user_messages": len(evs),
|
|
1349
|
+
"assistant_messages": len(assistant_events.get(f, [])),
|
|
1237
1350
|
"active": active.get(f, timedelta()),
|
|
1238
1351
|
}
|
|
1239
1352
|
|
|
@@ -1281,7 +1394,8 @@ def render_engagement(data: dict, tz_label: str) -> str:
|
|
|
1281
1394
|
if elapsed.total_seconds() > 0 else " — "
|
|
1282
1395
|
)
|
|
1283
1396
|
out.append(
|
|
1284
|
-
f"{_fmt_dur(s['active']):>7} ratio {ratio}
|
|
1397
|
+
f"{_fmt_dur(s['active']):>7} ratio {ratio} "
|
|
1398
|
+
f"you {s['user_messages']:<3} ai {s['assistant_messages']:<4} "
|
|
1285
1399
|
f"{s['first'].strftime(tfmt)}–{s['last'].strftime('%H:%M')} "
|
|
1286
1400
|
f"{_session_label(s['summary'])}"
|
|
1287
1401
|
)
|
|
@@ -1308,7 +1422,7 @@ def render_engagement(data: dict, tz_label: str) -> str:
|
|
|
1308
1422
|
(f, s), = sessions.items()
|
|
1309
1423
|
# recompute the session's own user events from the stored bounds is not
|
|
1310
1424
|
# enough — pull them again (cached parse, cheap)
|
|
1311
|
-
evs, _ = _engagement_event_streams(f, data["since"], data["until"])
|
|
1425
|
+
evs, _, _ = _engagement_event_streams(f, data["since"], data["until"])
|
|
1312
1426
|
pct = _gap_percentiles(evs)
|
|
1313
1427
|
if pct:
|
|
1314
1428
|
out.append(f"Prompt gaps: median {pct[0]}m, p90 {pct[1]}m")
|
|
@@ -1346,6 +1460,7 @@ def engagement_json(data: dict) -> dict:
|
|
|
1346
1460
|
if elapsed.total_seconds() > 0 else None
|
|
1347
1461
|
),
|
|
1348
1462
|
"user_messages": s["user_messages"],
|
|
1463
|
+
"assistant_messages": s["assistant_messages"],
|
|
1349
1464
|
})
|
|
1350
1465
|
span_min = 0
|
|
1351
1466
|
if data["sessions"]:
|
|
@@ -1374,6 +1489,102 @@ def engagement_json(data: dict) -> dict:
|
|
|
1374
1489
|
}
|
|
1375
1490
|
|
|
1376
1491
|
|
|
1492
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1493
|
+
# Session-report mode — the per-session "what did I do" view
|
|
1494
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1495
|
+
# Reuses the engagement engine (windowed, overlap-safe attention time) but
|
|
1496
|
+
# renders one numbered block per session, chronological, with both clocks
|
|
1497
|
+
# (ran = own first→last span, which overlaps others; active = deduped
|
|
1498
|
+
# attention), per-role message counts, and the intent/last-message inputs a
|
|
1499
|
+
# human day-review is written from.
|
|
1500
|
+
|
|
1501
|
+
def render_session_report(data: dict, tz_label: str) -> str:
|
|
1502
|
+
since, until = data["since"], data["until"]
|
|
1503
|
+
sessions = data["sessions"]
|
|
1504
|
+
multi_day = (until - since) > timedelta(days=1)
|
|
1505
|
+
tfmt = "%Y-%m-%d %H:%M" if multi_day else "%H:%M"
|
|
1506
|
+
head = (
|
|
1507
|
+
f"=== Session report {since:%Y-%m-%d %H:%M} → {until:%Y-%m-%d %H:%M} "
|
|
1508
|
+
f"(times: {tz_label}, break={data['break_minutes']}m) ==="
|
|
1509
|
+
)
|
|
1510
|
+
if not sessions:
|
|
1511
|
+
return head + "\n\n(no user activity in range)"
|
|
1512
|
+
out = [head, ""]
|
|
1513
|
+
rows = sorted(sessions.items(), key=lambda kv: kv[1]["first"]) # chronological
|
|
1514
|
+
for i, (f, s) in enumerate(rows, 1):
|
|
1515
|
+
summary = s["summary"]
|
|
1516
|
+
elapsed = s["last"] - s["first"]
|
|
1517
|
+
title = summary.get("title") or summary.get("first_prompt") or "(untitled)"
|
|
1518
|
+
out.append(f"{i}. {title}")
|
|
1519
|
+
out.append(
|
|
1520
|
+
f" {summary['decoded_project']} · "
|
|
1521
|
+
f"{s['first'].strftime(tfmt)}–{s['last'].strftime('%H:%M')} "
|
|
1522
|
+
f"(ran {_fmt_dur(elapsed)} · active {_fmt_dur(s['active'])})"
|
|
1523
|
+
)
|
|
1524
|
+
out.append(
|
|
1525
|
+
f" you {s['user_messages']} msgs · "
|
|
1526
|
+
f"assistant {s['assistant_messages']} msgs · "
|
|
1527
|
+
f"{summary['edit_count']} files edited"
|
|
1528
|
+
)
|
|
1529
|
+
out.append(f" intent: {summary.get('first_prompt') or '(no user prompt)'}")
|
|
1530
|
+
out.append(f" last: {summary.get('last_assistant') or '(no assistant message)'}")
|
|
1531
|
+
out.append("")
|
|
1532
|
+
total_active = sum((s["active"] for s in sessions.values()), timedelta())
|
|
1533
|
+
first = min(s["first"] for s in sessions.values())
|
|
1534
|
+
last = max(s["last"] for s in sessions.values())
|
|
1535
|
+
out.append(
|
|
1536
|
+
f"Total: {len(sessions)} session(s) · {_fmt_dur(total_active)} active "
|
|
1537
|
+
f"(overlap removed) across a {_fmt_dur(last - first)} span "
|
|
1538
|
+
f"({first.strftime(tfmt)}–{last.strftime('%H:%M')})."
|
|
1539
|
+
)
|
|
1540
|
+
out.append(
|
|
1541
|
+
"(active = your attention, parallel chats never double-counted; "
|
|
1542
|
+
"ran = each session's own first→last span, which can overlap others.)"
|
|
1543
|
+
)
|
|
1544
|
+
return "\n".join(out)
|
|
1545
|
+
|
|
1546
|
+
|
|
1547
|
+
def session_report_json(data: dict) -> dict:
|
|
1548
|
+
rows = sorted(data["sessions"].items(), key=lambda kv: kv[1]["first"])
|
|
1549
|
+
sessions_out = []
|
|
1550
|
+
total_active = timedelta()
|
|
1551
|
+
for f, s in rows:
|
|
1552
|
+
summary = s["summary"]
|
|
1553
|
+
elapsed = s["last"] - s["first"]
|
|
1554
|
+
total_active += s["active"]
|
|
1555
|
+
sessions_out.append({
|
|
1556
|
+
"uuid": summary["uuid"],
|
|
1557
|
+
"project": summary["decoded_project"],
|
|
1558
|
+
"title": summary.get("title") or summary.get("first_prompt") or "",
|
|
1559
|
+
"path": str(f),
|
|
1560
|
+
"first": s["first"].isoformat(),
|
|
1561
|
+
"last": s["last"].isoformat(),
|
|
1562
|
+
"elapsed_minutes": int(elapsed.total_seconds() // 60),
|
|
1563
|
+
"active_minutes": int(s["active"].total_seconds() // 60),
|
|
1564
|
+
"user_messages": s["user_messages"],
|
|
1565
|
+
"assistant_messages": s["assistant_messages"],
|
|
1566
|
+
"edits": summary["edit_count"],
|
|
1567
|
+
"intent": summary.get("first_prompt") or "",
|
|
1568
|
+
"last_message": summary.get("last_assistant") or "",
|
|
1569
|
+
})
|
|
1570
|
+
span_min = 0
|
|
1571
|
+
if data["sessions"]:
|
|
1572
|
+
first = min(s["first"] for s in data["sessions"].values())
|
|
1573
|
+
last = max(s["last"] for s in data["sessions"].values())
|
|
1574
|
+
span_min = int((last - first).total_seconds() // 60)
|
|
1575
|
+
return {
|
|
1576
|
+
"since": data["since"].isoformat(),
|
|
1577
|
+
"until": data["until"].isoformat(),
|
|
1578
|
+
"break_minutes": data["break_minutes"],
|
|
1579
|
+
"sessions": sessions_out,
|
|
1580
|
+
"totals": {
|
|
1581
|
+
"sessions": len(sessions_out),
|
|
1582
|
+
"active_minutes": int(total_active.total_seconds() // 60),
|
|
1583
|
+
"span_minutes": span_min,
|
|
1584
|
+
},
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
|
|
1377
1588
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
1378
1589
|
# JSON builders for legacy single-file modes
|
|
1379
1590
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1466,6 +1677,7 @@ NEW_MODES = {
|
|
|
1466
1677
|
"changelog", "file-edits", "tool-calls", "tool-usage",
|
|
1467
1678
|
"subagent-list", "subagent-finals", "subagent-tools", "subagent-files",
|
|
1468
1679
|
"resume-prev", "count", "journal", "diff", "timeline", "engagement",
|
|
1680
|
+
"session-report",
|
|
1469
1681
|
}
|
|
1470
1682
|
|
|
1471
1683
|
ALL_MODES = LEGACY_MODES | NEW_MODES
|
|
@@ -1509,6 +1721,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1509
1721
|
|
|
1510
1722
|
# Mode-specific flags
|
|
1511
1723
|
p.add_argument("--query", help="Search query (for search/count modes)")
|
|
1724
|
+
p.add_argument("--full", action="store_true",
|
|
1725
|
+
help="Wide-scope search: expand to full match windows instead of "
|
|
1726
|
+
"the per-session summary (bounded; degrades back to summary if oversized)")
|
|
1512
1727
|
p.add_argument("--uuid", help="UUID prefix (for lookup/resume-cmd modes)")
|
|
1513
1728
|
p.add_argument("--title", help="Title substring (for find mode)")
|
|
1514
1729
|
p.add_argument("--first-prompt", dest="first_prompt", help="First-prompt substring (for find mode)")
|
|
@@ -1590,40 +1805,6 @@ def main() -> int:
|
|
|
1590
1805
|
since = _resolve_time(args.since)
|
|
1591
1806
|
until = _resolve_time(args.until)
|
|
1592
1807
|
|
|
1593
|
-
# Legacy --mode search with --cwd (no --file) preserved byte-for-byte.
|
|
1594
|
-
if args.mode == "search" and args.cwd and not args.file and not args.all_projects \
|
|
1595
|
-
and not args.project and fmt == "text" \
|
|
1596
|
-
and args.role == "both" and args.in_channel == "text":
|
|
1597
|
-
if not args.query:
|
|
1598
|
-
print("--query required with --mode search", file=sys.stderr)
|
|
1599
|
-
return 1
|
|
1600
|
-
files = P.list_transcripts(P.find_project_dir(args.cwd, root))
|
|
1601
|
-
if not files:
|
|
1602
|
-
print("No transcript files found.")
|
|
1603
|
-
return 0
|
|
1604
|
-
total_matches = 0
|
|
1605
|
-
for i, f in enumerate(files, 1):
|
|
1606
|
-
print(f"Searching {i}/{len(files)}: {f.name}...", file=sys.stderr, end="\r")
|
|
1607
|
-
try:
|
|
1608
|
-
lines = PR.parse_lines(f)
|
|
1609
|
-
result = mode_search_legacy(lines, args.query)
|
|
1610
|
-
except Exception as e:
|
|
1611
|
-
print(f"\nError reading {f.name}: {e}", file=sys.stderr)
|
|
1612
|
-
continue
|
|
1613
|
-
if result is not None:
|
|
1614
|
-
mtime = _fmt_mtime(f.stat().st_mtime)
|
|
1615
|
-
print(f"\n{'=' * 60}")
|
|
1616
|
-
print(f"Session: {f.name} ({mtime})")
|
|
1617
|
-
print("=" * 60)
|
|
1618
|
-
print(result)
|
|
1619
|
-
total_matches += 1
|
|
1620
|
-
print(file=sys.stderr)
|
|
1621
|
-
if total_matches == 0:
|
|
1622
|
-
print(f"No matches for '{args.query}' found across {len(files)} session(s).")
|
|
1623
|
-
else:
|
|
1624
|
-
print(f"\n--- Found matches in {total_matches}/{len(files)} session(s) ---")
|
|
1625
|
-
return 0
|
|
1626
|
-
|
|
1627
1808
|
mode = args.mode
|
|
1628
1809
|
|
|
1629
1810
|
# Discovery modes — don't need --file
|
|
@@ -1712,7 +1893,7 @@ def main() -> int:
|
|
|
1712
1893
|
else:
|
|
1713
1894
|
print(render_timeline(data, tz_label=args.tz or "local"))
|
|
1714
1895
|
return 0
|
|
1715
|
-
if mode
|
|
1896
|
+
if mode in ("engagement", "session-report"):
|
|
1716
1897
|
try:
|
|
1717
1898
|
break_minutes = _parse_gap(args.break_spec, default=10)
|
|
1718
1899
|
if args.date:
|
|
@@ -1731,7 +1912,7 @@ def main() -> int:
|
|
|
1731
1912
|
return 1
|
|
1732
1913
|
if report_file and e_since is None:
|
|
1733
1914
|
# Window defaults to the file's own first→last user prompt.
|
|
1734
|
-
evs, _ = _engagement_event_streams(report_file, None, None)
|
|
1915
|
+
evs, _, _ = _engagement_event_streams(report_file, None, None)
|
|
1735
1916
|
if not evs:
|
|
1736
1917
|
print("(no user messages in this session)")
|
|
1737
1918
|
return 0
|
|
@@ -1750,7 +1931,12 @@ def main() -> int:
|
|
|
1750
1931
|
root, report_dirs, report_file, e_since, e_until, break_minutes,
|
|
1751
1932
|
current_uuid, exclude_current=args.exclude_current,
|
|
1752
1933
|
)
|
|
1753
|
-
if
|
|
1934
|
+
if mode == "session-report":
|
|
1935
|
+
if fmt == "json":
|
|
1936
|
+
_print_json(session_report_json(data))
|
|
1937
|
+
else:
|
|
1938
|
+
print(render_session_report(data, tz_label=args.tz or "local"))
|
|
1939
|
+
elif fmt == "json":
|
|
1754
1940
|
_print_json(engagement_json(data))
|
|
1755
1941
|
else:
|
|
1756
1942
|
print(render_engagement(data, tz_label=args.tz or "local"))
|
|
@@ -1769,8 +1955,8 @@ def main() -> int:
|
|
|
1769
1955
|
return 1
|
|
1770
1956
|
_emit(mode_diff(Path(args.file_a), Path(args.file_b), fmt=fmt))
|
|
1771
1957
|
return 0
|
|
1772
|
-
#
|
|
1773
|
-
#
|
|
1958
|
+
# All scoped searches (--file / --cwd / --project / --all-projects) route here.
|
|
1959
|
+
# Wide scope summarizes by default; --full expands to bounded match windows.
|
|
1774
1960
|
if mode == "search" and (args.file or args.all_projects or args.project or args.cwd):
|
|
1775
1961
|
if not args.query:
|
|
1776
1962
|
print("--query required", file=sys.stderr)
|
|
@@ -1780,8 +1966,12 @@ def main() -> int:
|
|
|
1780
1966
|
args.role, args.in_channel, since, until,
|
|
1781
1967
|
project=args.project, fmt=fmt,
|
|
1782
1968
|
exclude_current=args.exclude_current,
|
|
1783
|
-
current_uuid=current_uuid))
|
|
1969
|
+
current_uuid=current_uuid, full=args.full))
|
|
1784
1970
|
return 0
|
|
1971
|
+
if mode == "search":
|
|
1972
|
+
# Reached only when no scope flag was given — search needs one of these.
|
|
1973
|
+
print("search requires --file, --cwd, --project, or --all-projects", file=sys.stderr)
|
|
1974
|
+
return 1
|
|
1785
1975
|
|
|
1786
1976
|
# File-required modes
|
|
1787
1977
|
if mode == "subagent-tools":
|
|
@@ -1874,13 +2064,6 @@ def main() -> int:
|
|
|
1874
2064
|
if args.include_subagents:
|
|
1875
2065
|
body += _append_subagents(path)
|
|
1876
2066
|
print(body)
|
|
1877
|
-
elif mode == "search":
|
|
1878
|
-
if not args.query:
|
|
1879
|
-
print("--query required with --mode search", file=sys.stderr)
|
|
1880
|
-
return 1
|
|
1881
|
-
result = mode_search_legacy(lines, args.query)
|
|
1882
|
-
print(result if result is not None
|
|
1883
|
-
else f"No assistant messages containing '{args.query}' found.")
|
|
1884
2067
|
elif mode == "debug":
|
|
1885
2068
|
print(mode_debug(lines))
|
|
1886
2069
|
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
const fs = require('fs');
|
|
3
|
-
const path = require('path');
|
|
4
|
-
const os = require('os');
|
|
5
|
-
|
|
6
|
-
const mod = require('./migrate-claude-history.js');
|
|
7
|
-
|
|
8
|
-
// Set up a tiny synthetic .jsonl in a temp dir
|
|
9
|
-
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'migrate-test-'));
|
|
10
|
-
const testFile = path.join(tmp, 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl');
|
|
11
|
-
const sampleEntries = [
|
|
12
|
-
{ type: 'permission-mode', permissionMode: 'default', sessionId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' },
|
|
13
|
-
{ type: 'user', message: { role: 'user', content: 'Hello' }, uuid: '11111111-1111-1111-1111-111111111111', parentUuid: null, sessionId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', timestamp: '2026-05-24T18:00:00.000Z', cwd: 'C:\\fake\\old', version: '2.1.0' },
|
|
14
|
-
];
|
|
15
|
-
fs.writeFileSync(testFile, sampleEntries.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf8');
|
|
16
|
-
|
|
17
|
-
const oldRepo = mod.parseWindowsRepoPath('C:\\fake\\old', 'old');
|
|
18
|
-
const newRepo = mod.parseWindowsRepoPath('C:\\fake\\new', 'new');
|
|
19
|
-
|
|
20
|
-
const summary = { migrationNotesAppended: 0, migrationNotesSkipped: 0 };
|
|
21
|
-
|
|
22
|
-
// First call — should append
|
|
23
|
-
mod.appendMigrationNote({ filePath: testFile, oldRepo, newRepo, dryRun: false, summary });
|
|
24
|
-
console.log('After first call:', summary);
|
|
25
|
-
|
|
26
|
-
// Second call — should detect duplicate and skip
|
|
27
|
-
mod.appendMigrationNote({ filePath: testFile, oldRepo, newRepo, dryRun: false, summary });
|
|
28
|
-
console.log('After second call:', summary);
|
|
29
|
-
|
|
30
|
-
// Inspect the appended entry
|
|
31
|
-
const lines = fs.readFileSync(testFile, 'utf8').split('\n').filter((l) => l.trim());
|
|
32
|
-
const appended = JSON.parse(lines[lines.length - 1]);
|
|
33
|
-
|
|
34
|
-
console.log('');
|
|
35
|
-
console.log('=== Appended entry shape ===');
|
|
36
|
-
console.log('type: ', appended.type);
|
|
37
|
-
console.log('isMeta: ', 'isMeta' in appended ? appended.isMeta : '<not set>');
|
|
38
|
-
console.log('parent: ', appended.parentUuid);
|
|
39
|
-
console.log('uuid: ', appended.uuid);
|
|
40
|
-
console.log('cwd: ', appended.cwd);
|
|
41
|
-
console.log('');
|
|
42
|
-
console.log('=== content (first 200 chars) ===');
|
|
43
|
-
console.log(appended.message.content.slice(0, 200) + '...');
|
|
44
|
-
|
|
45
|
-
// Cleanup
|
|
46
|
-
fs.rmSync(tmp, { recursive: true, force: true });
|
|
47
|
-
console.log('');
|
|
48
|
-
console.log('Test passed:', summary.migrationNotesAppended === 1 && summary.migrationNotesSkipped === 1 && !('isMeta' in appended) ? 'YES' : 'NO');
|