elliot-stack 1.0.37 → 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 +13 -7
- package/skills/estack-read-claude-session-history/references/modes.md +46 -3
- package/skills/estack-read-claude-session-history/references/recipes.md +29 -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 -77
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-usage-parent/subagents/agent-sub1.jsonl +3 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-usage-parent.jsonl +3 -0
- package/skills/estack-read-claude-session-history/scripts/tests/test_modes.py +124 -0
- 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.
|
|
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,
|
|
@@ -9,7 +9,8 @@ description: >-
|
|
|
9
9
|
compact"), advisor response retrieval ("what did the advisor say"), subagent
|
|
10
10
|
output collection ("get all subagent finals"), cross-project session search by
|
|
11
11
|
keyword, session listing and triage, UUID and title lookup, resume-command
|
|
12
|
-
generation, file-edit and tool-call forensics,
|
|
12
|
+
generation, file-edit and tool-call forensics, tool- and skill-usage tallies
|
|
13
|
+
("which skills do I actually use"), session diff between two
|
|
13
14
|
sessions or subagents, weekly work journal, day timeline of activity blocks
|
|
14
15
|
and idle gaps, engagement/attention-time accounting (active vs elapsed time,
|
|
15
16
|
break detection, parallel-chat-safe totals), recovering from .claude-backups
|
|
@@ -95,12 +96,14 @@ What are you trying to do?
|
|
|
95
96
|
│ ├─ One session ─────────────────────────────── --mode search --file …
|
|
96
97
|
│ ├─ One project ─────────────────────────────── --mode search --cwd …
|
|
97
98
|
│ ├─ All projects ────────────────────────────── --mode search --all-projects
|
|
99
|
+
│ ├─ Expand a wide search to full windows ───── --full
|
|
98
100
|
│ └─ Filter to user msgs / tool-use inputs ──── --role user --in tool_use
|
|
99
101
|
│
|
|
100
102
|
├─ Forensics on a session
|
|
101
103
|
│ ├─ Chronological tool-call log ────────────── --mode changelog
|
|
102
104
|
│ ├─ Every file touched ─────────────────────── --mode file-edits
|
|
103
|
-
│
|
|
105
|
+
│ ├─ Every tool call (optionally filtered) ──── --mode tool-calls --tool Bash,Edit
|
|
106
|
+
│ └─ Which tools/skills do I actually use? ───── --mode tool-usage (+ scope; --tool Skill)
|
|
104
107
|
│
|
|
105
108
|
├─ Subagent (fan-out) work
|
|
106
109
|
│ ├─ List spawned subagents ─────────────────── --mode subagent-list
|
|
@@ -126,7 +129,7 @@ What are you trying to do?
|
|
|
126
129
|
| `advisor` | `--file` | All `advisor_tool_result` payloads |
|
|
127
130
|
| `pre-compact` | `--file` | 40 exchanges before the most recent `/compact` |
|
|
128
131
|
| `dump` | `--file` | Human-readable dump (auto-degrades on transcripts >5MB) |
|
|
129
|
-
| `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` |
|
|
130
133
|
| `debug` | `--file` | Entry/block type distributions + probes |
|
|
131
134
|
| `brief` | `--file` | 6-line summary: uuid·project·mtime·status / intent / last / edits / tools / subagents |
|
|
132
135
|
| `list` | `--cwd` or `--all-projects` | Rich table: mtime, size, uuid, msg count, flags, status, title |
|
|
@@ -136,6 +139,7 @@ What are you trying to do?
|
|
|
136
139
|
| `changelog` | `--file` | `HH:MM:SS TOOL one-line-summary`, day-grouped |
|
|
137
140
|
| `file-edits` | `--file` | Unique paths sorted with op tags |
|
|
138
141
|
| `tool-calls` | `--file` (+ `--tool` filter) | Timestamped per-call blocks |
|
|
142
|
+
| `tool-usage` | `--file` or scope (+ `--tool` filter, `--include-subagents`) | Tool-call tallies by name; `Skill` calls sub-tallied by skill name (counts real invocations, not text) |
|
|
139
143
|
| `subagent-list` | `--file` | List sibling subagents with agentType + description |
|
|
140
144
|
| `subagent-finals` | `--file` | Every subagent's final assistant message |
|
|
141
145
|
| `subagent-tools` | `--subagent` | Forensics on one subagent |
|
|
@@ -152,16 +156,17 @@ What are you trying to do?
|
|
|
152
156
|
- `--root {live|mirror|snapshot-24h|snapshot-1w|snapshot-1mo|<abs-path>}` — read from a `.claude-backups` mirror or snapshot instead of live. Default `live`.
|
|
153
157
|
- `--cwd <path>` — single-project scope. Use the original working directory (e.g. `"C:\Users\2supe\Other Claude Code"`).
|
|
154
158
|
- `--all-projects` — walk every project under `--root`.
|
|
155
|
-
- `--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`. 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
|
+
- `--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.)
|
|
156
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.
|
|
157
162
|
- `--since <spec>` / `--until <spec>` — accepts ISO date, ISO datetime, relative (`30m`, `24h`, `7d`, `1w`, `1mo`), named (`today`, `yesterday`, `now`).
|
|
158
163
|
- `--date <spec>` — single-day window for `timeline` (`--date yesterday`, `--date 2026-06-01`).
|
|
159
164
|
- `--gap <spec>` — idle-gap threshold for `timeline` blocks (`15m` default, `1h`).
|
|
160
165
|
- `--break <spec>` — break threshold for `engagement` (`10m` default; `5m` strict, `20m` forgiving). Gaps between your prompts longer than this count as breaks unless you replied right after Claude finished working.
|
|
161
166
|
- `--tz <spec>` — display timezone override (IANA name, `UTC`, or offset like `-4`). Default: system local time.
|
|
162
167
|
- `--format json` (or `--json`) — structured JSON output on every mode (except the legacy `--list`/`--list-subagents` aliases). Pipe-friendly: paths are strings, timestamps ISO.
|
|
163
|
-
- `--exclude-current` — drop the current session (detected via `CLAUDE_SESSION_ID`) from `list`, `journal`, `search`, `count`, `timeline`, and `
|
|
164
|
-
- `--include-subagents` — fold subagent finals into `brief`, `last`, `dump` output, each tagged `[subagent <id-short> · <agentType>]`.
|
|
168
|
+
- `--exclude-current` — drop the current session (detected via `CLAUDE_SESSION_ID`) from `list`, `journal`, `search`, `count`, `timeline`, `engagement`, and `tool-usage`. Useful for `tool-usage` so the very commands you're running now don't skew the tally.
|
|
169
|
+
- `--include-subagents` — fold subagent finals into `brief`, `last`, `dump` output, each tagged `[subagent <id-short> · <agentType>]`. For `tool-usage`, folds each session's subagent `tool_use` calls into the tally (the subagent is not counted as a separate session).
|
|
165
170
|
- `--force-dump` — bypass the 5 MB `dump` guard.
|
|
166
171
|
- `-n N` — count modifier (default 5 for `last`, 80 for `dump`, 10 for `resume-prev`).
|
|
167
172
|
|
|
@@ -202,6 +207,7 @@ See `references/recipes.md` → "Deletion-incident recovery" for the full playbo
|
|
|
202
207
|
| "How much did I actually work today?" | `--mode engagement --date today` |
|
|
203
208
|
| "How much time on Keel today?" | `--mode engagement --project keel --date today` |
|
|
204
209
|
| "How long did that session take me?" | `--mode engagement --file <session.jsonl>` |
|
|
210
|
+
| "Which skills do I actually use?" | `--mode tool-usage --all-projects --tool Skill` |
|
|
205
211
|
| Feed session data into a script | any mode + `--format json` |
|
|
206
212
|
|
|
207
213
|
See `references/recipes.md` for fuller multi-step workflows.
|
|
@@ -15,7 +15,8 @@ python read_transcript.py [--root <root>] [--cwd <path> | --all-projects | --pro
|
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
Global flag notes:
|
|
18
|
-
- `--
|
|
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.
|
|
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.
|
|
21
22
|
|
|
@@ -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.
|
|
@@ -364,6 +375,37 @@ path, first, last, elapsed_minutes, active_minutes, active_seconds, ratio,
|
|
|
364
375
|
user_messages}], totals: {sessions, active_minutes, active_seconds,
|
|
365
376
|
span_minutes}, stream_breaks: [{start, end, minutes}]}`.
|
|
366
377
|
|
|
378
|
+
### `tool-usage`
|
|
379
|
+
|
|
380
|
+
Tally tool calls by tool name; `Skill` calls are sub-tallied by skill name. Answers "which tools / skills do I actually use".
|
|
381
|
+
|
|
382
|
+
```bash
|
|
383
|
+
# Every tool across one session
|
|
384
|
+
python read_transcript.py --file <path> --mode tool-usage
|
|
385
|
+
|
|
386
|
+
# Skill usage across every project (the "what skills do I actually use" question)
|
|
387
|
+
python read_transcript.py --all-projects --mode tool-usage --tool Skill
|
|
388
|
+
|
|
389
|
+
# One project, last 30 days
|
|
390
|
+
python read_transcript.py --project keel --mode tool-usage --since 30d
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Scope is `--file` (one session) or `--cwd` / `--project` / `--all-projects`. `--tool` narrows to a comma-separated subset (e.g. `--tool Skill` for a skills-only view, `--tool Bash,Edit`). `--since/--until` bound the calls by their own timestamp (not file mtime — a session modified after `--until` is still read so in-window calls are counted). `--exclude-current` drops the current session so the commands you're running now don't skew the count. `--include-subagents` folds each session's `agent-*.jsonl` tool calls into its tally — needed to capture skills invoked inside fan-out subagents (the subagent is not counted as a separate session).
|
|
394
|
+
|
|
395
|
+
Why this exists: it counts real **invocations** — a `tool_use` block whose `name` is the tool (and, for skills, `input.skill`). Text-based modes like `search --in tool_use` and `count` match the *string* "ast-grep" wherever it appears (a `CLAUDE.md` instruction, a bash command, even your own search commands this session), so they over-count. `tool-usage` keys on structure and is immune to that.
|
|
396
|
+
|
|
397
|
+
Text output: one `<count> <ToolName>` row per tool, sorted by count descending; under the `Skill` row, a tree of `<count> <skill-name>` sub-rows. A leading line gives the grand total and number of sessions with calls.
|
|
398
|
+
|
|
399
|
+
```
|
|
400
|
+
Tool calls (66 total across 43 session(s)):
|
|
401
|
+
66 Skill
|
|
402
|
+
├ 38 manage-e-stack
|
|
403
|
+
├ 9 commit
|
|
404
|
+
└ ...
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
JSON shape: `{total, sessions, tools: [{tool, count}], skills: [{skill, count}]}` (both lists sorted by count descending; `skills` is empty unless `Skill` calls were counted).
|
|
408
|
+
|
|
367
409
|
---
|
|
368
410
|
|
|
369
411
|
## Comparison modes
|
|
@@ -401,8 +443,9 @@ Output is prefixed `A>` / `B>` (or with subagent id shorts).
|
|
|
401
443
|
| `resume-cmd` | `{uuid, path, project, encoded, command}` |
|
|
402
444
|
| `changelog` | `[{timestamp, tool, summary}]` |
|
|
403
445
|
| `tool-calls` / `subagent-tools` | `[{timestamp, tool, summary, input}]` |
|
|
446
|
+
| `tool-usage` | `{total, sessions, tools: [{tool, count}], skills: [{skill, count}]}` |
|
|
404
447
|
| `file-edits` / `subagent-files` | `[{path, ops}]` |
|
|
405
|
-
| `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}]` |
|
|
406
449
|
| `count` | `{sessions, messages, matches}` |
|
|
407
450
|
| `subagent-list` | `[{id, agentType, description, path, size_kb, mtime_iso}]` |
|
|
408
451
|
| `subagent-finals` | `[{id, agentType, text}]` |
|
|
@@ -245,6 +245,35 @@ python "$PY" --file <matching-session>.jsonl --mode tool-calls --tool Bash
|
|
|
245
245
|
|
|
246
246
|
---
|
|
247
247
|
|
|
248
|
+
## 8b. "Which skills (or tools) do I actually use?" — true invocation counts
|
|
249
|
+
|
|
250
|
+
To decide which skills to keep or prune, you need real usage counts. Do **not**
|
|
251
|
+
use `search`/`count` for this: they match the *string* (a skill name in a
|
|
252
|
+
`CLAUDE.md` instruction, a bash command, even the search commands you're running
|
|
253
|
+
right now), so they over-count. `tool-usage` counts real `tool_use` invocations —
|
|
254
|
+
for skills, the `Skill` block's `input.skill` — and is immune to that.
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
# Skills you actually invoked, ranked, across every project
|
|
258
|
+
python "$PY" --all-projects --mode tool-usage --tool Skill
|
|
259
|
+
|
|
260
|
+
# All tools (skills broken out under the Skill row)
|
|
261
|
+
python "$PY" --all-projects --mode tool-usage
|
|
262
|
+
|
|
263
|
+
# One project, last 30 days, excluding this very session
|
|
264
|
+
python "$PY" --project keel --mode tool-usage --tool Skill --since 30d --exclude-current
|
|
265
|
+
|
|
266
|
+
# Machine-readable: skills sorted by count
|
|
267
|
+
python "$PY" --all-projects --mode tool-usage --tool Skill --format json \
|
|
268
|
+
| python -c "import json,sys; [print(s['count'], s['skill']) for s in json.load(sys.stdin)['skills']]"
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
A skill missing from the output was never invoked in that scope — a positive
|
|
272
|
+
inventory ("here is everything I used, and X isn't in it") is stronger evidence
|
|
273
|
+
of non-use than a search that simply returns nothing.
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
248
277
|
## 9. Resume previous session in the current project
|
|
249
278
|
|
|
250
279
|
If you just `cd`'d into a project and want to pick up where you left off:
|
|
@@ -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"):
|
|
@@ -737,6 +822,131 @@ def mode_count(
|
|
|
737
822
|
return {"sessions": sessions, "messages": total_msgs, "matches": matches}
|
|
738
823
|
|
|
739
824
|
|
|
825
|
+
def _tally_tool_usage(
|
|
826
|
+
groups: list[list[Path]],
|
|
827
|
+
tool_filter: set[str] | None,
|
|
828
|
+
since: datetime | None,
|
|
829
|
+
until: datetime | None,
|
|
830
|
+
) -> tuple[dict[str, int], dict[str, int], int, int]:
|
|
831
|
+
"""Tally tool_use blocks by name (Skill sub-tallied by skill) over session groups.
|
|
832
|
+
|
|
833
|
+
Each group is the files of ONE logical session — the parent transcript plus,
|
|
834
|
+
when subagents are folded in, its `agent-*.jsonl` siblings. A group counts as
|
|
835
|
+
one session-with-calls if any of its files contributed a call.
|
|
836
|
+
|
|
837
|
+
Returns (tool_counts, skill_counts, total_calls, sessions_with_calls).
|
|
838
|
+
Keys on invocation STRUCTURE (a tool_use block's name / input.skill), so it
|
|
839
|
+
is immune to the substring false-positives that plague text search — the
|
|
840
|
+
word "ast-grep" in a CLAUDE.md or a bash command never counts as a call.
|
|
841
|
+
"""
|
|
842
|
+
tool_counts: dict[str, int] = {}
|
|
843
|
+
skill_counts: dict[str, int] = {}
|
|
844
|
+
total = 0
|
|
845
|
+
sessions_with = 0
|
|
846
|
+
window = since is not None or until is not None
|
|
847
|
+
for group in groups:
|
|
848
|
+
hit = False
|
|
849
|
+
for f in group:
|
|
850
|
+
try:
|
|
851
|
+
lines = PR.parse_lines(f)
|
|
852
|
+
except Exception as e: # noqa: BLE001 — one bad file shouldn't abort the tally
|
|
853
|
+
print(f"Error reading {f.name}: {e}", file=sys.stderr)
|
|
854
|
+
continue
|
|
855
|
+
for c in T.extract_tool_calls(lines, tool_filter):
|
|
856
|
+
if window:
|
|
857
|
+
ts = PR._parse_timestamp(c.get("timestamp"))
|
|
858
|
+
if ts is None:
|
|
859
|
+
continue
|
|
860
|
+
if ts.tzinfo is not None:
|
|
861
|
+
ts = ts.replace(tzinfo=None)
|
|
862
|
+
# Half-open window [since, until): since inclusive, until exclusive.
|
|
863
|
+
if since is not None and ts < since:
|
|
864
|
+
continue
|
|
865
|
+
if until is not None and ts >= until:
|
|
866
|
+
continue
|
|
867
|
+
name = c["name"] or "(unknown)"
|
|
868
|
+
tool_counts[name] = tool_counts.get(name, 0) + 1
|
|
869
|
+
total += 1
|
|
870
|
+
hit = True
|
|
871
|
+
if name == "Skill":
|
|
872
|
+
sk = (c.get("input") or {}).get("skill") or "(unnamed)"
|
|
873
|
+
skill_counts[sk] = skill_counts.get(sk, 0) + 1
|
|
874
|
+
if hit:
|
|
875
|
+
sessions_with += 1
|
|
876
|
+
return tool_counts, skill_counts, total, sessions_with
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def mode_tool_usage(
|
|
880
|
+
root: Path,
|
|
881
|
+
cwd: str | None,
|
|
882
|
+
all_projects: bool,
|
|
883
|
+
file_path: Path | None,
|
|
884
|
+
since: datetime | None,
|
|
885
|
+
until: datetime | None,
|
|
886
|
+
tool_filter: set[str] | None = None,
|
|
887
|
+
project: str | None = None,
|
|
888
|
+
exclude_current: bool = False,
|
|
889
|
+
include_subagents: bool = False,
|
|
890
|
+
current_uuid: str | None = None,
|
|
891
|
+
fmt: str = "text",
|
|
892
|
+
):
|
|
893
|
+
"""Tally tool_use blocks by tool name; Skill calls sub-tallied by skill name.
|
|
894
|
+
|
|
895
|
+
Answers "which tools/skills do I actually use" by counting real invocations,
|
|
896
|
+
not text occurrences. Scope is --file (one session) or the usual
|
|
897
|
+
--cwd/--project/--all-projects. --tool narrows to a subset (e.g. --tool Skill).
|
|
898
|
+
--include-subagents folds each session's agent-*.jsonl tool calls into its tally.
|
|
899
|
+
"""
|
|
900
|
+
if file_path:
|
|
901
|
+
parents = [file_path]
|
|
902
|
+
else:
|
|
903
|
+
project_dirs = _scoped_project_dirs(root, cwd, all_projects, project)
|
|
904
|
+
if project_dirs is None:
|
|
905
|
+
return "--file, --cwd, --project, or --all-projects required"
|
|
906
|
+
parents = []
|
|
907
|
+
for pd in project_dirs:
|
|
908
|
+
# `since` is a safe lower-bound mtime pre-filter (a session whose last
|
|
909
|
+
# event predates `since` holds no in-window calls). `until` is NOT a
|
|
910
|
+
# safe mtime filter — a session modified after `until` can still hold
|
|
911
|
+
# calls inside the window — so the upper bound is applied per-call in
|
|
912
|
+
# _tally_tool_usage, mirroring timeline/engagement.
|
|
913
|
+
parents.extend(P.list_transcripts(pd, since=since))
|
|
914
|
+
if exclude_current and current_uuid:
|
|
915
|
+
parents = [f for f in parents if f.stem != current_uuid]
|
|
916
|
+
|
|
917
|
+
groups = []
|
|
918
|
+
for p in parents:
|
|
919
|
+
group = [p]
|
|
920
|
+
if include_subagents:
|
|
921
|
+
group.extend(P.list_subagents(p))
|
|
922
|
+
groups.append(group)
|
|
923
|
+
|
|
924
|
+
tool_counts, skill_counts, total, sessions_with = _tally_tool_usage(
|
|
925
|
+
groups, tool_filter, since, until
|
|
926
|
+
)
|
|
927
|
+
tools_sorted = sorted(tool_counts.items(), key=lambda x: (-x[1], x[0]))
|
|
928
|
+
skills_sorted = sorted(skill_counts.items(), key=lambda x: (-x[1], x[0]))
|
|
929
|
+
|
|
930
|
+
if fmt == "json":
|
|
931
|
+
return {
|
|
932
|
+
"total": total,
|
|
933
|
+
"sessions": sessions_with,
|
|
934
|
+
"tools": [{"tool": k, "count": v} for k, v in tools_sorted],
|
|
935
|
+
"skills": [{"skill": k, "count": v} for k, v in skills_sorted],
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
if total == 0:
|
|
939
|
+
return "No tool calls found."
|
|
940
|
+
out = [f"Tool calls ({total} total across {sessions_with} session(s)):"]
|
|
941
|
+
for name, count in tools_sorted:
|
|
942
|
+
out.append(f" {count:5d} {name}")
|
|
943
|
+
if name == "Skill" and skills_sorted:
|
|
944
|
+
for i, (sk, n) in enumerate(skills_sorted):
|
|
945
|
+
glyph = "└" if i == len(skills_sorted) - 1 else "├"
|
|
946
|
+
out.append(f" {glyph} {n} {sk}")
|
|
947
|
+
return "\n".join(out)
|
|
948
|
+
|
|
949
|
+
|
|
740
950
|
def mode_journal(
|
|
741
951
|
root: Path,
|
|
742
952
|
cwd: str | None,
|
|
@@ -1339,7 +1549,7 @@ LEGACY_MODES = {"last", "advisor", "pre-compact", "dump", "search", "debug"}
|
|
|
1339
1549
|
|
|
1340
1550
|
NEW_MODES = {
|
|
1341
1551
|
"list", "lookup", "find", "resume-cmd", "brief",
|
|
1342
|
-
"changelog", "file-edits", "tool-calls",
|
|
1552
|
+
"changelog", "file-edits", "tool-calls", "tool-usage",
|
|
1343
1553
|
"subagent-list", "subagent-finals", "subagent-tools", "subagent-files",
|
|
1344
1554
|
"resume-prev", "count", "journal", "diff", "timeline", "engagement",
|
|
1345
1555
|
}
|
|
@@ -1385,13 +1595,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1385
1595
|
|
|
1386
1596
|
# Mode-specific flags
|
|
1387
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)")
|
|
1388
1601
|
p.add_argument("--uuid", help="UUID prefix (for lookup/resume-cmd modes)")
|
|
1389
1602
|
p.add_argument("--title", help="Title substring (for find mode)")
|
|
1390
1603
|
p.add_argument("--first-prompt", dest="first_prompt", help="First-prompt substring (for find mode)")
|
|
1391
1604
|
p.add_argument("--role", default="both", choices=["user", "assistant", "both"])
|
|
1392
1605
|
p.add_argument("--in", dest="in_channel", default="text",
|
|
1393
1606
|
choices=["text", "tool_use", "tool_result", "thinking", "all"])
|
|
1394
|
-
p.add_argument("--tool", help="Comma-separated tool names (for tool-calls)")
|
|
1607
|
+
p.add_argument("--tool", help="Comma-separated tool names (for tool-calls / tool-usage)")
|
|
1395
1608
|
p.add_argument("--subagent", help="Subagent file path (for subagent-tools/files)")
|
|
1396
1609
|
p.add_argument("--file-a", dest="file_a", help="First file for diff mode")
|
|
1397
1610
|
p.add_argument("--file-b", dest="file_b", help="Second file for diff mode")
|
|
@@ -1401,7 +1614,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1401
1614
|
p.add_argument("--exclude-current", action="store_true",
|
|
1402
1615
|
help="Drop the current session (via CLAUDE_SESSION_ID) from output")
|
|
1403
1616
|
p.add_argument("--include-subagents", action="store_true",
|
|
1404
|
-
help="Fold subagent finals into brief/last/dump output"
|
|
1617
|
+
help="Fold subagent finals into brief/last/dump output; "
|
|
1618
|
+
"fold subagent tool calls into tool-usage tallies")
|
|
1405
1619
|
p.add_argument("--force-dump", action="store_true",
|
|
1406
1620
|
help="Bypass the 5MB dump-size guard")
|
|
1407
1621
|
p.add_argument("--format", default="text", choices=["text", "json"],
|
|
@@ -1465,40 +1679,6 @@ def main() -> int:
|
|
|
1465
1679
|
since = _resolve_time(args.since)
|
|
1466
1680
|
until = _resolve_time(args.until)
|
|
1467
1681
|
|
|
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
1682
|
mode = args.mode
|
|
1503
1683
|
|
|
1504
1684
|
# Discovery modes — don't need --file
|
|
@@ -1544,6 +1724,19 @@ def main() -> int:
|
|
|
1544
1724
|
)
|
|
1545
1725
|
print(counts["sessions"])
|
|
1546
1726
|
return 0
|
|
1727
|
+
if mode == "tool-usage":
|
|
1728
|
+
fp = Path(args.file) if args.file else None
|
|
1729
|
+
if fp and not fp.exists():
|
|
1730
|
+
print(f"File not found: {fp}", file=sys.stderr)
|
|
1731
|
+
return 1
|
|
1732
|
+
_emit(mode_tool_usage(
|
|
1733
|
+
root, args.cwd, args.all_projects, fp, since, until,
|
|
1734
|
+
tool_filter=_split_tools(args.tool), project=args.project,
|
|
1735
|
+
exclude_current=args.exclude_current,
|
|
1736
|
+
include_subagents=args.include_subagents,
|
|
1737
|
+
current_uuid=current_uuid, fmt=fmt,
|
|
1738
|
+
))
|
|
1739
|
+
return 0
|
|
1547
1740
|
if mode == "journal":
|
|
1548
1741
|
_emit(mode_journal(root, args.cwd, args.all_projects, since, until,
|
|
1549
1742
|
current_uuid, project=args.project, fmt=fmt,
|
|
@@ -1631,8 +1824,8 @@ def main() -> int:
|
|
|
1631
1824
|
return 1
|
|
1632
1825
|
_emit(mode_diff(Path(args.file_a), Path(args.file_b), fmt=fmt))
|
|
1633
1826
|
return 0
|
|
1634
|
-
#
|
|
1635
|
-
#
|
|
1827
|
+
# All scoped searches (--file / --cwd / --project / --all-projects) route here.
|
|
1828
|
+
# Wide scope summarizes by default; --full expands to bounded match windows.
|
|
1636
1829
|
if mode == "search" and (args.file or args.all_projects or args.project or args.cwd):
|
|
1637
1830
|
if not args.query:
|
|
1638
1831
|
print("--query required", file=sys.stderr)
|
|
@@ -1642,8 +1835,12 @@ def main() -> int:
|
|
|
1642
1835
|
args.role, args.in_channel, since, until,
|
|
1643
1836
|
project=args.project, fmt=fmt,
|
|
1644
1837
|
exclude_current=args.exclude_current,
|
|
1645
|
-
current_uuid=current_uuid))
|
|
1838
|
+
current_uuid=current_uuid, full=args.full))
|
|
1646
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
|
|
1647
1844
|
|
|
1648
1845
|
# File-required modes
|
|
1649
1846
|
if mode == "subagent-tools":
|
|
@@ -1736,13 +1933,6 @@ def main() -> int:
|
|
|
1736
1933
|
if args.include_subagents:
|
|
1737
1934
|
body += _append_subagents(path)
|
|
1738
1935
|
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
1936
|
elif mode == "debug":
|
|
1747
1937
|
print(mode_debug(lines))
|
|
1748
1938
|
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
{"type":"user","timestamp":"2026-05-01T10:00:03Z","message":{"role":"user","content":"sub task"}}
|
|
2
|
+
{"type":"assistant","timestamp":"2026-05-01T10:00:04Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"s1","name":"Skill","input":{"skill":"estack-repo-search"}}]}}
|
|
3
|
+
{"type":"assistant","timestamp":"2026-05-01T10:00:05Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"s2","name":"Bash","input":{"command":"ls"}}]}}
|
package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-usage-parent.jsonl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
{"type":"user","timestamp":"2026-05-01T10:00:00Z","message":{"role":"user","content":"do it"}}
|
|
2
|
+
{"type":"assistant","timestamp":"2026-05-01T10:00:01Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"p1","name":"Skill","input":{"skill":"commit"}}]}}
|
|
3
|
+
{"type":"assistant","timestamp":"2026-05-01T10:00:02Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"p2","name":"Agent","input":{"description":"sub","subagent_type":"Explore"}}]}}
|
|
@@ -110,6 +110,130 @@ def test_tool_calls_filter(cli_path, fixtures_dir):
|
|
|
110
110
|
assert "Glob" not in r.stdout
|
|
111
111
|
|
|
112
112
|
|
|
113
|
+
def test_tool_usage_file(cli_path, fixtures_dir):
|
|
114
|
+
r = _run_cli(cli_path, "--file", str(fixtures_dir / "tool-zoo.jsonl"), "--mode", "tool-usage")
|
|
115
|
+
assert r.returncode == 0
|
|
116
|
+
assert "Tool calls" in r.stdout
|
|
117
|
+
assert "Bash" in r.stdout
|
|
118
|
+
assert "Skill" in r.stdout
|
|
119
|
+
# Skill calls are sub-tallied by the actual skill name (input.skill).
|
|
120
|
+
assert "using-superpowers" in r.stdout
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_tool_usage_skill_filter(cli_path, fixtures_dir):
|
|
124
|
+
r = _run_cli(
|
|
125
|
+
cli_path, "--file", str(fixtures_dir / "tool-zoo.jsonl"),
|
|
126
|
+
"--mode", "tool-usage", "--tool", "Skill",
|
|
127
|
+
)
|
|
128
|
+
assert r.returncode == 0
|
|
129
|
+
assert "Skill" in r.stdout
|
|
130
|
+
assert "using-superpowers" in r.stdout
|
|
131
|
+
# Filtering to Skill must drop every other tool.
|
|
132
|
+
assert "Bash" not in r.stdout
|
|
133
|
+
assert "Glob" not in r.stdout
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_tool_usage_json(cli_path, fixtures_dir):
|
|
137
|
+
r = _run_cli(
|
|
138
|
+
cli_path, "--file", str(fixtures_dir / "tool-zoo.jsonl"),
|
|
139
|
+
"--mode", "tool-usage", "--format", "json",
|
|
140
|
+
)
|
|
141
|
+
assert r.returncode == 0
|
|
142
|
+
data = json.loads(r.stdout)
|
|
143
|
+
assert data["total"] == 9
|
|
144
|
+
assert data["sessions"] == 1
|
|
145
|
+
tools = {t["tool"]: t["count"] for t in data["tools"]}
|
|
146
|
+
assert tools["Skill"] == 1
|
|
147
|
+
assert tools["Bash"] == 1
|
|
148
|
+
skills = {s["skill"]: s["count"] for s in data["skills"]}
|
|
149
|
+
assert skills == {"using-superpowers": 1}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_tool_usage_scope_aggregates(cli_path, fixtures_dir, tmp_path):
|
|
153
|
+
# Two sessions in one project → counts add up across files.
|
|
154
|
+
fake_root = tmp_path / "projects"
|
|
155
|
+
fake_proj = fake_root / "C--fake-proj"
|
|
156
|
+
fake_proj.mkdir(parents=True)
|
|
157
|
+
shutil.copy(fixtures_dir / "tool-zoo.jsonl", fake_proj / "a.jsonl")
|
|
158
|
+
shutil.copy(fixtures_dir / "tool-zoo.jsonl", fake_proj / "b.jsonl")
|
|
159
|
+
r = _run_cli(
|
|
160
|
+
cli_path, "--root", str(fake_root), "--cwd", "C:\\fake\\proj",
|
|
161
|
+
"--mode", "tool-usage", "--format", "json",
|
|
162
|
+
)
|
|
163
|
+
assert r.returncode == 0
|
|
164
|
+
data = json.loads(r.stdout)
|
|
165
|
+
assert data["sessions"] == 2
|
|
166
|
+
assert data["total"] == 18
|
|
167
|
+
skills = {s["skill"]: s["count"] for s in data["skills"]}
|
|
168
|
+
assert skills == {"using-superpowers": 2}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_tool_usage_missing_file(cli_path, tmp_path):
|
|
172
|
+
r = _run_cli(cli_path, "--file", str(tmp_path / "nope.jsonl"), "--mode", "tool-usage")
|
|
173
|
+
assert r.returncode == 1
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_tool_usage_until_keeps_in_window_calls(cli_path, fixtures_dir, tmp_path):
|
|
177
|
+
# The tool-zoo calls are stamped 2026-05-01T10:00:0x. Copy the fixture, then
|
|
178
|
+
# bump its mtime far past --until. A naive mtime filter would drop the file
|
|
179
|
+
# and report zero; per-call timestamp filtering must still count the calls.
|
|
180
|
+
import os
|
|
181
|
+
fake_root = tmp_path / "projects"
|
|
182
|
+
fake_proj = fake_root / "C--fake-proj"
|
|
183
|
+
fake_proj.mkdir(parents=True)
|
|
184
|
+
target = fake_proj / "a.jsonl"
|
|
185
|
+
shutil.copy(fixtures_dir / "tool-zoo.jsonl", target)
|
|
186
|
+
future = 1_900_000_000 # ~2030, well after the --until bound below
|
|
187
|
+
os.utime(target, (future, future))
|
|
188
|
+
r = _run_cli(
|
|
189
|
+
cli_path, "--root", str(fake_root), "--cwd", "C:\\fake\\proj",
|
|
190
|
+
"--mode", "tool-usage", "--until", "2026-05-02", "--format", "json",
|
|
191
|
+
)
|
|
192
|
+
assert r.returncode == 0
|
|
193
|
+
data = json.loads(r.stdout)
|
|
194
|
+
assert data["total"] == 9, data # all 9 calls are inside the window
|
|
195
|
+
assert data["sessions"] == 1
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_tool_usage_exclude_current_drops_session(cli_path, fixtures_dir, tmp_path):
|
|
199
|
+
fake_root = tmp_path / "projects"
|
|
200
|
+
fake_proj = fake_root / "C--fake-proj"
|
|
201
|
+
fake_proj.mkdir(parents=True)
|
|
202
|
+
shutil.copy(fixtures_dir / "tool-zoo.jsonl", fake_proj / "cur.jsonl")
|
|
203
|
+
shutil.copy(fixtures_dir / "tool-zoo.jsonl", fake_proj / "other.jsonl")
|
|
204
|
+
# Without exclusion: 2 sessions, 18 calls. Excluding "cur" leaves 1 session, 9.
|
|
205
|
+
r = _run_cli(
|
|
206
|
+
cli_path, "--root", str(fake_root), "--cwd", "C:\\fake\\proj",
|
|
207
|
+
"--mode", "tool-usage", "--exclude-current", "--format", "json",
|
|
208
|
+
env_overrides={"CLAUDE_SESSION_ID": "cur"},
|
|
209
|
+
)
|
|
210
|
+
assert r.returncode == 0
|
|
211
|
+
data = json.loads(r.stdout)
|
|
212
|
+
assert data["sessions"] == 1
|
|
213
|
+
assert data["total"] == 9
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_tool_usage_include_subagents(cli_path, fixtures_dir):
|
|
217
|
+
# Parent calls: Skill(commit) + Agent. Subagent calls: Skill(estack-repo-search) + Bash.
|
|
218
|
+
# --include-subagents must fold the subagent's calls in, without counting the
|
|
219
|
+
# subagent as its own session.
|
|
220
|
+
parent = fixtures_dir / "tool-usage-parent.jsonl"
|
|
221
|
+
without = json.loads(_run_cli(
|
|
222
|
+
cli_path, "--file", str(parent), "--mode", "tool-usage", "--format", "json",
|
|
223
|
+
).stdout)
|
|
224
|
+
assert without["total"] == 2
|
|
225
|
+
assert without["sessions"] == 1
|
|
226
|
+
assert {s["skill"] for s in without["skills"]} == {"commit"}
|
|
227
|
+
|
|
228
|
+
with_sub = json.loads(_run_cli(
|
|
229
|
+
cli_path, "--file", str(parent), "--mode", "tool-usage",
|
|
230
|
+
"--include-subagents", "--format", "json",
|
|
231
|
+
).stdout)
|
|
232
|
+
assert with_sub["total"] == 4 # +Skill(estack-repo-search) +Bash
|
|
233
|
+
assert with_sub["sessions"] == 1 # subagent is not a separate session
|
|
234
|
+
assert {s["skill"] for s in with_sub["skills"]} == {"commit", "estack-repo-search"}
|
|
235
|
+
|
|
236
|
+
|
|
113
237
|
def test_subagent_list(cli_path, fixtures_dir):
|
|
114
238
|
r = _run_cli(
|
|
115
239
|
cli_path, "--file", str(fixtures_dir / "subagent-parent.jsonl"),
|
|
@@ -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
|
|