agent-chat-reader 0.1.2__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/PKG-INFO +13 -1
  2. {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/README.md +12 -0
  3. {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/pyproject.toml +1 -1
  4. {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/src/agent_chat_reader/__init__.py +1 -1
  5. {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/src/agent_chat_reader/cli.py +93 -11
  6. {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/src/agent_chat_reader/output.py +62 -6
  7. {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/tests/test_package.py +88 -1
  8. {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/uv.lock +1 -1
  9. {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  10. {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/.github/workflows/ci.yml +0 -0
  11. {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/.github/workflows/release.yml +0 -0
  12. {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/.gitignore +0 -0
  13. {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/.pre-commit-config.yaml +0 -0
  14. {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/.python-version +0 -0
  15. {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/AGENTS.md +0 -0
  16. {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/LICENSE +0 -0
  17. {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/docs/api.md +0 -0
  18. {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/docs/ci-private-submodules.md +0 -0
  19. {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/src/agent_chat_reader/claude.py +0 -0
  20. {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/src/agent_chat_reader/codex.py +0 -0
  21. {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/src/agent_chat_reader/models.py +0 -0
  22. {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/src/agent_chat_reader/py.typed +0 -0
  23. {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/templates/licenses/Apache-2.0 +0 -0
  24. {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/templates/licenses/MIT +0 -0
  25. {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/templates/licenses/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-chat-reader
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Read and search Codex CLI and Claude Code chat history from the terminal.
5
5
  Project-URL: Homepage, https://github.com/alik-git/agent-chat-reader
6
6
  Project-URL: Repository, https://github.com/alik-git/agent-chat-reader
@@ -68,6 +68,8 @@ agent-chat-reader --find "policy_interface" --source codex
68
68
  agent-chat-reader 019eaecb
69
69
  agent-chat-reader 1bfc739b --verbose # include tool call summaries
70
70
  agent-chat-reader 019eaecb --tail 5 # last 5 user turns only
71
+ agent-chat-reader 019eaecb --hide-timestamps
72
+ agent-chat-reader 019eaecb --format json
71
73
  ```
72
74
 
73
75
  ## What it filters out
@@ -89,8 +91,18 @@ Use `--include-subagents` to see everything.
89
91
  | `--verbose` / `-v` | Include brief tool call summaries (Claude sessions) |
90
92
  | `--tail N` / `-n N` | Show only the last N user turns of a session |
91
93
  | `--include-subagents` | Include guardian/subagent sessions |
94
+ | `--format text\|json` | Output format for session reads |
95
+ | `--hide-timestamps` | Hide message timestamps and elapsed-gap labels |
92
96
  | `--limit N` | Max sessions shown by `--list` (default: 40) |
93
97
 
98
+ Session reads show timestamps by default. Elapsed labels use the current
99
+ speaker's role, such as `(agent took 8s)` or `(user took 4m)`, and only report
100
+ the time since the previous visible message.
101
+
102
+ Use `--format json` when another agent or script needs stable structured
103
+ fields instead of terminal separators. JSON turns include `timestamp`,
104
+ `local_time`, `elapsed_seconds`, `role`, and `text`.
105
+
94
106
  ## Session storage locations
95
107
 
96
108
  | Agent | Path |
@@ -40,6 +40,8 @@ agent-chat-reader --find "policy_interface" --source codex
40
40
  agent-chat-reader 019eaecb
41
41
  agent-chat-reader 1bfc739b --verbose # include tool call summaries
42
42
  agent-chat-reader 019eaecb --tail 5 # last 5 user turns only
43
+ agent-chat-reader 019eaecb --hide-timestamps
44
+ agent-chat-reader 019eaecb --format json
43
45
  ```
44
46
 
45
47
  ## What it filters out
@@ -61,8 +63,18 @@ Use `--include-subagents` to see everything.
61
63
  | `--verbose` / `-v` | Include brief tool call summaries (Claude sessions) |
62
64
  | `--tail N` / `-n N` | Show only the last N user turns of a session |
63
65
  | `--include-subagents` | Include guardian/subagent sessions |
66
+ | `--format text\|json` | Output format for session reads |
67
+ | `--hide-timestamps` | Hide message timestamps and elapsed-gap labels |
64
68
  | `--limit N` | Max sessions shown by `--list` (default: 40) |
65
69
 
70
+ Session reads show timestamps by default. Elapsed labels use the current
71
+ speaker's role, such as `(agent took 8s)` or `(user took 4m)`, and only report
72
+ the time since the previous visible message.
73
+
74
+ Use `--format json` when another agent or script needs stable structured
75
+ fields instead of terminal separators. JSON turns include `timestamp`,
76
+ `local_time`, `elapsed_seconds`, `role`, and `text`.
77
+
66
78
  ## Session storage locations
67
79
 
68
80
  | Agent | Path |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agent-chat-reader"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "Read and search Codex CLI and Claude Code chat history from the terminal."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -2,6 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.1.2"
5
+ __version__ = "0.1.4"
6
6
 
7
7
  __all__ = ["__version__"]
@@ -3,13 +3,16 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import json
6
7
  import sys
7
8
  from pathlib import Path
8
9
 
9
10
  from agent_chat_reader import __version__, claude, codex
10
- from agent_chat_reader.models import SessionMeta
11
+ from agent_chat_reader.models import SessionMeta, Turn
11
12
  from agent_chat_reader.output import (
12
13
  FindHit,
14
+ elapsed_seconds_between,
15
+ fmt_ts,
13
16
  print_find_result,
14
17
  print_session_list,
15
18
  print_turn,
@@ -58,12 +61,12 @@ def _title_key(title: str) -> str:
58
61
 
59
62
 
60
63
  def cmd_find(
61
- keyword: str,
64
+ keywords: list[str],
62
65
  *,
63
66
  source_filter: str | None,
64
67
  include_subagents: bool,
65
68
  ) -> int:
66
- """Search sessions for a keyword, deduplicating continuation sessions."""
69
+ """Search sessions for keywords (AND logic), deduplicating continuation sessions."""
67
70
  sessions: list[SessionMeta] = []
68
71
  if source_filter != "claude":
69
72
  sessions += codex.list_sessions(include_subagents=include_subagents)
@@ -71,7 +74,12 @@ def cmd_find(
71
74
  sessions += claude.list_sessions()
72
75
 
73
76
  sessions = sorted(sessions, key=lambda s: s.mtime, reverse=True)
74
- keyword_lower = keyword.lower()
77
+ keywords_lower = [k.lower() for k in keywords]
78
+
79
+ def _turn_matches(text: str) -> bool:
80
+ """Return True if the turn contains all keywords (AND)."""
81
+ text_lower = text.lower()
82
+ return all(k in text_lower for k in keywords_lower)
75
83
 
76
84
  # Collect hits per session, then group by title to deduplicate continuations.
77
85
  # Each group keeps the most-recent session's metadata as the representative.
@@ -89,7 +97,7 @@ def cmd_find(
89
97
  hits: list[FindHit] = [
90
98
  (t.role, t.text[:120].replace("\n", " "), t.timestamp)
91
99
  for t in turns
92
- if keyword_lower in t.text.lower()
100
+ if _turn_matches(t.text)
93
101
  ]
94
102
  if not hits:
95
103
  continue
@@ -99,12 +107,12 @@ def cmd_find(
99
107
  groups[key] = (s, hits, 1)
100
108
  else:
101
109
  rep, existing_hits, count = groups[key]
102
- # Merge hits, keeping the most-recent session as representative.
103
110
  rep = s if s.mtime > rep.mtime else rep
104
111
  groups[key] = (rep, existing_hits + hits, count + 1)
105
112
 
106
113
  if not groups:
107
- print(f"No sessions found containing {keyword!r}")
114
+ query = " AND ".join(f"{k!r}" for k in keywords)
115
+ print(f"No sessions found containing {query}")
108
116
  return 0
109
117
 
110
118
  for rep, hits, count in groups.values():
@@ -119,6 +127,8 @@ def cmd_read(
119
127
  verbose: bool,
120
128
  include_subagents: bool,
121
129
  tail: int | None,
130
+ show_timestamps: bool,
131
+ output_format: str,
122
132
  ) -> int:
123
133
  """Read a specific session as clean conversation."""
124
134
  result = _find_session(session_id)
@@ -128,7 +138,6 @@ def cmd_read(
128
138
 
129
139
  path, source = result
130
140
  size_kb = path.stat().st_size // 1024
131
- print(f"Source: {source.upper()} | {path.name} | {size_kb}KB")
132
141
 
133
142
  if source == "codex":
134
143
  turns = codex.read_turns(path, tail=tail)
@@ -137,18 +146,69 @@ def cmd_read(
137
146
  path, verbose=verbose, include_subagents=include_subagents, tail=tail
138
147
  )
139
148
 
149
+ if output_format == "json":
150
+ print(_json_session(source=source, path=path, size_kb=size_kb, turns=turns))
151
+ return 0
152
+
153
+ print(f"Source: {source.upper()} | {path.name} | {size_kb}KB")
154
+
140
155
  if not turns:
141
156
  print("(no conversation turns found)")
142
157
  return 0
143
158
 
159
+ previous_turn = None
144
160
  for turn in turns:
145
- print_turn(turn)
161
+ print_turn(
162
+ turn,
163
+ show_timestamps=show_timestamps,
164
+ previous_turn=previous_turn,
165
+ )
166
+ previous_turn = turn
146
167
 
147
168
  print(f"\n{'─' * 60}")
148
169
  print(f"Total turns: {len(turns)}")
149
170
  return 0
150
171
 
151
172
 
173
+ def _json_session(*, source: str, path: Path, size_kb: int, turns: list[Turn]) -> str:
174
+ """Serialize a read session as structured JSON."""
175
+ previous_turn = None
176
+ turn_records: list[dict[str, object]] = []
177
+ for turn in turns:
178
+ elapsed_seconds: int | None = (
179
+ None
180
+ if previous_turn is None
181
+ else elapsed_seconds_between(previous_turn, turn)
182
+ )
183
+ turn_records.append(
184
+ {
185
+ "role": turn.role,
186
+ "timestamp": turn.timestamp or None,
187
+ "local_time": fmt_ts(turn.timestamp) or None,
188
+ "elapsed_seconds": elapsed_seconds,
189
+ "text": turn.text,
190
+ }
191
+ )
192
+ previous_turn = turn
193
+
194
+ payload = {
195
+ "source": source,
196
+ "session_id": _session_id_for_path(path, source),
197
+ "path": str(path),
198
+ "size_kb": size_kb,
199
+ "total_turns": len(turns),
200
+ "turns": turn_records,
201
+ }
202
+ return json.dumps(payload, indent=2)
203
+
204
+
205
+ def _session_id_for_path(path: Path, source: str) -> str:
206
+ """Return the source-specific session id for a session file."""
207
+ if source == "codex":
208
+ return codex._session_id_from_path(path)
209
+ return path.stem
210
+
211
+
152
212
  def main(argv: list[str] | None = None) -> int:
153
213
  """Run the agent-chat-reader command-line interface."""
154
214
  p = argparse.ArgumentParser(
@@ -165,8 +225,9 @@ def main(argv: list[str] | None = None) -> int:
165
225
  p.add_argument(
166
226
  "--find",
167
227
  "-f",
228
+ nargs="+",
168
229
  metavar="KEYWORD",
169
- help="Search sessions for keyword",
230
+ help="Search sessions (multiple keywords = AND logic)",
170
231
  )
171
232
  p.add_argument("--source", choices=["codex", "claude"], help="Filter to one source")
172
233
  p.add_argument(
@@ -187,6 +248,25 @@ def main(argv: list[str] | None = None) -> int:
187
248
  action="store_true",
188
249
  help="Include guardian/subagent sessions",
189
250
  )
251
+ p.add_argument(
252
+ "--format",
253
+ choices=["text", "json"],
254
+ default="text",
255
+ help="Output format for session reads (default: text)",
256
+ )
257
+ p.add_argument(
258
+ "--show-timestamps",
259
+ action="store_true",
260
+ dest="show_timestamps",
261
+ default=True,
262
+ help="Show message timestamps and elapsed gaps in session reads",
263
+ )
264
+ p.add_argument(
265
+ "--hide-timestamps",
266
+ action="store_false",
267
+ dest="show_timestamps",
268
+ help="Hide message timestamps and elapsed gaps in session reads",
269
+ )
190
270
  p.add_argument(
191
271
  "--limit",
192
272
  type=int,
@@ -204,7 +284,7 @@ def main(argv: list[str] | None = None) -> int:
204
284
  )
205
285
  if args.find:
206
286
  return cmd_find(
207
- args.find,
287
+ args.find, # list[str] from nargs="+"
208
288
  source_filter=args.source,
209
289
  include_subagents=args.include_subagents,
210
290
  )
@@ -214,6 +294,8 @@ def main(argv: list[str] | None = None) -> int:
214
294
  verbose=args.verbose,
215
295
  include_subagents=args.include_subagents,
216
296
  tail=args.tail,
297
+ show_timestamps=args.show_timestamps,
298
+ output_format=args.format,
217
299
  )
218
300
 
219
301
  p.print_help()
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import textwrap
6
- from datetime import datetime
6
+ from datetime import datetime, timedelta
7
7
 
8
8
  from agent_chat_reader.models import SessionMeta, Turn
9
9
 
@@ -18,11 +18,62 @@ def fmt_ts(ts_str: str) -> str:
18
18
  """Format an ISO timestamp to a short local time string."""
19
19
  if not ts_str:
20
20
  return ""
21
- try:
22
- dt = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
21
+ dt = _parse_ts(ts_str)
22
+ if dt is not None:
23
23
  return dt.astimezone().strftime("%Y-%m-%d %H:%M")
24
+ return ts_str[:16]
25
+
26
+
27
+ def _parse_ts(ts_str: str) -> datetime | None:
28
+ """Parse an ISO timestamp, returning None for non-ISO placeholders."""
29
+ try:
30
+ return datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
24
31
  except ValueError:
25
- return ts_str[:16]
32
+ return None
33
+
34
+
35
+ def fmt_elapsed(delta: timedelta) -> str:
36
+ """Format an elapsed duration compactly."""
37
+ seconds = max(0, int(delta.total_seconds()))
38
+ if seconds < 60:
39
+ return f"{seconds}s"
40
+ minutes = seconds // 60
41
+ if minutes < 60:
42
+ return f"{minutes}m"
43
+ hours = minutes // 60
44
+ minutes %= 60
45
+ if hours < 24:
46
+ return f"{hours}h {minutes}m" if minutes else f"{hours}h"
47
+ days = hours // 24
48
+ hours %= 24
49
+ return f"{days}d {hours}h" if hours else f"{days}d"
50
+
51
+
52
+ def elapsed_seconds_between(previous_turn: Turn, turn: Turn) -> int | None:
53
+ """Return elapsed seconds between two turns, if both timestamps parse."""
54
+ current_dt = _parse_ts(turn.timestamp)
55
+ previous_dt = _parse_ts(previous_turn.timestamp)
56
+ if current_dt is None or previous_dt is None:
57
+ return None
58
+ return max(0, int((current_dt - previous_dt).total_seconds()))
59
+
60
+
61
+ def _timestamp_suffix(turn: Turn, previous_turn: Turn | None) -> str:
62
+ """Return the optional timestamp/gap suffix for a turn header."""
63
+ timestamp = fmt_ts(turn.timestamp)
64
+ if not timestamp:
65
+ return ""
66
+
67
+ if previous_turn is None:
68
+ return f" {timestamp}"
69
+
70
+ elapsed_seconds = elapsed_seconds_between(previous_turn, turn)
71
+ if elapsed_seconds is None:
72
+ return f" {timestamp}"
73
+
74
+ elapsed = fmt_elapsed(timedelta(seconds=elapsed_seconds))
75
+ role = turn.role.lower()
76
+ return f" {timestamp} ({role} took {elapsed})"
26
77
 
27
78
 
28
79
  def fmt_mtime(mtime: float) -> str:
@@ -30,9 +81,14 @@ def fmt_mtime(mtime: float) -> str:
30
81
  return datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M") # noqa: DTZ006
31
82
 
32
83
 
33
- def print_turn(turn: Turn) -> None:
84
+ def print_turn(
85
+ turn: Turn,
86
+ *,
87
+ show_timestamps: bool = True,
88
+ previous_turn: Turn | None = None,
89
+ ) -> None:
34
90
  """Print a single conversation turn with a header rule."""
35
- ts_str = f" {fmt_ts(turn.timestamp)}" if turn.timestamp else ""
91
+ ts_str = _timestamp_suffix(turn, previous_turn) if show_timestamps else ""
36
92
  print(f"\n{'─' * 60}")
37
93
  print(f"[{turn.role}]{ts_str}")
38
94
  print("─" * 60)
@@ -9,9 +9,10 @@ import pytest
9
9
 
10
10
  import agent_chat_reader
11
11
  from agent_chat_reader.claude import _extract_user_text
12
- from agent_chat_reader.cli import main
12
+ from agent_chat_reader.cli import _json_session, main
13
13
  from agent_chat_reader.codex import _apply_tail, _session_id_from_path, read_turns
14
14
  from agent_chat_reader.models import Turn
15
+ from agent_chat_reader.output import elapsed_seconds_between, fmt_elapsed, print_turn
15
16
 
16
17
  # ── Version ───────────────────────────────────────────────────────────────────
17
18
 
@@ -154,6 +155,92 @@ def test_apply_tail_none_returns_all() -> None:
154
155
  assert _apply_tail(turns, tail=None) == turns
155
156
 
156
157
 
158
+ # ── Output formatting ────────────────────────────────────────────────────────
159
+
160
+
161
+ def test_print_turn_shows_timestamp_by_default(
162
+ capsys: pytest.CaptureFixture[str],
163
+ ) -> None:
164
+ """The default transcript view includes timing context."""
165
+ print_turn(Turn("USER", "hello", "2026-01-01T12:00:00Z"))
166
+ out = capsys.readouterr().out
167
+ assert "[USER]" in out
168
+ assert "2026-01" in out
169
+
170
+
171
+ def test_print_turn_can_hide_timestamp(
172
+ capsys: pytest.CaptureFixture[str],
173
+ ) -> None:
174
+ """Timestamp display can be disabled for quieter transcript reads."""
175
+ print_turn(
176
+ Turn("USER", "hello", "2026-01-01T00:00:00Z"),
177
+ show_timestamps=False,
178
+ )
179
+ out = capsys.readouterr().out
180
+ assert "[USER]" in out
181
+ assert "2026-01" not in out
182
+
183
+
184
+ def test_print_turn_can_show_agent_elapsed_time(
185
+ capsys: pytest.CaptureFixture[str],
186
+ ) -> None:
187
+ """Timestamp mode shows elapsed time for the current speaker."""
188
+ previous_turn = Turn("USER", "hello", "2026-01-01T00:00:00Z")
189
+ print_turn(
190
+ Turn("AGENT", "hi", "2026-01-01T00:02:05Z"),
191
+ show_timestamps=True,
192
+ previous_turn=previous_turn,
193
+ )
194
+ out = capsys.readouterr().out
195
+ assert "[AGENT]" in out
196
+ assert "(agent took 2m)" in out
197
+
198
+
199
+ def test_print_turn_can_show_user_elapsed_time(
200
+ capsys: pytest.CaptureFixture[str],
201
+ ) -> None:
202
+ """User messages show elapsed time without inferring idle state."""
203
+ previous_turn = Turn("AGENT", "question?", "2026-01-01T00:00:00Z")
204
+ print_turn(
205
+ Turn("USER", "answer", "2026-01-01T00:30:00Z"),
206
+ show_timestamps=True,
207
+ previous_turn=previous_turn,
208
+ )
209
+ assert "(user took 30m)" in capsys.readouterr().out
210
+
211
+
212
+ def test_fmt_elapsed_handles_long_gaps() -> None:
213
+ """Elapsed durations stay compact for long sessions."""
214
+ from datetime import timedelta
215
+
216
+ assert fmt_elapsed(timedelta(days=1, hours=2, minutes=3)) == "1d 2h"
217
+
218
+
219
+ def test_elapsed_seconds_between_returns_machine_readable_gap() -> None:
220
+ """Elapsed gap calculations are available without parsing display text."""
221
+ previous_turn = Turn("USER", "hello", "2026-01-01T00:00:00Z")
222
+ turn = Turn("AGENT", "hi", "2026-01-01T00:02:05Z")
223
+ assert elapsed_seconds_between(previous_turn, turn) == 125
224
+
225
+
226
+ def test_json_session_includes_structured_timing_fields(tmp_path: Path) -> None:
227
+ """JSON output exposes stable fields for agents and scripts."""
228
+ session = tmp_path / _SESSION_FILE
229
+ turns = [
230
+ Turn("USER", "hello", "2026-01-01T00:00:00Z"),
231
+ Turn("AGENT", "hi", "2026-01-01T00:02:05Z"),
232
+ ]
233
+ payload = json.loads(
234
+ _json_session(source="codex", path=session, size_kb=12, turns=turns)
235
+ )
236
+ assert payload["source"] == "codex"
237
+ assert payload["size_kb"] == 12
238
+ assert payload["total_turns"] == 2
239
+ assert payload["turns"][0]["elapsed_seconds"] is None
240
+ assert payload["turns"][1]["elapsed_seconds"] == 125
241
+ assert payload["turns"][1]["text"] == "hi"
242
+
243
+
157
244
  # ── Claude parsing ────────────────────────────────────────────────────────────
158
245
 
159
246
 
@@ -8,7 +8,7 @@ resolution-markers = [
8
8
 
9
9
  [[package]]
10
10
  name = "agent-chat-reader"
11
- version = "0.1.2"
11
+ version = "0.1.4"
12
12
  source = { editable = "." }
13
13
 
14
14
  [package.optional-dependencies]