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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "elliot-stack",
3
- "version": "1.0.38",
3
+ "version": "1.0.39",
4
4
  "description": "Elliot's skill stack for Claude Code — install via npx elliot-stack@latest",
5
5
  "bin": {
6
6
  "elliot-stack": "bin/install.cjs"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: estack-read-claude-session-history
3
- version: 1.1.0
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 | Matches windowed for context (supports `--role`, `--in text|tool_use|thinking|all`) |
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 > until:
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 > until:
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 = _paths.list_transcripts(project_dir, since=since, until=until)
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 progress:
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
- print(f"\nError reading {f.name}: {e}", file=sys.stderr)
160
- if progress:
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
- return "Provide --file, --cwd, --project, or --all-projects"
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
- # Group by session for readable output
632
- by_session: dict[Path, list] = {}
633
- for m in matches:
634
- by_session.setdefault(m.session_path, []).append(m)
635
- out = []
636
- for sp, ms in by_session.items():
637
- out.append(f"\n{'=' * 60}\nSession: {sp.name} ({_fmt_mtime(ms[0].mtime)})\n{'=' * 60}")
638
- for i, m in enumerate(ms, 1):
639
- label = f"--- Match #{i} [{m.role}/{m.where}] ---"
640
- out.append(f"{label}\n{m.window_text[:1500]}")
641
- return "\n\n".join(out)
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 > until:
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
- # (cwd-scoped searches with non-default role/in/json land here — the
1773
- # byte-compat legacy path above already handled the default-flag case.)
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) == []
@@ -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