agent-chat-reader 0.1.3__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.3 → agent_chat_reader-0.1.4}/PKG-INFO +13 -1
  2. {agent_chat_reader-0.1.3 → agent_chat_reader-0.1.4}/README.md +12 -0
  3. {agent_chat_reader-0.1.3 → agent_chat_reader-0.1.4}/pyproject.toml +1 -1
  4. {agent_chat_reader-0.1.3 → agent_chat_reader-0.1.4}/src/agent_chat_reader/__init__.py +1 -1
  5. {agent_chat_reader-0.1.3 → agent_chat_reader-0.1.4}/src/agent_chat_reader/cli.py +79 -3
  6. {agent_chat_reader-0.1.3 → agent_chat_reader-0.1.4}/src/agent_chat_reader/output.py +62 -6
  7. {agent_chat_reader-0.1.3 → agent_chat_reader-0.1.4}/tests/test_package.py +88 -1
  8. {agent_chat_reader-0.1.3 → agent_chat_reader-0.1.4}/uv.lock +1 -1
  9. {agent_chat_reader-0.1.3 → agent_chat_reader-0.1.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  10. {agent_chat_reader-0.1.3 → agent_chat_reader-0.1.4}/.github/workflows/ci.yml +0 -0
  11. {agent_chat_reader-0.1.3 → agent_chat_reader-0.1.4}/.github/workflows/release.yml +0 -0
  12. {agent_chat_reader-0.1.3 → agent_chat_reader-0.1.4}/.gitignore +0 -0
  13. {agent_chat_reader-0.1.3 → agent_chat_reader-0.1.4}/.pre-commit-config.yaml +0 -0
  14. {agent_chat_reader-0.1.3 → agent_chat_reader-0.1.4}/.python-version +0 -0
  15. {agent_chat_reader-0.1.3 → agent_chat_reader-0.1.4}/AGENTS.md +0 -0
  16. {agent_chat_reader-0.1.3 → agent_chat_reader-0.1.4}/LICENSE +0 -0
  17. {agent_chat_reader-0.1.3 → agent_chat_reader-0.1.4}/docs/api.md +0 -0
  18. {agent_chat_reader-0.1.3 → agent_chat_reader-0.1.4}/docs/ci-private-submodules.md +0 -0
  19. {agent_chat_reader-0.1.3 → agent_chat_reader-0.1.4}/src/agent_chat_reader/claude.py +0 -0
  20. {agent_chat_reader-0.1.3 → agent_chat_reader-0.1.4}/src/agent_chat_reader/codex.py +0 -0
  21. {agent_chat_reader-0.1.3 → agent_chat_reader-0.1.4}/src/agent_chat_reader/models.py +0 -0
  22. {agent_chat_reader-0.1.3 → agent_chat_reader-0.1.4}/src/agent_chat_reader/py.typed +0 -0
  23. {agent_chat_reader-0.1.3 → agent_chat_reader-0.1.4}/templates/licenses/Apache-2.0 +0 -0
  24. {agent_chat_reader-0.1.3 → agent_chat_reader-0.1.4}/templates/licenses/MIT +0 -0
  25. {agent_chat_reader-0.1.3 → 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.3
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.3"
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.3"
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,
@@ -124,6 +127,8 @@ def cmd_read(
124
127
  verbose: bool,
125
128
  include_subagents: bool,
126
129
  tail: int | None,
130
+ show_timestamps: bool,
131
+ output_format: str,
127
132
  ) -> int:
128
133
  """Read a specific session as clean conversation."""
129
134
  result = _find_session(session_id)
@@ -133,7 +138,6 @@ def cmd_read(
133
138
 
134
139
  path, source = result
135
140
  size_kb = path.stat().st_size // 1024
136
- print(f"Source: {source.upper()} | {path.name} | {size_kb}KB")
137
141
 
138
142
  if source == "codex":
139
143
  turns = codex.read_turns(path, tail=tail)
@@ -142,18 +146,69 @@ def cmd_read(
142
146
  path, verbose=verbose, include_subagents=include_subagents, tail=tail
143
147
  )
144
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
+
145
155
  if not turns:
146
156
  print("(no conversation turns found)")
147
157
  return 0
148
158
 
159
+ previous_turn = None
149
160
  for turn in turns:
150
- print_turn(turn)
161
+ print_turn(
162
+ turn,
163
+ show_timestamps=show_timestamps,
164
+ previous_turn=previous_turn,
165
+ )
166
+ previous_turn = turn
151
167
 
152
168
  print(f"\n{'─' * 60}")
153
169
  print(f"Total turns: {len(turns)}")
154
170
  return 0
155
171
 
156
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
+
157
212
  def main(argv: list[str] | None = None) -> int:
158
213
  """Run the agent-chat-reader command-line interface."""
159
214
  p = argparse.ArgumentParser(
@@ -193,6 +248,25 @@ def main(argv: list[str] | None = None) -> int:
193
248
  action="store_true",
194
249
  help="Include guardian/subagent sessions",
195
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
+ )
196
270
  p.add_argument(
197
271
  "--limit",
198
272
  type=int,
@@ -220,6 +294,8 @@ def main(argv: list[str] | None = None) -> int:
220
294
  verbose=args.verbose,
221
295
  include_subagents=args.include_subagents,
222
296
  tail=args.tail,
297
+ show_timestamps=args.show_timestamps,
298
+ output_format=args.format,
223
299
  )
224
300
 
225
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.3"
11
+ version = "0.1.4"
12
12
  source = { editable = "." }
13
13
 
14
14
  [package.optional-dependencies]