cctx-cli 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,251 @@
1
+ """Terminal renderer for autopsy Diagnosis output.
2
+
3
+ render_diagnosis(diagnosis, console=None) -> None
4
+ render_aggregate(report, console=None) -> None
5
+ render_harvest_results(results, dry_run=False, console=None) -> None
6
+ render_projects(projects, console=None) -> None
7
+ render_sessions(project, console=None) -> None
8
+
9
+ Uses rich for formatting. Accepts an optional Console for testing.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING
15
+
16
+ from rich.console import Console
17
+ from rich.panel import Panel
18
+ from rich.rule import Rule
19
+ from rich.syntax import Syntax
20
+ from rich.table import Table
21
+ from rich.text import Text
22
+
23
+ from cctx.models import FindingKind, Severity
24
+
25
+ if TYPE_CHECKING:
26
+ from cctx.discovery import ProjectInfo
27
+ from cctx.models import AggregateReport, Diagnosis
28
+
29
+ _SEVERITY_STYLE = {
30
+ Severity.HIGH: "bold red",
31
+ Severity.MEDIUM: "bold yellow",
32
+ Severity.LOW: "bold green",
33
+ }
34
+
35
+ _KIND_LABEL = {
36
+ FindingKind.RETRY_LOOP: "RETRY LOOP",
37
+ FindingKind.SCOPE_CREEP: "SCOPE CREEP",
38
+ FindingKind.STALE_CONTEXT: "STALE CONTEXT",
39
+ }
40
+
41
+
42
+ def _default_console() -> Console:
43
+ return Console()
44
+
45
+
46
+ def render_diagnosis(
47
+ diagnosis: Diagnosis,
48
+ *,
49
+ session_path: Path | None = None,
50
+ console: Console | None = None,
51
+ ) -> None:
52
+ con = console or _default_console()
53
+
54
+ # Header
55
+ con.print(Rule(f"cctx autopsy — session {diagnosis.session_id}"))
56
+ cost_line = f"Session cost: ${diagnosis.total_cost_usd:.2f}"
57
+ if diagnosis.waste_cost_usd > 0:
58
+ pct = (
59
+ diagnosis.waste_cost_usd / diagnosis.total_cost_usd * 100
60
+ if diagnosis.total_cost_usd
61
+ else 0
62
+ )
63
+ cost_line += f" | Attributed waste: ${diagnosis.waste_cost_usd:.2f} ({pct:.0f}%)"
64
+ con.print(cost_line)
65
+
66
+ if not diagnosis.findings:
67
+ con.print("\nNo findings — session looks clean.")
68
+ return
69
+
70
+ con.print(f"Inflection turn: {diagnosis.inflection_turn}")
71
+ con.print()
72
+
73
+ # Findings
74
+ for finding in diagnosis.findings:
75
+ style = _SEVERITY_STYLE.get(finding.severity, "")
76
+ label = _KIND_LABEL.get(finding.kind, finding.kind.value.upper())
77
+ badge = Text(f" {label} ", style=style)
78
+ conf_note = f"({finding.confidence.value} confidence)"
79
+ con.print(badge, conf_note, "—", finding.summary)
80
+
81
+ # Patches
82
+ if diagnosis.patches:
83
+ con.print()
84
+ con.print(Rule("CLAUDE.md patches"))
85
+ for patch in diagnosis.patches:
86
+ con.print(f"\n{patch.description} [{patch.target_file}]")
87
+ con.print(f"Evidence: {patch.evidence_summary}")
88
+ syntax = Syntax(patch.unified_diff, "diff", theme="monokai", word_wrap=True)
89
+ con.print(syntax)
90
+
91
+ if session_path is not None:
92
+ con.print()
93
+ con.print(
94
+ Text("cctx trace ", style="bold")
95
+ + Text(str(session_path), style="dim")
96
+ + Text(" to step through this session interactively", style="dim")
97
+ )
98
+
99
+
100
+ def render_aggregate(report: AggregateReport, *, console: Console | None = None) -> None:
101
+ con = console or _default_console()
102
+
103
+ days = int(report.window.total_seconds() / 86400)
104
+ con.print(Rule(f"cctx autopsy — last {days} days"))
105
+ con.print(
106
+ f"Sessions: {report.sessions_analysed} analysed, "
107
+ f"{report.sessions_with_findings} with findings"
108
+ )
109
+ con.print(
110
+ f"Total cost: ${report.total_cost_usd:.2f} | "
111
+ f"Waste: ${report.waste_cost_usd:.2f}"
112
+ )
113
+
114
+ if not report.by_kind:
115
+ con.print("\nNo findings across sessions.")
116
+ return
117
+
118
+ # Summary table
119
+ table = Table(title="Finding frequency")
120
+ table.add_column("Pattern")
121
+ table.add_column("Sessions", justify="right")
122
+ table.add_column("Waste ($)", justify="right")
123
+ for kind, ev in report.by_kind.items():
124
+ table.add_row(
125
+ _KIND_LABEL.get(kind, kind.value),
126
+ str(ev.session_count),
127
+ f"${ev.total_waste_usd:.2f}",
128
+ )
129
+ con.print(table)
130
+
131
+ # Patches
132
+ if report.patches:
133
+ con.print(Rule("Recommended CLAUDE.md patches"))
134
+ for patch in report.patches:
135
+ con.print(f"\n{patch.description}")
136
+ syntax = Syntax(patch.unified_diff, "diff", theme="monokai", word_wrap=True)
137
+ con.print(syntax)
138
+
139
+
140
+ def render_harvest_results(
141
+ results: list, # list[ApplyResult] — ApplyStatus is harvest-internal; imported lazily
142
+ *,
143
+ dry_run: bool = False,
144
+ console: Console | None = None,
145
+ ) -> None:
146
+ """Render harvest ApplyResult list to terminal."""
147
+ from cctx.harvest import ApplyStatus
148
+
149
+ con = console or _default_console()
150
+
151
+ if not results:
152
+ con.print("No patches to apply. Session looks clean.")
153
+ return
154
+
155
+ total = len(results)
156
+ for i, result in enumerate(results, start=1):
157
+ if result.status == ApplyStatus.SKIPPED:
158
+ con.print(
159
+ Text(
160
+ f"already present ({result.message.removeprefix('already present: ')}) "
161
+ f"— skipping",
162
+ style="dim",
163
+ )
164
+ )
165
+ continue
166
+
167
+ if result.status == ApplyStatus.APPLIED:
168
+ title_style = "green"
169
+ elif result.status == ApplyStatus.ERROR:
170
+ title_style = "red"
171
+ else:
172
+ title_style = "dim"
173
+ title = f"Patch {i} of {total} — {result.patch.finding_kind.value}"
174
+ syntax = Syntax(result.patch.unified_diff, "diff", theme="monokai", word_wrap=True)
175
+ panel = Panel(
176
+ syntax,
177
+ title=title,
178
+ title_align="left",
179
+ border_style=title_style,
180
+ subtitle=Text(result.patch.evidence_summary, style="dim"),
181
+ subtitle_align="left",
182
+ )
183
+ con.print(panel)
184
+
185
+ applied_count = sum(1 for r in results if r.status == ApplyStatus.APPLIED)
186
+ if dry_run:
187
+ con.print("Dry run complete. No changes made.")
188
+ else:
189
+ con.print(f"Applied {applied_count} patch(es).")
190
+
191
+
192
+ def render_projects(projects: list[ProjectInfo], *, console: Console | None = None) -> None:
193
+ con = console or _default_console()
194
+
195
+ if not projects:
196
+ con.print("No projects found in ~/.claude/projects/.")
197
+ return
198
+
199
+ con.print(Rule("cctx — projects"))
200
+ table = Table(show_header=True, box=None, pad_edge=False, show_edge=False)
201
+ table.add_column("Project", style="bold")
202
+ table.add_column("Sessions", justify="right", style="dim")
203
+ table.add_column("Last session", style="dim")
204
+
205
+ for proj in projects:
206
+ last = proj.latest_time.strftime("%Y-%m-%d") if proj.latest_time else "—"
207
+ table.add_row(
208
+ proj.display_name,
209
+ str(proj.session_count),
210
+ last,
211
+ )
212
+ con.print(table)
213
+ con.print()
214
+ con.print(
215
+ Text("cctx ls <project-path>", style="bold") +
216
+ Text(" to list sessions in a project", style="dim")
217
+ )
218
+ con.print(
219
+ Text("cctx autopsy --latest <project-path>", style="bold") +
220
+ Text(" to diagnose the most recent session", style="dim")
221
+ )
222
+
223
+
224
+ def render_sessions(project: ProjectInfo, *, console: Console | None = None) -> None:
225
+ con = console or _default_console()
226
+
227
+ con.print(Rule(f"cctx — {project.display_name}"))
228
+ if not project.sessions:
229
+ con.print("No sessions found.")
230
+ return
231
+
232
+ table = Table(show_header=True, box=None, pad_edge=False, show_edge=False)
233
+ table.add_column("Session", style="bold")
234
+ table.add_column("Date", style="dim")
235
+ table.add_column("Branch", style="dim")
236
+ table.add_column("Path", style="dim")
237
+
238
+ for s in project.sessions:
239
+ date_str = s.start_time.strftime("%Y-%m-%d %H:%M") if s.start_time else "—"
240
+ table.add_row(
241
+ s.session_id[:8],
242
+ date_str,
243
+ s.git_branch or "—",
244
+ str(s.path),
245
+ )
246
+ con.print(table)
247
+ con.print()
248
+ con.print(
249
+ Text("cctx autopsy <path>", style="bold") +
250
+ Text(" to diagnose a session", style="dim")
251
+ )
@@ -0,0 +1,291 @@
1
+ """Textual TUI for trace visualization with autopsy overlay.
2
+
3
+ Public API:
4
+ affected_turns(finding, trace) -> frozenset[int]
5
+ verdict(diagnosis) -> str
6
+ launch(trace, diagnosis) -> None
7
+
8
+ Internal:
9
+ _build_flagged_index(findings, trace) -> dict[int, list[Finding]]
10
+
11
+ Textual is only imported inside launch() so that the pure helpers remain
12
+ importable without the textual package (e.g. during testing).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import TYPE_CHECKING
18
+
19
+ from cctx.models import FindingKind
20
+
21
+ if TYPE_CHECKING:
22
+ from cctx.models import Diagnosis, Finding, SessionTrace, Turn
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Pure helpers — no Textual dependency
27
+ # ---------------------------------------------------------------------------
28
+
29
+
30
+ def affected_turns(finding: Finding, trace: SessionTrace) -> frozenset[int]:
31
+ """Return turn numbers covered by a finding using per-kind evidence extraction."""
32
+ ev = finding.evidence or {}
33
+ kind = finding.kind
34
+
35
+ if kind is FindingKind.RETRY_LOOP:
36
+ turns: set[int] = {occ["turn"] for occ in ev.get("occurrences", []) if "turn" in occ}
37
+ elif kind is FindingKind.SCOPE_CREEP:
38
+ turns = {ph["turn"] for ph in ev.get("phrases", []) if "turn" in ph}
39
+ elif kind is FindingKind.STALE_CONTEXT:
40
+ last = finding.last_turn if finding.last_turn is not None else finding.first_turn
41
+ turns = set()
42
+ for item in ev.get("stale_items", []):
43
+ if "last_referenced_turn" in item:
44
+ turns.update(range(item["last_referenced_turn"] + 1, last + 1))
45
+ else:
46
+ last = finding.last_turn if finding.last_turn is not None else finding.first_turn
47
+ turns = set(range(finding.first_turn, last + 1))
48
+
49
+ return frozenset(turns) if turns else frozenset({finding.first_turn})
50
+
51
+
52
+ def verdict(diagnosis: Diagnosis) -> str:
53
+ """One-line human summary: 'Clean session' or '{n} finding(s) · ${waste:.2f} waste'."""
54
+ if not diagnosis.findings:
55
+ return "Clean session"
56
+ n = len(diagnosis.findings)
57
+ label = "finding" if n == 1 else "findings"
58
+ return f"{n} {label} · ${diagnosis.waste_cost_usd:.2f} waste"
59
+
60
+
61
+ def _build_flagged_index(findings: list[Finding], trace: SessionTrace) -> dict[int, list[Finding]]:
62
+ """Map turn_number -> list of findings that affect that turn."""
63
+ index: dict[int, list[Finding]] = {}
64
+ for finding in findings:
65
+ for tn in affected_turns(finding, trace):
66
+ index.setdefault(tn, []).append(finding)
67
+ return index
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Entry point — Textual imports deferred here
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ def launch(trace: SessionTrace, diagnosis: Diagnosis) -> None: # noqa: C901
76
+ """Build and run the Textual app (blocking). Imports textual on first call."""
77
+ from textual.app import App, ComposeResult
78
+ from textual.binding import Binding
79
+ from textual.containers import ScrollableContainer
80
+ from textual.screen import ModalScreen
81
+ from textual.widgets import DataTable, Footer, Header, Label, Static
82
+
83
+ flagged = _build_flagged_index(diagnosis.findings, trace)
84
+ session_verdict = verdict(diagnosis)
85
+
86
+ class FindingModal(ModalScreen): # type: ignore[type-arg]
87
+ """Finding details for the selected turn."""
88
+
89
+ DEFAULT_CSS = """
90
+ FindingModal {
91
+ align: center middle;
92
+ width: 80%;
93
+ height: 80%;
94
+ }
95
+ FindingModal > ScrollableContainer {
96
+ background: $surface;
97
+ border: solid $accent;
98
+ padding: 1 2;
99
+ }
100
+ """
101
+
102
+ BINDINGS = [
103
+ Binding("escape", "dismiss", "Close"),
104
+ Binding("q", "dismiss", "Close"),
105
+ ]
106
+
107
+ def __init__(self, findings: list[Finding]) -> None:
108
+ super().__init__()
109
+ self._findings = findings
110
+
111
+ def compose(self) -> ComposeResult:
112
+ lines: list[str] = []
113
+ for f in self._findings:
114
+ lines.append(
115
+ f"[bold]{f.kind.value}[/] severity={f.severity.value}"
116
+ f" confidence={f.confidence.value}"
117
+ )
118
+ lines.append(f" {f.summary}")
119
+ if f.cost_usd is not None:
120
+ lines.append(f" cost: ${f.cost_usd:.4f}")
121
+ lines.append("")
122
+ text = "\n".join(lines).rstrip() or "No findings."
123
+ with ScrollableContainer():
124
+ yield Label(text, markup=True)
125
+
126
+ class ToolResultModal(ModalScreen): # type: ignore[type-arg]
127
+ """Turn content and tool results for the selected turn."""
128
+
129
+ DEFAULT_CSS = """
130
+ ToolResultModal {
131
+ align: center middle;
132
+ width: 80%;
133
+ height: 80%;
134
+ }
135
+ ToolResultModal > ScrollableContainer {
136
+ background: $surface;
137
+ border: solid $accent;
138
+ padding: 1 2;
139
+ }
140
+ """
141
+
142
+ BINDINGS = [
143
+ Binding("escape", "dismiss", "Close"),
144
+ Binding("q", "dismiss", "Close"),
145
+ ]
146
+
147
+ def __init__(self, selected_turn: Turn) -> None:
148
+ super().__init__()
149
+ self._turn = selected_turn
150
+
151
+ def compose(self) -> ComposeResult:
152
+ t = self._turn
153
+ lines: list[str] = [f"[bold]Turn {t.turn_number}[/] — {t.role}", ""]
154
+ if t.text:
155
+ preview = t.text[:2000]
156
+ if len(t.text) > 2000:
157
+ preview += "\n[dim]…truncated[/]"
158
+ lines.append(preview)
159
+ lines.append("")
160
+ for tr in t.tool_results:
161
+ lines.append(f"[bold]{tr.tool_name}[/] ({tr.tool_use_id})")
162
+ content_preview = tr.content[:1000]
163
+ if len(tr.content) > 1000:
164
+ content_preview += "\n[dim]…truncated[/]"
165
+ lines.append(content_preview)
166
+ lines.append("")
167
+ if not t.text and not t.tool_results:
168
+ lines.append("[dim](no content)[/]")
169
+ with ScrollableContainer():
170
+ yield Label("\n".join(lines), markup=True)
171
+
172
+ class HelpScreen(ModalScreen): # type: ignore[type-arg]
173
+ """Keyboard shortcut reference."""
174
+
175
+ DEFAULT_CSS = """
176
+ HelpScreen {
177
+ align: center middle;
178
+ width: 60%;
179
+ height: 60%;
180
+ }
181
+ HelpScreen > ScrollableContainer {
182
+ background: $surface;
183
+ border: solid $accent;
184
+ padding: 1 2;
185
+ }
186
+ """
187
+
188
+ BINDINGS = [
189
+ Binding("escape", "dismiss", "Close"),
190
+ Binding("q", "dismiss", "Close"),
191
+ Binding("question_mark", "dismiss", "Close"),
192
+ ]
193
+
194
+ def compose(self) -> ComposeResult:
195
+ help_text = (
196
+ "[bold]cctx trace — keyboard shortcuts[/]\n\n"
197
+ " [bold]↑ / ↓[/] Navigate turns\n"
198
+ " [bold]Enter[/] Show turn content / tool results\n"
199
+ " [bold]f[/] Show finding details (flagged turns only)\n"
200
+ " [bold]?[/] Toggle this help screen\n"
201
+ " [bold]q[/] Quit\n"
202
+ )
203
+ with ScrollableContainer():
204
+ yield Label(help_text, markup=True)
205
+
206
+ class TraceTUI(App): # type: ignore[type-arg]
207
+ """Interactive turn-by-turn trace viewer with autopsy overlay."""
208
+
209
+ DEFAULT_CSS = """
210
+ DataTable { height: 1fr; }
211
+ #verdict { height: 1; background: $accent; color: $text; padding: 0 1; }
212
+ """
213
+
214
+ BINDINGS = [
215
+ Binding("q", "quit", "Quit"),
216
+ Binding("f", "show_finding", "Finding"),
217
+ Binding("question_mark", "show_help", "Help"),
218
+ ]
219
+
220
+ def __init__(self) -> None:
221
+ super().__init__()
222
+ self._turn_numbers: list[int] = []
223
+
224
+ def compose(self) -> ComposeResult:
225
+ yield Header()
226
+ yield DataTable(id="turns", cursor_type="row")
227
+ yield Static(session_verdict, id="verdict")
228
+ yield Footer()
229
+
230
+ def on_mount(self) -> None:
231
+ table = self.query_one("#turns", DataTable)
232
+ table.add_column("#", width=6)
233
+ table.add_column("Role", width=14)
234
+ table.add_column("Model", width=22)
235
+ table.add_column("Tokens", width=10)
236
+ table.add_column("Flags", width=22)
237
+
238
+ for t in trace.turns:
239
+ is_flagged = t.turn_number in flagged
240
+ findings = flagged.get(t.turn_number, [])
241
+ tokens = (
242
+ f"{t.usage.input_tokens + t.usage.cache_creation_5m + t.usage.cache_creation_1h + t.usage.cache_read:,}"
243
+ if t.usage
244
+ else ""
245
+ )
246
+ model = t.model or ""
247
+ flags = ", ".join(f.kind.value for f in findings)
248
+
249
+ if is_flagged:
250
+ cells = [
251
+ f"[bold red]{t.turn_number}[/]",
252
+ f"[bold red]{t.role}[/]",
253
+ f"[red]{model}[/]",
254
+ f"[red]{tokens}[/]",
255
+ f"[bold red]{flags}[/]",
256
+ ]
257
+ else:
258
+ cells = [str(t.turn_number), t.role, model, tokens, flags]
259
+
260
+ table.add_row(*cells)
261
+ self._turn_numbers.append(t.turn_number)
262
+
263
+ def _current_turn_number(self) -> int | None:
264
+ table = self.query_one("#turns", DataTable)
265
+ idx = table.cursor_row
266
+ if idx < 0 or idx >= len(self._turn_numbers):
267
+ return None
268
+ return self._turn_numbers[idx]
269
+
270
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
271
+ idx = event.cursor_row
272
+ if idx < 0 or idx >= len(self._turn_numbers):
273
+ return
274
+ turn_number = self._turn_numbers[idx]
275
+ selected = next((t for t in trace.turns if t.turn_number == turn_number), None)
276
+ if selected is not None:
277
+ self.push_screen(ToolResultModal(selected))
278
+
279
+ def action_show_finding(self) -> None:
280
+ tn = self._current_turn_number()
281
+ if tn is None:
282
+ return
283
+ findings = flagged.get(tn)
284
+ if not findings:
285
+ return
286
+ self.push_screen(FindingModal(findings))
287
+
288
+ def action_show_help(self) -> None:
289
+ self.push_screen(HelpScreen())
290
+
291
+ TraceTUI().run()
cctx/tokenizer.py ADDED
@@ -0,0 +1,77 @@
1
+ """cctx tokenizer.
2
+
3
+ The ONLY module in cctx allowed to import `anthropic`. Wraps
4
+ anthropic.messages.count_tokens() and walks a parsed SessionTrace,
5
+ populating token_count fields on Turns, ToolUses, and ToolResults.
6
+
7
+ Honors CCTX_OFFLINE=1 to skip live API calls and use a len(text)//4
8
+ heuristic instead — useful for CI, air-gapped environments, or quick
9
+ relative-proportion estimates.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+
17
+ from cctx.models import SessionTrace, Turn
18
+
19
+
20
+ def tokenize_session(trace: SessionTrace) -> SessionTrace:
21
+ """Walk a SessionTrace and populate token_count fields in place. Returns the same trace."""
22
+ counter = _build_counter()
23
+ _tokenize_trace_recursively(trace, counter)
24
+ return trace
25
+
26
+
27
+ def _build_counter():
28
+ """Return a callable str -> int. Heuristic in offline mode; live API otherwise."""
29
+ if os.environ.get("CCTX_OFFLINE") == "1":
30
+ return _heuristic_token_count
31
+ return _make_live_counter()
32
+
33
+
34
+ def _heuristic_token_count(text: str) -> int:
35
+ return len(text) // 4
36
+
37
+
38
+ def _make_live_counter():
39
+ api_key = os.environ.get("ANTHROPIC_API_KEY")
40
+ if not api_key:
41
+ return _heuristic_token_count
42
+ import anthropic # lazy: offline mode never loads the SDK
43
+
44
+ client = anthropic.Anthropic(api_key=api_key)
45
+ cache: dict[str, int] = {}
46
+
47
+ def count(text: str) -> int:
48
+ if text in cache:
49
+ return cache[text]
50
+ result = client.messages.count_tokens(
51
+ model="claude-sonnet-4-5",
52
+ messages=[{"role": "user", "content": text or " "}],
53
+ )
54
+ n = result.input_tokens
55
+ cache[text] = n
56
+ return n
57
+
58
+ return count
59
+
60
+
61
+ def _tokenize_trace_recursively(trace: SessionTrace, counter) -> None:
62
+ for turn in trace.turns:
63
+ _tokenize_turn(turn, counter)
64
+ for child in trace.subagents:
65
+ _tokenize_trace_recursively(child, counter)
66
+
67
+
68
+ def _tokenize_turn(turn: Turn, counter) -> None:
69
+ narrative = ((turn.text or "") + ("\n" + turn.thinking if turn.thinking else "")).strip()
70
+ if narrative:
71
+ turn.token_count = counter(narrative)
72
+
73
+ for use in turn.tool_uses:
74
+ use.token_count = counter(json.dumps(use.tool_input, sort_keys=True))
75
+
76
+ for result in turn.tool_results:
77
+ result.token_count = counter(result.content or "")