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.
- cctx/__init__.py +3 -0
- cctx/cli.py +375 -0
- cctx/diagnostician/__init__.py +81 -0
- cctx/diagnostician/aggregate.py +40 -0
- cctx/diagnostician/inflection.py +19 -0
- cctx/diagnostician/patterns/__init__.py +1 -0
- cctx/diagnostician/patterns/retry_loop.py +145 -0
- cctx/diagnostician/patterns/scope_creep.py +87 -0
- cctx/diagnostician/patterns/stale_context.py +147 -0
- cctx/discovery.py +185 -0
- cctx/exporters/__init__.py +0 -0
- cctx/exporters/csv.py +64 -0
- cctx/exporters/jsonl.py +64 -0
- cctx/harvest.py +173 -0
- cctx/models.py +269 -0
- cctx/parsers/__init__.py +1 -0
- cctx/parsers/claude_code.py +690 -0
- cctx/pricing.py +18 -0
- cctx/recommender/__init__.py +0 -0
- cctx/recommender/claude_md.py +131 -0
- cctx/recommender/evidence.py +46 -0
- cctx/renderers/__init__.py +0 -0
- cctx/renderers/report.py +58 -0
- cctx/renderers/templates/autopsy.html.j2 +249 -0
- cctx/renderers/terminal.py +251 -0
- cctx/renderers/trace_tui.py +291 -0
- cctx/tokenizer.py +77 -0
- cctx_cli-0.1.0.dist-info/METADATA +159 -0
- cctx_cli-0.1.0.dist-info/RECORD +31 -0
- cctx_cli-0.1.0.dist-info/WHEEL +4 -0
- cctx_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -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 "")
|