elliot-stack 1.0.38 → 1.0.39
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-read-claude-session-history/SKILL.md +4 -2
- package/skills/estack-read-claude-session-history/references/modes.md +13 -2
- 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 +127 -75
- package/skills/estack-read-claude-session-history/scripts/tests/test_parser.py +10 -1
- package/skills/estack-read-claude-session-history/scripts/tests/test_search.py +17 -1
- package/skills/estack-read-claude-session-history/scripts/tests/test_search_output.py +161 -0
- package/skills/estack-read-claude-session-history/scripts/tests/test_timezone_and_project.py +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: estack-read-claude-session-history
|
|
3
|
-
version: 1.1
|
|
3
|
+
version: 1.2.1
|
|
4
4
|
description: >-
|
|
5
5
|
(read-claude-session-history) Invoke for ANY task involving Claude Code
|
|
6
6
|
session history, transcripts, or .jsonl files - this is the only way to read,
|
|
@@ -96,6 +96,7 @@ What are you trying to do?
|
|
|
96
96
|
│ ├─ One session ─────────────────────────────── --mode search --file …
|
|
97
97
|
│ ├─ One project ─────────────────────────────── --mode search --cwd …
|
|
98
98
|
│ ├─ All projects ────────────────────────────── --mode search --all-projects
|
|
99
|
+
│ ├─ Expand a wide search to full windows ───── --full
|
|
99
100
|
│ └─ Filter to user msgs / tool-use inputs ──── --role user --in tool_use
|
|
100
101
|
│
|
|
101
102
|
├─ Forensics on a session
|
|
@@ -128,7 +129,7 @@ What are you trying to do?
|
|
|
128
129
|
| `advisor` | `--file` | All `advisor_tool_result` payloads |
|
|
129
130
|
| `pre-compact` | `--file` | 40 exchanges before the most recent `/compact` |
|
|
130
131
|
| `dump` | `--file` | Human-readable dump (auto-degrades on transcripts >5MB) |
|
|
131
|
-
| `search` | `--query` + scope |
|
|
132
|
+
| `search` | `--query` + scope | Single file: full match windows. Wide scope (`--cwd`/`--project`/`--all-projects`): per-session summary by default; `--full` expands to windows. Either way the full view is bounded by a char budget and degrades back to a summary (with a note) if it would overflow. Supports `--role`, `--in text\|tool_use\|tool_result\|thinking\|all` |
|
|
132
133
|
| `debug` | `--file` | Entry/block type distributions + probes |
|
|
133
134
|
| `brief` | `--file` | 6-line summary: uuid·project·mtime·status / intent / last / edits / tools / subagents |
|
|
134
135
|
| `list` | `--cwd` or `--all-projects` | Rich table: mtime, size, uuid, msg count, flags, status, title |
|
|
@@ -157,6 +158,7 @@ What are you trying to do?
|
|
|
157
158
|
- `--all-projects` — walk every project under `--root`.
|
|
158
159
|
- `--project <name>` — filter projects by name substring, case-insensitive, matches encoded or decoded form (`--project keel`, `--project "Other Claude Code"`). Works on `list`, `journal`, `search`, `count`, `find`, `timeline`, `engagement`, `tool-usage`. Use this instead of `--cwd` when you know the project's name but not its exact path. (Note: for `engagement`, scope filters which sessions are *reported* — the attention stream is always computed across all projects so parallel chats never double-count.)
|
|
159
160
|
- `--file <path>` — single-session scope.
|
|
161
|
+
- `--full` — for wide-scope `search` (`--cwd`/`--project`/`--all-projects`), expand the default per-session summary into full match windows. Single-file searches (`--file`) are always full and ignore this flag. In every case the full view is bounded by a character budget (~10k tokens); if the windows would overflow it, the output degrades back to the summary with a note.
|
|
160
162
|
- `--since <spec>` / `--until <spec>` — accepts ISO date, ISO datetime, relative (`30m`, `24h`, `7d`, `1w`, `1mo`), named (`today`, `yesterday`, `now`).
|
|
161
163
|
- `--date <spec>` — single-day window for `timeline` (`--date yesterday`, `--date 2026-06-01`).
|
|
162
164
|
- `--gap <spec>` — idle-gap threshold for `timeline` blocks (`15m` default, `1h`).
|
|
@@ -15,6 +15,7 @@ python read_transcript.py [--root <root>] [--cwd <path> | --all-projects | --pro
|
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
Global flag notes:
|
|
18
|
+
- `--since <spec>` / `--until <spec>` — the time window is half-open `[since, until)`: `since` is inclusive, `until` is exclusive, so an event stamped exactly at `--until` is not included. This holds for every message-level mode (`search`, `timeline`, `engagement`, `tool-usage`). Session-level modes (`list`, `journal`, `count`) instead filter whole sessions by file mtime, so a session last written at or before `--until` is shown.
|
|
18
19
|
- `--project <name>` — case-insensitive substring filter on project directory names (encoded or decoded form). Applies to `list`, `journal`, `search`, `count`, `find`, `timeline`, `engagement`, `tool-usage`. Exit 1 when nothing matches.
|
|
19
20
|
- `--tz <spec>` — display timezone: IANA name (`America/New_York`), `UTC`, or fixed offset (`+5`, `-4`, `+05:30`, `UTC-4`). Default is system local time. All displayed timestamps AND `--since/--until/--date` interpretation use this zone.
|
|
20
21
|
- `--format json` (alias `--json`) — structured output on every mode except the legacy `--list`/`--list-subagents` aliases. Shapes per mode are listed below.
|
|
@@ -188,17 +189,27 @@ python read_transcript.py --cwd <path> --mode search --query "<q>"
|
|
|
188
189
|
|
|
189
190
|
# All projects
|
|
190
191
|
python read_transcript.py --all-projects --mode search --query "<q>"
|
|
192
|
+
|
|
193
|
+
# Expand a wide search to full match windows
|
|
194
|
+
python read_transcript.py --all-projects --mode search --query "<q>" --full
|
|
191
195
|
```
|
|
192
196
|
|
|
193
197
|
Flags:
|
|
194
198
|
- `--role {user,assistant,both}` (default `both`)
|
|
195
|
-
- `--in {text,tool_use,thinking,all}` (default `text`)
|
|
199
|
+
- `--in {text,tool_use,tool_result,thinking,all}` (default `text`)
|
|
200
|
+
- `--full` — wide scope only (see Output below)
|
|
196
201
|
- `--since` / `--until`
|
|
197
202
|
|
|
198
203
|
`--in tool_use` searches the `name + JSON-stringified input` of every `tool_use` block — useful for finding "the session where I ran `git push --force`".
|
|
199
204
|
|
|
200
205
|
`--in thinking` searches `thinking` blocks (model reasoning).
|
|
201
206
|
|
|
207
|
+
`--in tool_result` searches the text content of `tool_result` blocks (what tools returned). `--in all` covers text + tool_use + tool_result + thinking.
|
|
208
|
+
|
|
209
|
+
**Output.** A single-file search (`--file`) prints full match windows. A wide search (`--cwd` / `--project` / `--all-projects`) prints a **per-session summary** by default — one line per session (`mtime · uuid8 · project · hit-count · first snippet`), sorted newest first, with a header counting total hits and sessions. This keeps cross-project searches well under the harness's ~25k-token Read cap instead of dumping tens of thousands of tokens that the reader then refuses. Add `--full` to expand a wide search into full windows. In every case the full view (single-file, or wide + `--full`) is bounded by a character budget (~10k tokens) and degrades back to the summary with a note if it would overflow — so even a single huge session can't blow the Read cap. Sessions past the 200-line summary cap are counted in a footer, never silently dropped. JSON mirrors this: wide scope returns compact per-session metadata (`uuid`, `project`, `hits`, `first_snippet`) by default, full per-match objects (with `window`) under `--full`.
|
|
210
|
+
|
|
211
|
+
Progress (`Searching i/N…`) prints to stderr only when stderr is an interactive terminal — captured/piped runs stay clean.
|
|
212
|
+
|
|
202
213
|
### `count`
|
|
203
214
|
|
|
204
215
|
Count sessions matching a query.
|
|
@@ -434,7 +445,7 @@ Output is prefixed `A>` / `B>` (or with subagent id shorts).
|
|
|
434
445
|
| `tool-calls` / `subagent-tools` | `[{timestamp, tool, summary, input}]` |
|
|
435
446
|
| `tool-usage` | `{total, sessions, tools: [{tool, count}], skills: [{skill, count}]}` |
|
|
436
447
|
| `file-edits` / `subagent-files` | `[{path, ops}]` |
|
|
437
|
-
| `search` | `[{session, mtime_iso, role, where, timestamp, window}]` |
|
|
448
|
+
| `search` | single-file or `--full`: `[{session, mtime_iso, role, where, timestamp, window}]`; wide scope default: `[{session, uuid, project, mtime_iso, hits, first_snippet}]` |
|
|
438
449
|
| `count` | `{sessions, messages, matches}` |
|
|
439
450
|
| `subagent-list` | `[{id, agentType, description, path, size_kb, mtime_iso}]` |
|
|
440
451
|
| `subagent-finals` | `[{id, agentType, text}]` |
|
|
@@ -282,9 +282,10 @@ def filter_by_time(
|
|
|
282
282
|
# Strip tzinfo for naive comparison
|
|
283
283
|
if ts.tzinfo is not None:
|
|
284
284
|
ts = ts.replace(tzinfo=None)
|
|
285
|
+
# Half-open window [since, until): since inclusive, until exclusive.
|
|
285
286
|
if since is not None and ts < since:
|
|
286
287
|
continue
|
|
287
|
-
if until is not None and ts
|
|
288
|
+
if until is not None and ts >= until:
|
|
288
289
|
continue
|
|
289
290
|
out.append(m)
|
|
290
291
|
return out
|
|
@@ -70,6 +70,16 @@ def _entry_search_text(obj: dict, in_channel: str) -> list[tuple[str, str]]:
|
|
|
70
70
|
return out
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
def _show_progress(progress: bool) -> bool:
|
|
74
|
+
"""Progress is for a live terminal only.
|
|
75
|
+
|
|
76
|
+
When stderr is captured to a file or pipe (e.g. by a tool harness), the
|
|
77
|
+
per-file ``Searching i/N…\\r`` stream lands as hundreds of literal lines and
|
|
78
|
+
inflates the captured output. Suppress it unless stderr is an interactive TTY.
|
|
79
|
+
"""
|
|
80
|
+
return progress and sys.stderr.isatty()
|
|
81
|
+
|
|
82
|
+
|
|
73
83
|
def _window(text: str, q: str, n: int = 200) -> str:
|
|
74
84
|
"""Return up to n chars of context around the first match of q."""
|
|
75
85
|
if not text or not q:
|
|
@@ -90,7 +100,7 @@ def search_session(
|
|
|
90
100
|
path: Path,
|
|
91
101
|
query: str,
|
|
92
102
|
role: Literal["user", "assistant", "both"] = "both",
|
|
93
|
-
in_channel: Literal["text", "tool_use", "thinking", "all"] = "text",
|
|
103
|
+
in_channel: Literal["text", "tool_use", "tool_result", "thinking", "all"] = "text",
|
|
94
104
|
since: datetime | None = None,
|
|
95
105
|
until: datetime | None = None,
|
|
96
106
|
) -> list[Match]:
|
|
@@ -117,9 +127,10 @@ def search_session(
|
|
|
117
127
|
continue
|
|
118
128
|
if ts_dt.tzinfo is not None:
|
|
119
129
|
ts_dt = ts_dt.replace(tzinfo=None)
|
|
130
|
+
# Half-open window [since, until): since inclusive, until exclusive.
|
|
120
131
|
if since is not None and ts_dt < since:
|
|
121
132
|
continue
|
|
122
|
-
if until is not None and ts_dt
|
|
133
|
+
if until is not None and ts_dt >= until:
|
|
123
134
|
continue
|
|
124
135
|
for where, text in _entry_search_text(obj, in_channel):
|
|
125
136
|
if q in text.lower():
|
|
@@ -138,15 +149,19 @@ def search_project(
|
|
|
138
149
|
project_dir: Path,
|
|
139
150
|
query: str,
|
|
140
151
|
role: Literal["user", "assistant", "both"] = "both",
|
|
141
|
-
in_channel: Literal["text", "tool_use", "thinking", "all"] = "text",
|
|
152
|
+
in_channel: Literal["text", "tool_use", "tool_result", "thinking", "all"] = "text",
|
|
142
153
|
since: datetime | None = None,
|
|
143
154
|
until: datetime | None = None,
|
|
144
155
|
progress: bool = True,
|
|
145
156
|
) -> Iterator[Match]:
|
|
146
157
|
"""Search every transcript in a project directory, newest first."""
|
|
147
|
-
files
|
|
158
|
+
# Filter files by mtime >= since only. A session last written after `until`
|
|
159
|
+
# may still contain messages inside the window, so we don't bound file mtime
|
|
160
|
+
# above — search_session() applies the `until` cut per-message instead.
|
|
161
|
+
files = _paths.list_transcripts(project_dir, since=since)
|
|
162
|
+
show = _show_progress(progress)
|
|
148
163
|
for i, f in enumerate(files, 1):
|
|
149
|
-
if
|
|
164
|
+
if show:
|
|
150
165
|
print(
|
|
151
166
|
f"Searching {i}/{len(files)}: {f.name}...",
|
|
152
167
|
file=sys.stderr,
|
|
@@ -156,8 +171,11 @@ def search_project(
|
|
|
156
171
|
for m in search_session(f, query, role, in_channel, since, until):
|
|
157
172
|
yield m
|
|
158
173
|
except Exception as e:
|
|
159
|
-
|
|
160
|
-
|
|
174
|
+
# Lead with \n only to clear an active \r progress line; when progress
|
|
175
|
+
# is suppressed there's no line to clear, so don't add a blank one.
|
|
176
|
+
prefix = "\n" if show else ""
|
|
177
|
+
print(f"{prefix}Error reading {f.name}: {e}", file=sys.stderr)
|
|
178
|
+
if show:
|
|
161
179
|
print(file=sys.stderr)
|
|
162
180
|
|
|
163
181
|
|
|
@@ -165,14 +183,14 @@ def search_all_projects(
|
|
|
165
183
|
root: Path,
|
|
166
184
|
query: str,
|
|
167
185
|
role: Literal["user", "assistant", "both"] = "both",
|
|
168
|
-
in_channel: Literal["text", "tool_use", "thinking", "all"] = "text",
|
|
186
|
+
in_channel: Literal["text", "tool_use", "tool_result", "thinking", "all"] = "text",
|
|
169
187
|
since: datetime | None = None,
|
|
170
188
|
until: datetime | None = None,
|
|
171
189
|
progress: bool = True,
|
|
172
190
|
) -> Iterator[Match]:
|
|
173
191
|
"""Walk every project directory under root."""
|
|
174
192
|
for project_dir in _paths.list_projects(root):
|
|
175
|
-
if progress:
|
|
193
|
+
if _show_progress(progress):
|
|
176
194
|
print(f"--- {project_dir.name} ---", file=sys.stderr)
|
|
177
195
|
yield from search_project(
|
|
178
196
|
project_dir, query, role, in_channel, since, until, progress
|
|
@@ -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
|
|
@@ -1509,6 +1595,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1509
1595
|
|
|
1510
1596
|
# Mode-specific flags
|
|
1511
1597
|
p.add_argument("--query", help="Search query (for search/count modes)")
|
|
1598
|
+
p.add_argument("--full", action="store_true",
|
|
1599
|
+
help="Wide-scope search: expand to full match windows instead of "
|
|
1600
|
+
"the per-session summary (bounded; degrades back to summary if oversized)")
|
|
1512
1601
|
p.add_argument("--uuid", help="UUID prefix (for lookup/resume-cmd modes)")
|
|
1513
1602
|
p.add_argument("--title", help="Title substring (for find mode)")
|
|
1514
1603
|
p.add_argument("--first-prompt", dest="first_prompt", help="First-prompt substring (for find mode)")
|
|
@@ -1590,40 +1679,6 @@ def main() -> int:
|
|
|
1590
1679
|
since = _resolve_time(args.since)
|
|
1591
1680
|
until = _resolve_time(args.until)
|
|
1592
1681
|
|
|
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
1682
|
mode = args.mode
|
|
1628
1683
|
|
|
1629
1684
|
# Discovery modes — don't need --file
|
|
@@ -1769,8 +1824,8 @@ def main() -> int:
|
|
|
1769
1824
|
return 1
|
|
1770
1825
|
_emit(mode_diff(Path(args.file_a), Path(args.file_b), fmt=fmt))
|
|
1771
1826
|
return 0
|
|
1772
|
-
#
|
|
1773
|
-
#
|
|
1827
|
+
# All scoped searches (--file / --cwd / --project / --all-projects) route here.
|
|
1828
|
+
# Wide scope summarizes by default; --full expands to bounded match windows.
|
|
1774
1829
|
if mode == "search" and (args.file or args.all_projects or args.project or args.cwd):
|
|
1775
1830
|
if not args.query:
|
|
1776
1831
|
print("--query required", file=sys.stderr)
|
|
@@ -1780,8 +1835,12 @@ def main() -> int:
|
|
|
1780
1835
|
args.role, args.in_channel, since, until,
|
|
1781
1836
|
project=args.project, fmt=fmt,
|
|
1782
1837
|
exclude_current=args.exclude_current,
|
|
1783
|
-
current_uuid=current_uuid))
|
|
1838
|
+
current_uuid=current_uuid, full=args.full))
|
|
1784
1839
|
return 0
|
|
1840
|
+
if mode == "search":
|
|
1841
|
+
# Reached only when no scope flag was given — search needs one of these.
|
|
1842
|
+
print("search requires --file, --cwd, --project, or --all-projects", file=sys.stderr)
|
|
1843
|
+
return 1
|
|
1785
1844
|
|
|
1786
1845
|
# File-required modes
|
|
1787
1846
|
if mode == "subagent-tools":
|
|
@@ -1874,13 +1933,6 @@ def main() -> int:
|
|
|
1874
1933
|
if args.include_subagents:
|
|
1875
1934
|
body += _append_subagents(path)
|
|
1876
1935
|
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
1936
|
elif mode == "debug":
|
|
1885
1937
|
print(mode_debug(lines))
|
|
1886
1938
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Tests for lib.parser."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
-
from datetime import datetime
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
7
|
import pytest
|
|
@@ -132,6 +132,15 @@ def test_filter_by_time(fixtures_dir):
|
|
|
132
132
|
)
|
|
133
133
|
|
|
134
134
|
|
|
135
|
+
def test_filter_by_time_until_is_exclusive():
|
|
136
|
+
# Half-open [since, until): a message stamped exactly at `until` is excluded.
|
|
137
|
+
# Derive the bound from the parser so the test is timezone-independent.
|
|
138
|
+
msgs = [{"timestamp": "2026-05-01T10:00:00Z"}]
|
|
139
|
+
at = PR._parse_timestamp(msgs[0]["timestamp"])
|
|
140
|
+
assert PR.filter_by_time(msgs, since=None, until=at) == []
|
|
141
|
+
assert PR.filter_by_time(msgs, since=None, until=at + timedelta(seconds=1)) == msgs
|
|
142
|
+
|
|
143
|
+
|
|
135
144
|
def test_truncated_line_dropped(fixtures_dir, capsys):
|
|
136
145
|
# Should not raise, and warning should be on stderr
|
|
137
146
|
lines = _load(fixtures_dir, "truncated.jsonl")
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"""Tests for lib.search."""
|
|
2
2
|
|
|
3
|
-
from datetime import datetime
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
|
+
from lib import parser as PR
|
|
6
7
|
from lib import search as S
|
|
7
8
|
|
|
8
9
|
|
|
@@ -67,6 +68,21 @@ def test_search_with_time_filter(fixtures_dir):
|
|
|
67
68
|
assert matches == []
|
|
68
69
|
|
|
69
70
|
|
|
71
|
+
def test_search_until_is_exclusive(fixtures_dir):
|
|
72
|
+
# The "Hello" user message is stamped 2026-05-01T10:00:00Z. Derive the bound
|
|
73
|
+
# from the parser (it converts to local naive time) so this is tz-independent.
|
|
74
|
+
# Half-open [since, until): until at that instant excludes it; one second later includes it.
|
|
75
|
+
at = PR._parse_timestamp("2026-05-01T10:00:00Z")
|
|
76
|
+
assert S.search_session(
|
|
77
|
+
fixtures_dir / "basic-session.jsonl", "Hello", role="both", until=at
|
|
78
|
+
) == []
|
|
79
|
+
# "Hello" matches only the user message at the boundary instant, so +1s yields exactly 1.
|
|
80
|
+
assert len(S.search_session(
|
|
81
|
+
fixtures_dir / "basic-session.jsonl", "Hello", role="both",
|
|
82
|
+
until=at + timedelta(seconds=1)
|
|
83
|
+
)) == 1
|
|
84
|
+
|
|
85
|
+
|
|
70
86
|
def test_search_project(fixtures_dir, tmp_path):
|
|
71
87
|
# Copy 2 fixtures into a fake project dir and search
|
|
72
88
|
import shutil
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Tests for cross-scope search output budgeting: summary-by-default, --full,
|
|
2
|
+
the char-budget degrade, and TTY-gated progress."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
import read_transcript as RT
|
|
14
|
+
from lib.search import Match
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _run_cli(cli_path, *args):
|
|
18
|
+
env = dict(os.environ)
|
|
19
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
20
|
+
return subprocess.run(
|
|
21
|
+
[sys.executable, str(cli_path), *args],
|
|
22
|
+
capture_output=True, text=True, encoding="utf-8", env=env,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# A realistic epoch base so _fmt_mtime renders sane dates if output is printed.
|
|
27
|
+
_BASE_MTIME = 1_700_000_000
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _mk(stem, project, mtime, window, role="assistant", where="text"):
|
|
31
|
+
sp = Path("/root") / project / f"{stem}.jsonl"
|
|
32
|
+
return Match(session_path=sp, mtime=mtime, role=role, where=where,
|
|
33
|
+
timestamp=None, window_text=window)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.fixture
|
|
37
|
+
def wide_root(fixtures_dir, tmp_path):
|
|
38
|
+
"""A fake projects root with two projects, each holding a session."""
|
|
39
|
+
root = tmp_path / "projects"
|
|
40
|
+
keel = root / "C--Users-x-Keel-Project"
|
|
41
|
+
other = root / "C--Users-x-Other-Claude-Code"
|
|
42
|
+
keel.mkdir(parents=True)
|
|
43
|
+
other.mkdir(parents=True)
|
|
44
|
+
shutil.copy(fixtures_dir / "basic-session.jsonl", keel / "keelsession.jsonl")
|
|
45
|
+
shutil.copy(fixtures_dir / "basic-session.jsonl", other / "othersession.jsonl")
|
|
46
|
+
return root
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── unit: renderers ──────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
def test_one_line_collapses_and_truncates():
|
|
52
|
+
assert RT._one_line("a\n b c", 100) == "a b c"
|
|
53
|
+
assert RT._one_line("x" * 200, 10) == "x" * 10 + "…"
|
|
54
|
+
assert RT._one_line("", 10) == ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_summary_counts_all_hits_and_orders_newest_first():
|
|
58
|
+
matches = [
|
|
59
|
+
_mk("aaaaaaaa11", "C--Users-x-Proj", _BASE_MTIME, "hello world"),
|
|
60
|
+
_mk("aaaaaaaa11", "C--Users-x-Proj", _BASE_MTIME, "hello again"),
|
|
61
|
+
_mk("bbbbbbbb22", "C--Users-x-Proj", _BASE_MTIME + 100, "hello there"),
|
|
62
|
+
]
|
|
63
|
+
out = RT._render_search_summary("hello", matches)
|
|
64
|
+
assert '"hello": 3 matches across 2 sessions' in out
|
|
65
|
+
assert "2 hits" in out # the aaaa session has 2
|
|
66
|
+
# bbbb (BASE+100, newer) sorts before aaaa (BASE, older) — newest first
|
|
67
|
+
assert out.index("bbbbbbbb") < out.index("aaaaaaaa")
|
|
68
|
+
assert "--- Match #" not in out # no full windows in summary
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_summary_caps_sessions_but_counts_all(monkeypatch):
|
|
72
|
+
monkeypatch.setattr(RT, "SEARCH_SUMMARY_SESSION_CAP", 2)
|
|
73
|
+
matches = [_mk(f"sess{i:04d}xx", "C--Users-x-Proj", _BASE_MTIME + i, "hit") for i in range(5)]
|
|
74
|
+
out = RT._render_search_summary("hit", matches)
|
|
75
|
+
assert "5 matches across 5 sessions" in out # header counts everything
|
|
76
|
+
assert "3 more session" in out # 5 total - 2 shown
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_search_summary_json_has_no_windows():
|
|
80
|
+
matches = [_mk("aaaaaaaa11", "C--Users-x-My-Proj", _BASE_MTIME, "hello world snippet")]
|
|
81
|
+
js = RT._search_summary_json(matches)
|
|
82
|
+
assert js[0]["uuid"] == "aaaaaaaa11"
|
|
83
|
+
assert js[0]["hits"] == 1
|
|
84
|
+
assert "window" not in js[0]
|
|
85
|
+
assert js[0]["first_snippet"] == "hello world snippet"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_single_file_full_degrades_to_summary_when_oversized(monkeypatch, fixtures_dir):
|
|
89
|
+
# Even a single-file search degrades if its full windows exceed the budget.
|
|
90
|
+
monkeypatch.setattr(RT, "SEARCH_CHAR_BUDGET", 10)
|
|
91
|
+
out = RT.mode_search_v2(
|
|
92
|
+
root=Path("."), cwd=None, all_projects=False,
|
|
93
|
+
file_path=fixtures_dir / "basic-session.jsonl",
|
|
94
|
+
query="Hello", role="both", in_channel="text",
|
|
95
|
+
since=None, until=None, fmt="text",
|
|
96
|
+
)
|
|
97
|
+
assert out.startswith("[note: full output")
|
|
98
|
+
assert '"Hello":' in out # summary header is appended after the note
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_wide_full_degrades_to_summary_when_oversized(monkeypatch, wide_root):
|
|
102
|
+
# The advertised path: wide scope + --full, oversized, degrades to summary.
|
|
103
|
+
monkeypatch.setattr(RT, "SEARCH_CHAR_BUDGET", 10)
|
|
104
|
+
out = RT.mode_search_v2(
|
|
105
|
+
root=wide_root, cwd=None, all_projects=True, file_path=None,
|
|
106
|
+
query="Hello", role="both", in_channel="text",
|
|
107
|
+
since=None, until=None, fmt="text", full=True,
|
|
108
|
+
)
|
|
109
|
+
assert out.startswith("[note: full output")
|
|
110
|
+
assert "matches across" in out # summary header appended
|
|
111
|
+
assert "--- Match #" not in out # degraded — no windows survive
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ── CLI: end-to-end ──────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
def test_wide_search_summarizes_by_default(cli_path, wide_root):
|
|
117
|
+
r = _run_cli(cli_path, "--root", str(wide_root), "--mode", "search",
|
|
118
|
+
"--query", "Hello", "--all-projects")
|
|
119
|
+
assert r.returncode == 0
|
|
120
|
+
assert '"Hello":' in r.stdout and "matches across" in r.stdout # real summary header
|
|
121
|
+
assert "--- Match #" not in r.stdout # summary, not windows
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_wide_search_full_shows_windows(cli_path, wide_root):
|
|
125
|
+
r = _run_cli(cli_path, "--root", str(wide_root), "--mode", "search",
|
|
126
|
+
"--query", "Hello", "--all-projects", "--full")
|
|
127
|
+
assert r.returncode == 0
|
|
128
|
+
assert "--- Match #" in r.stdout # full windows present
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_progress_suppressed_when_stderr_not_a_tty(cli_path, wide_root):
|
|
132
|
+
# stderr is captured (not a TTY) — the Searching i/N progress must not appear.
|
|
133
|
+
r = _run_cli(cli_path, "--root", str(wide_root), "--mode", "search",
|
|
134
|
+
"--query", "Hello", "--all-projects")
|
|
135
|
+
assert "Searching" not in r.stderr
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_cwd_search_matches_user_messages(cli_path, wide_root):
|
|
139
|
+
# "Hello" lives only in the user message of basic-session.jsonl. A --cwd
|
|
140
|
+
# search now routes through mode_search_v2 (role=both), so it must be found —
|
|
141
|
+
# the old assistant-only --cwd path would have missed it.
|
|
142
|
+
found = _run_cli(cli_path, "--root", str(wide_root), "--mode", "search",
|
|
143
|
+
"--query", "Hello", "--cwd", "Keel")
|
|
144
|
+
assert found.returncode == 0
|
|
145
|
+
assert '"Hello":' in found.stdout and "across" in found.stdout
|
|
146
|
+
assert "keelsess" in found.stdout
|
|
147
|
+
|
|
148
|
+
# Restricting to assistant role excludes the user-only hit.
|
|
149
|
+
asst = _run_cli(cli_path, "--root", str(wide_root), "--mode", "search",
|
|
150
|
+
"--query", "Hello", "--cwd", "Keel", "--role", "assistant")
|
|
151
|
+
assert asst.returncode == 0
|
|
152
|
+
assert "No matches" in asst.stdout
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_json_role_filter_excludes_user_only_match(cli_path, wide_root):
|
|
156
|
+
# JSON path honors --role too: assistant-only over a user-only term → empty list.
|
|
157
|
+
r = _run_cli(cli_path, "--root", str(wide_root), "--mode", "search",
|
|
158
|
+
"--query", "Hello", "--cwd", "Keel",
|
|
159
|
+
"--role", "assistant", "--format", "json")
|
|
160
|
+
assert r.returncode == 0
|
|
161
|
+
assert json.loads(r.stdout) == []
|
package/skills/estack-read-claude-session-history/scripts/tests/test_timezone_and_project.py
CHANGED
|
@@ -186,11 +186,24 @@ def test_cli_list_project_filter(cli_path, multi_root):
|
|
|
186
186
|
|
|
187
187
|
|
|
188
188
|
def test_cli_search_project_filter(cli_path, multi_root):
|
|
189
|
+
# Wide scope summarizes by default: the session shows as its uuid8 prefix,
|
|
190
|
+
# and the off-project session must not appear.
|
|
189
191
|
r = _run_cli(
|
|
190
192
|
cli_path, "--root", str(multi_root), "--mode", "search",
|
|
191
193
|
"--query", "help", "--project", "keel",
|
|
192
194
|
)
|
|
193
195
|
assert r.returncode == 0
|
|
196
|
+
assert "keelsess" in r.stdout
|
|
197
|
+
assert "othersess" not in r.stdout
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_cli_search_project_filter_full(cli_path, multi_root):
|
|
201
|
+
# --full expands to match windows, which include the full session filename.
|
|
202
|
+
r = _run_cli(
|
|
203
|
+
cli_path, "--root", str(multi_root), "--mode", "search",
|
|
204
|
+
"--query", "help", "--project", "keel", "--full",
|
|
205
|
+
)
|
|
206
|
+
assert r.returncode == 0
|
|
194
207
|
assert "keelsession" in r.stdout
|
|
195
208
|
|
|
196
209
|
|