agent-chat-reader 0.1.0__tar.gz → 0.1.2__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.0 → agent_chat_reader-0.1.2}/.github/workflows/ci.yml +2 -2
  2. {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/PKG-INFO +1 -1
  3. {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/pyproject.toml +1 -1
  4. {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/src/agent_chat_reader/__init__.py +1 -1
  5. {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/src/agent_chat_reader/claude.py +15 -16
  6. {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/src/agent_chat_reader/cli.py +57 -16
  7. {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/src/agent_chat_reader/codex.py +4 -3
  8. {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/src/agent_chat_reader/output.py +14 -6
  9. {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/tests/test_package.py +62 -22
  10. {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/uv.lock +1 -1
  11. {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  12. {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/.github/workflows/release.yml +0 -0
  13. {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/.gitignore +0 -0
  14. {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/.pre-commit-config.yaml +0 -0
  15. {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/.python-version +0 -0
  16. {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/AGENTS.md +0 -0
  17. {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/LICENSE +0 -0
  18. {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/README.md +0 -0
  19. {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/docs/api.md +0 -0
  20. {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/docs/ci-private-submodules.md +0 -0
  21. {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/src/agent_chat_reader/models.py +0 -0
  22. {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/src/agent_chat_reader/py.typed +0 -0
  23. {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/templates/licenses/Apache-2.0 +0 -0
  24. {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/templates/licenses/MIT +0 -0
  25. {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/templates/licenses/README.md +0 -0
@@ -104,7 +104,7 @@ jobs:
104
104
  run: python -m pip install --force-reinstall dist/*.whl
105
105
 
106
106
  - name: Smoke test installed package
107
- run: python -c "import mypackage; print(mypackage.do_useful_thing())"
107
+ run: python -c "import agent_chat_reader; print(agent_chat_reader.__version__)"
108
108
 
109
109
  package-uv:
110
110
  name: Package Build (uv)
@@ -134,4 +134,4 @@ jobs:
134
134
  run: uv pip install --force-reinstall dist/*.whl
135
135
 
136
136
  - name: Smoke test installed package
137
- run: uv run python -c "import mypackage; print(mypackage.do_useful_thing())"
137
+ run: uv run python -c "import agent_chat_reader; print(agent_chat_reader.__version__)"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-chat-reader
3
- Version: 0.1.0
3
+ Version: 0.1.2
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agent-chat-reader"
7
- version = "0.1.0"
7
+ version = "0.1.2"
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.0"
5
+ __version__ = "0.1.2"
6
6
 
7
7
  __all__ = ["__version__"]
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  from pathlib import Path
7
+ from typing import Any
7
8
 
8
9
  from agent_chat_reader.models import SessionMeta, Turn
9
10
 
@@ -31,23 +32,21 @@ def _session_title(path: Path) -> str:
31
32
  return last_title
32
33
 
33
34
 
34
- def _extract_user_text(content: str | list) -> str | None: # type: ignore[type-arg]
35
+ def _extract_user_text(content: str | list[Any]) -> str | None:
35
36
  """Extract real user text; return None for tool-result carriers."""
36
37
  if isinstance(content, str):
37
38
  return content.strip() or None
38
- if isinstance(content, list):
39
- has_tool_result = any(
40
- b.get("type") == "tool_result" for b in content if isinstance(b, dict)
41
- )
42
- if has_tool_result:
43
- return None
44
- text = " ".join(
45
- b.get("text", "")
46
- for b in content
47
- if isinstance(b, dict) and b.get("type") == "text"
48
- ).strip()
49
- return text or None
50
- return None
39
+ has_tool_result = any(
40
+ b.get("type") == "tool_result" for b in content if isinstance(b, dict)
41
+ )
42
+ if has_tool_result:
43
+ return None
44
+ text = " ".join(
45
+ b.get("text", "")
46
+ for b in content
47
+ if isinstance(b, dict) and b.get("type") == "text"
48
+ ).strip()
49
+ return text or None
51
50
 
52
51
 
53
52
  def list_sessions() -> list[SessionMeta]:
@@ -142,7 +141,7 @@ def read_turns(
142
141
  return _apply_tail(turns, tail)
143
142
 
144
143
 
145
- def _format_tool_call(block: dict) -> str: # type: ignore[type-arg]
144
+ def _format_tool_call(block: dict[str, Any]) -> str:
146
145
  """Format a tool_use block as a short bracketed summary."""
147
146
  name = block.get("name", "?")
148
147
  inp = block.get("input", {})
@@ -164,4 +163,4 @@ def _apply_tail(turns: list[Turn], tail: int | None) -> list[Turn]:
164
163
  user_indices = [i for i, t in enumerate(turns) if t.role == "USER"]
165
164
  if tail >= len(user_indices):
166
165
  return turns
167
- return turns[user_indices[-tail]:]
166
+ return turns[user_indices[-tail] :]
@@ -8,7 +8,12 @@ from pathlib import Path
8
8
 
9
9
  from agent_chat_reader import __version__, claude, codex
10
10
  from agent_chat_reader.models import SessionMeta
11
- from agent_chat_reader.output import print_find_result, print_session_list, print_turn
11
+ from agent_chat_reader.output import (
12
+ FindHit,
13
+ print_find_result,
14
+ print_session_list,
15
+ print_turn,
16
+ )
12
17
 
13
18
 
14
19
  def _find_session(session_id: str) -> tuple[Path, str] | None:
@@ -47,13 +52,18 @@ def cmd_list(
47
52
  return 0
48
53
 
49
54
 
55
+ def _title_key(title: str) -> str:
56
+ """Normalise a session title for dedup grouping."""
57
+ return title.strip()[:80].lower()
58
+
59
+
50
60
  def cmd_find(
51
61
  keyword: str,
52
62
  *,
53
63
  source_filter: str | None,
54
64
  include_subagents: bool,
55
65
  ) -> int:
56
- """Search sessions for a keyword."""
66
+ """Search sessions for a keyword, deduplicating continuation sessions."""
57
67
  sessions: list[SessionMeta] = []
58
68
  if source_filter != "claude":
59
69
  sessions += codex.list_sessions(include_subagents=include_subagents)
@@ -62,7 +72,10 @@ def cmd_find(
62
72
 
63
73
  sessions = sorted(sessions, key=lambda s: s.mtime, reverse=True)
64
74
  keyword_lower = keyword.lower()
65
- found_any = False
75
+
76
+ # Collect hits per session, then group by title to deduplicate continuations.
77
+ # Each group keeps the most-recent session's metadata as the representative.
78
+ groups: dict[str, tuple[SessionMeta, list[FindHit], int]] = {}
66
79
 
67
80
  for s in sessions:
68
81
  try:
@@ -73,17 +86,30 @@ def cmd_find(
73
86
  except Exception:
74
87
  continue
75
88
 
76
- hits = [
77
- (t.role, t.text[:120].replace("\n", " "))
89
+ hits: list[FindHit] = [
90
+ (t.role, t.text[:120].replace("\n", " "), t.timestamp)
78
91
  for t in turns
79
92
  if keyword_lower in t.text.lower()
80
93
  ]
81
- if hits:
82
- found_any = True
83
- print_find_result(s, hits)
94
+ if not hits:
95
+ continue
84
96
 
85
- if not found_any:
97
+ key = _title_key(s.title)
98
+ if key not in groups:
99
+ groups[key] = (s, hits, 1)
100
+ else:
101
+ rep, existing_hits, count = groups[key]
102
+ # Merge hits, keeping the most-recent session as representative.
103
+ rep = s if s.mtime > rep.mtime else rep
104
+ groups[key] = (rep, existing_hits + hits, count + 1)
105
+
106
+ if not groups:
86
107
  print(f"No sessions found containing {keyword!r}")
108
+ return 0
109
+
110
+ for rep, hits, count in groups.values():
111
+ print_find_result(rep, hits, merged_count=count)
112
+
87
113
  return 0
88
114
 
89
115
 
@@ -118,7 +144,7 @@ def cmd_read(
118
144
  for turn in turns:
119
145
  print_turn(turn)
120
146
 
121
- print(f"\n{'─'*60}")
147
+ print(f"\n{'─' * 60}")
122
148
  print(f"Total turns: {len(turns)}")
123
149
  return 0
124
150
 
@@ -130,27 +156,42 @@ def main(argv: list[str] | None = None) -> int:
130
156
  description="Read and search Codex CLI and Claude Code chat history.",
131
157
  )
132
158
  p.add_argument(
133
- "--version", action="version", version=f"agent-chat-reader {__version__}",
159
+ "--version",
160
+ action="version",
161
+ version=f"agent-chat-reader {__version__}",
134
162
  )
135
163
  p.add_argument("session", nargs="?", help="Session ID or file path to read")
136
164
  p.add_argument("--list", "-l", action="store_true", help="List recent sessions")
137
165
  p.add_argument(
138
- "--find", "-f", metavar="KEYWORD", help="Search sessions for keyword",
166
+ "--find",
167
+ "-f",
168
+ metavar="KEYWORD",
169
+ help="Search sessions for keyword",
139
170
  )
140
171
  p.add_argument("--source", choices=["codex", "claude"], help="Filter to one source")
141
172
  p.add_argument(
142
- "--verbose", "-v", action="store_true",
173
+ "--verbose",
174
+ "-v",
175
+ action="store_true",
143
176
  help="Include tool call summaries (Claude sessions)",
144
177
  )
145
178
  p.add_argument(
146
- "--tail", "-n", type=int, metavar="N", help="Show only the last N user turns",
179
+ "--tail",
180
+ "-n",
181
+ type=int,
182
+ metavar="N",
183
+ help="Show only the last N user turns",
147
184
  )
148
185
  p.add_argument(
149
- "--include-subagents", action="store_true",
186
+ "--include-subagents",
187
+ action="store_true",
150
188
  help="Include guardian/subagent sessions",
151
189
  )
152
190
  p.add_argument(
153
- "--limit", type=int, default=40, help="Max sessions for --list (default: 40)",
191
+ "--limit",
192
+ type=int,
193
+ default=40,
194
+ help="Max sessions for --list (default: 40)",
154
195
  )
155
196
 
156
197
  args = p.parse_args(argv)
@@ -21,7 +21,7 @@ def is_subagent(path: Path) -> bool:
21
21
  if payload.get("thread_source") == "subagent":
22
22
  return True
23
23
  source = payload.get("source", {})
24
- return isinstance(source, dict) and "subagent" in source
24
+ return bool(isinstance(source, dict) and "subagent" in source)
25
25
  except Exception:
26
26
  pass
27
27
  return False
@@ -37,7 +37,8 @@ def _first_user_message(path: Path) -> str:
37
37
  rec.get("type") == "event_msg"
38
38
  and rec.get("payload", {}).get("type") == "user_message"
39
39
  ):
40
- return rec["payload"].get("message", "").replace("\n", " ")[:80]
40
+ msg: str = rec["payload"].get("message", "")
41
+ return msg.replace("\n", " ")[:80]
41
42
  except Exception:
42
43
  pass
43
44
  return ""
@@ -148,4 +149,4 @@ def _apply_tail(turns: list[Turn], tail: int | None) -> list[Turn]:
148
149
  user_indices = [i for i, t in enumerate(turns) if t.role == "USER"]
149
150
  if tail >= len(user_indices):
150
151
  return turns
151
- return turns[user_indices[-tail]:]
152
+ return turns[user_indices[-tail] :]
@@ -10,6 +10,9 @@ from agent_chat_reader.models import SessionMeta, Turn
10
10
  _MAX_TURN_LEN = 3000
11
11
  _WRAP_THRESHOLD = 400
12
12
 
13
+ # Type alias: (role, snippet, timestamp)
14
+ FindHit = tuple[str, str, str]
15
+
13
16
 
14
17
  def fmt_ts(ts_str: str) -> str:
15
18
  """Format an ISO timestamp to a short local time string."""
@@ -30,7 +33,7 @@ def fmt_mtime(mtime: float) -> str:
30
33
  def print_turn(turn: Turn) -> None:
31
34
  """Print a single conversation turn with a header rule."""
32
35
  ts_str = f" {fmt_ts(turn.timestamp)}" if turn.timestamp else ""
33
- print(f"\n{'─'*60}")
36
+ print(f"\n{'─' * 60}")
34
37
  print(f"[{turn.role}]{ts_str}")
35
38
  print("─" * 60)
36
39
  text = turn.text
@@ -54,16 +57,21 @@ def print_session_list(sessions: list[SessionMeta]) -> None:
54
57
 
55
58
  def print_find_result(
56
59
  session: SessionMeta,
57
- hits: list[tuple[str, str]],
60
+ hits: list[FindHit],
58
61
  *,
59
62
  max_hits: int = 4,
63
+ merged_count: int = 1,
60
64
  ) -> None:
61
65
  """Print a single session's find results."""
62
- print(f"\n{'='*70}")
63
- print(f"[{session.source.upper()}] {session.id} {fmt_mtime(session.mtime)}")
66
+ print(f"\n{'=' * 70}")
67
+ merged = f" [{merged_count} sessions]" if merged_count > 1 else ""
68
+ print(
69
+ f"[{session.source.upper()}] {session.id} {fmt_mtime(session.mtime)}{merged}"
70
+ )
64
71
  if session.title:
65
72
  print(f' "{session.title[:60]}"')
66
- for role, snippet in hits[:max_hits]:
67
- print(f" [{role}] {snippet!r}")
73
+ for role, snippet, ts in hits[:max_hits]:
74
+ ts_str = f" {fmt_ts(ts)}" if ts else ""
75
+ print(f" [{role}{ts_str}] {snippet!r}")
68
76
  if len(hits) > max_hits:
69
77
  print(f" ... and {len(hits) - max_hits} more matches")
@@ -15,6 +15,7 @@ from agent_chat_reader.models import Turn
15
15
 
16
16
  # ── Version ───────────────────────────────────────────────────────────────────
17
17
 
18
+
18
19
  def test_version() -> None:
19
20
  """Package version is a non-empty string."""
20
21
  assert isinstance(agent_chat_reader.__version__, str)
@@ -31,6 +32,7 @@ def test_cli_version(capsys: pytest.CaptureFixture[str]) -> None:
31
32
 
32
33
  # ── Codex parsing ─────────────────────────────────────────────────────────────
33
34
 
35
+
34
36
  def _write_jsonl(path: Path, records: list[dict]) -> None:
35
37
  """Write a list of dicts as JSONL."""
36
38
  with path.open("w") as fh:
@@ -44,15 +46,30 @@ _SESSION_FILE = "rollout-2026-01-01T00-00-00-019eae7d-da44-7413-8eb7-52d87219b1d
44
46
  def test_codex_read_turns_basic(tmp_path: Path) -> None:
45
47
  """Extracts user and agent turns from a minimal Codex session."""
46
48
  session = tmp_path / _SESSION_FILE
47
- _write_jsonl(session, [
48
- {"type": "session_meta", "payload": {"thread_source": "user"}},
49
- {"type": "event_msg", "timestamp": "2026-01-01T00:00:00Z",
50
- "payload": {"type": "user_message", "message": "hello"}},
51
- {"type": "event_msg", "timestamp": "2026-01-01T00:00:01Z",
52
- "payload": {"type": "agent_message", "message": "hi there"}},
53
- {"type": "response_item", "timestamp": "2026-01-01T00:00:02Z",
54
- "payload": {"role": "assistant", "content": [{"type": "text", "text": "ok"}]}},
55
- ])
49
+ _write_jsonl(
50
+ session,
51
+ [
52
+ {"type": "session_meta", "payload": {"thread_source": "user"}},
53
+ {
54
+ "type": "event_msg",
55
+ "timestamp": "2026-01-01T00:00:00Z",
56
+ "payload": {"type": "user_message", "message": "hello"},
57
+ },
58
+ {
59
+ "type": "event_msg",
60
+ "timestamp": "2026-01-01T00:00:01Z",
61
+ "payload": {"type": "agent_message", "message": "hi there"},
62
+ },
63
+ {
64
+ "type": "response_item",
65
+ "timestamp": "2026-01-01T00:00:02Z",
66
+ "payload": {
67
+ "role": "assistant",
68
+ "content": [{"type": "text", "text": "ok"}],
69
+ },
70
+ },
71
+ ],
72
+ )
56
73
  turns = read_turns(session)
57
74
  assert turns[0] == Turn("USER", "hello", "2026-01-01T00:00:00Z")
58
75
  assert turns[1] == Turn("AGENT", "hi there", "2026-01-01T00:00:01Z")
@@ -62,14 +79,29 @@ def test_codex_read_turns_basic(tmp_path: Path) -> None:
62
79
  def test_codex_deduplicates_agent_and_response_item(tmp_path: Path) -> None:
63
80
  """agent_message and response_item with same text are deduplicated."""
64
81
  session = tmp_path / _SESSION_FILE
65
- _write_jsonl(session, [
66
- {"type": "event_msg", "timestamp": "t1",
67
- "payload": {"type": "user_message", "message": "hi"}},
68
- {"type": "event_msg", "timestamp": "t2",
69
- "payload": {"type": "agent_message", "message": "same text"}},
70
- {"type": "response_item", "timestamp": "t3",
71
- "payload": {"role": "assistant", "content": [{"type": "text", "text": "same text"}]}},
72
- ])
82
+ _write_jsonl(
83
+ session,
84
+ [
85
+ {
86
+ "type": "event_msg",
87
+ "timestamp": "t1",
88
+ "payload": {"type": "user_message", "message": "hi"},
89
+ },
90
+ {
91
+ "type": "event_msg",
92
+ "timestamp": "t2",
93
+ "payload": {"type": "agent_message", "message": "same text"},
94
+ },
95
+ {
96
+ "type": "response_item",
97
+ "timestamp": "t3",
98
+ "payload": {
99
+ "role": "assistant",
100
+ "content": [{"type": "text", "text": "same text"}],
101
+ },
102
+ },
103
+ ],
104
+ )
73
105
  turns = read_turns(session)
74
106
  agent_turns = [t for t in turns if t.role == "AGENT"]
75
107
  assert len(agent_turns) == 1
@@ -79,11 +111,17 @@ def test_codex_deduplicates_agent_and_response_item(tmp_path: Path) -> None:
79
111
  def test_codex_skips_empty_messages(tmp_path: Path) -> None:
80
112
  """Empty user/agent messages are not emitted as turns."""
81
113
  session = tmp_path / _SESSION_FILE
82
- _write_jsonl(session, [
83
- {"type": "event_msg", "payload": {"type": "user_message", "message": " "}},
84
- {"type": "event_msg", "payload": {"type": "agent_message", "message": ""}},
85
- {"type": "event_msg", "payload": {"type": "user_message", "message": "real"}},
86
- ])
114
+ _write_jsonl(
115
+ session,
116
+ [
117
+ {"type": "event_msg", "payload": {"type": "user_message", "message": " "}},
118
+ {"type": "event_msg", "payload": {"type": "agent_message", "message": ""}},
119
+ {
120
+ "type": "event_msg",
121
+ "payload": {"type": "user_message", "message": "real"},
122
+ },
123
+ ],
124
+ )
87
125
  turns = read_turns(session)
88
126
  assert len(turns) == 1
89
127
  assert turns[0].text == "real"
@@ -118,6 +156,7 @@ def test_apply_tail_none_returns_all() -> None:
118
156
 
119
157
  # ── Claude parsing ────────────────────────────────────────────────────────────
120
158
 
159
+
121
160
  def test_claude_extract_user_text_string() -> None:
122
161
  """Plain string content is returned as-is."""
123
162
  assert _extract_user_text("hello") == "hello"
@@ -142,6 +181,7 @@ def test_claude_extract_user_text_text_block() -> None:
142
181
 
143
182
  # ── CLI smoke ─────────────────────────────────────────────────────────────────
144
183
 
184
+
145
185
  def test_cli_no_args_exits_zero(capsys: pytest.CaptureFixture[str]) -> None:
146
186
  """Calling main() with no args prints help and exits 0."""
147
187
  assert main([]) == 0
@@ -8,7 +8,7 @@ resolution-markers = [
8
8
 
9
9
  [[package]]
10
10
  name = "agent-chat-reader"
11
- version = "0.1.0"
11
+ version = "0.1.2"
12
12
  source = { editable = "." }
13
13
 
14
14
  [package.optional-dependencies]