claude-code-tracker 1.4.0-beta.3 → 1.4.0-beta.4
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.
- package/README.md +5 -0
- package/package.json +1 -1
- package/src/__pycache__/cost.cpython-312.pyc +0 -0
- package/src/__pycache__/parse_compactions.cpython-312.pyc +0 -0
- package/src/__pycache__/parse_friction.cpython-312.pyc +0 -0
- package/src/__pycache__/parse_skills.cpython-312.pyc +0 -0
- package/src/__pycache__/platform_utils.cpython-312.pyc +0 -0
- package/src/__pycache__/storage.cpython-312.pyc +0 -0
- package/src/__pycache__/write-agent.cpython-312.pyc +0 -0
- package/src/__pycache__/write-turns.cpython-312.pyc +0 -0
- package/src/backfill.py +19 -4
- package/src/cost-summary.py +32 -23
- package/src/generate-charts.py +135 -11
- package/src/parse_compactions.py +112 -0
- package/src/parse_friction.py +16 -25
- package/src/stop-hook.sh +5 -1
- package/src/storage.py +142 -1
package/README.md
CHANGED
|
@@ -15,6 +15,11 @@ After every session, it parses the transcript, updates a `tokens.json` ledger, r
|
|
|
15
15
|
- **Prompt efficiency**: ratio of key prompts to total human messages
|
|
16
16
|
- **Per-agent cost breakdown**: SubagentStop hook captures each spawned agent's token usage
|
|
17
17
|
separately — see which agent types (architect, quick-fixer, Explore, etc.) drive the most cost
|
|
18
|
+
- **Context compaction tracking**: counts and metadata for every context compaction event —
|
|
19
|
+
auto/manual trigger, pre-compaction token count, session distribution
|
|
20
|
+
- **Skill invocation tracking**: per-skill success/error rates, duration, and timeline
|
|
21
|
+
- **Friction event tracking**: tool errors, permission denials, corrections, retries — with
|
|
22
|
+
category breakdown, rate trends, and error classification
|
|
18
23
|
|
|
19
24
|
All data lives in `<project>/.claude/tracking/` alongside your code.
|
|
20
25
|
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/src/backfill.py
CHANGED
|
@@ -189,15 +189,14 @@ total_turns = len(new_entries)
|
|
|
189
189
|
print(f"{sessions_processed} session{'s' if sessions_processed != 1 else ''} processed, {total_turns} turn{'s' if total_turns != 1 else ''} written.")
|
|
190
190
|
|
|
191
191
|
# Backfill friction events from the same transcripts
|
|
192
|
-
from parse_friction import parse_friction
|
|
192
|
+
from parse_friction import parse_friction
|
|
193
193
|
|
|
194
|
-
friction_file = os.path.join(tracking_dir, "friction.json")
|
|
195
194
|
friction_count = 0
|
|
196
195
|
for jf in jsonl_files:
|
|
197
196
|
session_id = os.path.splitext(os.path.basename(jf))[0]
|
|
198
197
|
try:
|
|
199
198
|
events = parse_friction(jf, session_id, project_name, "main")
|
|
200
|
-
|
|
199
|
+
storage.replace_session_friction(tracking_dir, session_id, events)
|
|
201
200
|
friction_count += len(events)
|
|
202
201
|
except Exception:
|
|
203
202
|
pass
|
|
@@ -205,8 +204,24 @@ for jf in jsonl_files:
|
|
|
205
204
|
if friction_count:
|
|
206
205
|
print(f"{friction_count} friction event{'s' if friction_count != 1 else ''} backfilled.")
|
|
207
206
|
|
|
207
|
+
# Backfill compaction events from the same transcripts
|
|
208
|
+
from parse_compactions import parse_compactions as parse_compact_events
|
|
209
|
+
|
|
210
|
+
compaction_count = 0
|
|
211
|
+
for jf in jsonl_files:
|
|
212
|
+
session_id = os.path.splitext(os.path.basename(jf))[0]
|
|
213
|
+
try:
|
|
214
|
+
events = parse_compact_events(jf, session_id, project_name)
|
|
215
|
+
storage.replace_session_compactions(tracking_dir, session_id, events)
|
|
216
|
+
compaction_count += len(events)
|
|
217
|
+
except Exception:
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
if compaction_count:
|
|
221
|
+
print(f"{compaction_count} compaction event{'s' if compaction_count != 1 else ''} backfilled.")
|
|
222
|
+
|
|
208
223
|
# Regenerate charts if we added anything
|
|
209
|
-
if new_entries or friction_count:
|
|
224
|
+
if new_entries or friction_count or compaction_count:
|
|
210
225
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
211
226
|
charts_html = os.path.join(tracking_dir, "charts.html")
|
|
212
227
|
os.system(f'python3 "{script_dir}/generate-charts.py" "{tracking_dir}" "{charts_html}" 2>/dev/null')
|
package/src/cost-summary.py
CHANGED
|
@@ -138,28 +138,37 @@ if days > 1:
|
|
|
138
138
|
print(f"\n Avg cost/day: ${total_cost/days:>11.2f} over {days} days")
|
|
139
139
|
|
|
140
140
|
# --- Friction summary ---
|
|
141
|
-
|
|
142
|
-
if
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
141
|
+
friction_data = storage.get_all_friction(tracking_dir)
|
|
142
|
+
if friction_data:
|
|
143
|
+
print(f"\nFriction:")
|
|
144
|
+
friction_total = len(friction_data)
|
|
145
|
+
cat_counts = defaultdict(int)
|
|
146
|
+
tool_counts = defaultdict(int)
|
|
147
|
+
for fe in friction_data:
|
|
148
|
+
cat_counts[fe.get('category', 'unknown')] += 1
|
|
149
|
+
tn = fe.get('tool_name')
|
|
150
|
+
if tn:
|
|
151
|
+
tool_counts[tn] += 1
|
|
152
|
+
top_cat = max(cat_counts, key=cat_counts.get)
|
|
153
|
+
print(f" Total events: {friction_total:>8}")
|
|
154
|
+
print(f" Top category: {top_cat:>8} ({cat_counts[top_cat]} events)")
|
|
155
|
+
if tool_counts:
|
|
156
|
+
top_tool = max(tool_counts, key=tool_counts.get)
|
|
157
|
+
print(f" Top tool: {top_tool:>8} ({tool_counts[top_tool]} events)")
|
|
158
|
+
|
|
159
|
+
# --- Compaction summary ---
|
|
160
|
+
compaction_data = storage.get_all_compactions(tracking_dir)
|
|
161
|
+
if compaction_data:
|
|
162
|
+
total_compactions = len(compaction_data)
|
|
163
|
+
auto_count = sum(1 for c in compaction_data if c.get('trigger', 'auto') == 'auto')
|
|
164
|
+
manual_count = total_compactions - auto_count
|
|
165
|
+
avg_pre = round(sum(c.get('pre_tokens', 0) for c in compaction_data) / total_compactions) if total_compactions > 0 else 0
|
|
166
|
+
sessions_compacted = len({c.get('session_id') for c in compaction_data})
|
|
167
|
+
pct_compacted = round(sessions_compacted / total_sessions * 100, 1) if total_sessions > 0 else 0
|
|
168
|
+
print(f"\nCompaction:")
|
|
169
|
+
print(f" Total events: {total_compactions:>8}")
|
|
170
|
+
print(f" Auto / manual: {auto_count:>5} / {manual_count}")
|
|
171
|
+
print(f" Avg pre-tokens: {avg_pre:>12,}")
|
|
172
|
+
print(f" Sessions affected: {pct_compacted:>9.1f}% ({sessions_compacted} of {total_sessions})")
|
|
164
173
|
|
|
165
174
|
print("=" * W)
|
package/src/generate-charts.py
CHANGED
|
@@ -25,15 +25,8 @@ data = storage.get_all_turns(tracking_dir)
|
|
|
25
25
|
agent_data = storage.get_all_agents(tracking_dir)
|
|
26
26
|
skill_data = storage.get_all_skills(tracking_dir)
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
friction_data = []
|
|
31
|
-
if os.path.exists(friction_file):
|
|
32
|
-
try:
|
|
33
|
-
with open(friction_file, encoding='utf-8') as f:
|
|
34
|
-
friction_data = json.load(f)
|
|
35
|
-
except:
|
|
36
|
-
pass
|
|
28
|
+
friction_data = storage.get_all_friction(tracking_dir)
|
|
29
|
+
compaction_data = storage.get_all_compactions(tracking_dir)
|
|
37
30
|
|
|
38
31
|
def format_duration(seconds):
|
|
39
32
|
if seconds <= 0:
|
|
@@ -523,7 +516,7 @@ has_skill_data = bool(friction_by_skill)
|
|
|
523
516
|
|
|
524
517
|
retry_events = [fe for fe in friction_data if fe.get('category') == 'retry']
|
|
525
518
|
retry_total = len(retry_events)
|
|
526
|
-
retry_resolved = sum(1 for fe in retry_events if fe.get('resolved')
|
|
519
|
+
retry_resolved = sum(1 for fe in retry_events if fe.get('resolved'))
|
|
527
520
|
retry_rate = round(retry_resolved / retry_total * 100, 1) if retry_total > 0 else 0
|
|
528
521
|
|
|
529
522
|
total_friction = len(friction_data)
|
|
@@ -793,6 +786,133 @@ else:
|
|
|
793
786
|
error_js_constants = ''
|
|
794
787
|
error_js_charts = ''
|
|
795
788
|
|
|
789
|
+
# --- Compaction data aggregation ---
|
|
790
|
+
compaction_by_date = defaultdict(lambda: {"auto": 0, "manual": 0})
|
|
791
|
+
compaction_tokens = []
|
|
792
|
+
compaction_per_session = defaultdict(int)
|
|
793
|
+
for ce in compaction_data:
|
|
794
|
+
d = ce.get('date', 'unknown')
|
|
795
|
+
trig = ce.get('trigger', 'auto')
|
|
796
|
+
if trig == 'manual':
|
|
797
|
+
compaction_by_date[d]["manual"] += 1
|
|
798
|
+
else:
|
|
799
|
+
compaction_by_date[d]["auto"] += 1
|
|
800
|
+
pre_t = ce.get('pre_tokens', 0)
|
|
801
|
+
if pre_t > 0:
|
|
802
|
+
compaction_tokens.append({"x": ce.get('timestamp', '')[:10] if ce.get('timestamp') else 'unknown', "y": pre_t})
|
|
803
|
+
compaction_per_session[ce.get('session_id', '')] += 1
|
|
804
|
+
|
|
805
|
+
total_compactions = len(compaction_data)
|
|
806
|
+
total_auto = sum(1 for c in compaction_data if c.get('trigger', 'auto') == 'auto')
|
|
807
|
+
total_manual = total_compactions - total_auto
|
|
808
|
+
avg_pre_tokens = round(sum(c.get('pre_tokens', 0) for c in compaction_data) / total_compactions) if total_compactions > 0 else 0
|
|
809
|
+
sessions_with_compaction = len([s for s in compaction_per_session if compaction_per_session[s] > 0])
|
|
810
|
+
pct_sessions_compacted = round(sessions_with_compaction / total_sessions * 100, 1) if total_sessions > 0 else 0
|
|
811
|
+
avg_per_session = round(total_compactions / total_sessions, 1) if total_sessions > 0 else 0
|
|
812
|
+
|
|
813
|
+
compact_dates_sorted = sorted(compaction_by_date.keys())
|
|
814
|
+
compact_dates_js = json.dumps(compact_dates_sorted)
|
|
815
|
+
compact_auto_js = json.dumps([compaction_by_date[d]["auto"] for d in compact_dates_sorted])
|
|
816
|
+
compact_manual_js = json.dumps([compaction_by_date[d]["manual"] for d in compact_dates_sorted])
|
|
817
|
+
compact_scatter_js = json.dumps(compaction_tokens)
|
|
818
|
+
|
|
819
|
+
compact_dist = {"0": 0, "1": 0, "2": 0, "3+": 0}
|
|
820
|
+
all_session_ids = {e.get("session_id") for e in data}
|
|
821
|
+
for sid in all_session_ids:
|
|
822
|
+
cnt = compaction_per_session.get(sid, 0)
|
|
823
|
+
if cnt == 0:
|
|
824
|
+
compact_dist["0"] += 1
|
|
825
|
+
elif cnt == 1:
|
|
826
|
+
compact_dist["1"] += 1
|
|
827
|
+
elif cnt == 2:
|
|
828
|
+
compact_dist["2"] += 1
|
|
829
|
+
else:
|
|
830
|
+
compact_dist["3+"] += 1
|
|
831
|
+
compact_dist_labels_js = json.dumps(list(compact_dist.keys()))
|
|
832
|
+
compact_dist_values_js = json.dumps(list(compact_dist.values()))
|
|
833
|
+
|
|
834
|
+
if compaction_data:
|
|
835
|
+
compaction_stat_html = f''' <div class="stat">
|
|
836
|
+
<div class="stat-label">Compactions</div>
|
|
837
|
+
<div class="stat-value">{total_compactions}</div>
|
|
838
|
+
<div class="stat-sub">{avg_per_session}/session, {pct_sessions_compacted}% of sessions</div>
|
|
839
|
+
</div>'''
|
|
840
|
+
compaction_section_html = f'''<div class="section">
|
|
841
|
+
<div class="section-header compaction">Context Compactions</div>
|
|
842
|
+
<div class="grid">
|
|
843
|
+
<div class="card">
|
|
844
|
+
<h2>Compactions per day</h2>
|
|
845
|
+
<canvas id="compactDay"></canvas>
|
|
846
|
+
</div>
|
|
847
|
+
<div class="card">
|
|
848
|
+
<h2>Pre-compaction token count</h2>
|
|
849
|
+
<canvas id="compactTokens"></canvas>
|
|
850
|
+
</div>
|
|
851
|
+
<div class="card">
|
|
852
|
+
<h2>Compactions per session</h2>
|
|
853
|
+
<canvas id="compactDist"></canvas>
|
|
854
|
+
</div>
|
|
855
|
+
</div>
|
|
856
|
+
</div>
|
|
857
|
+
|
|
858
|
+
'''
|
|
859
|
+
compaction_js_constants = f'''const COMPACT_DATES = {compact_dates_js};
|
|
860
|
+
const COMPACT_AUTO = {compact_auto_js};
|
|
861
|
+
const COMPACT_MANUAL = {compact_manual_js};
|
|
862
|
+
const COMPACT_SCATTER = {compact_scatter_js};
|
|
863
|
+
const COMPACT_DIST_LABELS = {compact_dist_labels_js};
|
|
864
|
+
const COMPACT_DIST_VALUES = {compact_dist_values_js};'''
|
|
865
|
+
compaction_js_charts = '''
|
|
866
|
+
// Compactions per day (stacked bar)
|
|
867
|
+
new Chart(document.getElementById('compactDay'), {
|
|
868
|
+
type: 'bar',
|
|
869
|
+
data: {
|
|
870
|
+
labels: COMPACT_DATES,
|
|
871
|
+
datasets: [
|
|
872
|
+
{ label: 'Auto', data: COMPACT_AUTO, backgroundColor: '#8b5cf6', borderRadius: 2 },
|
|
873
|
+
{ label: 'Manual', data: COMPACT_MANUAL, backgroundColor: '#c084fc', borderRadius: 2 }
|
|
874
|
+
]
|
|
875
|
+
},
|
|
876
|
+
options: { ...baseOpts, scales: { ...baseOpts.scales,
|
|
877
|
+
x: { ...baseOpts.scales.x, stacked: true },
|
|
878
|
+
y: { ...baseOpts.scales.y, stacked: true } } }
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
// Pre-compaction token count (scatter)
|
|
882
|
+
new Chart(document.getElementById('compactTokens'), {
|
|
883
|
+
type: 'scatter',
|
|
884
|
+
data: {
|
|
885
|
+
datasets: [{ label: 'Pre-tokens', data: COMPACT_SCATTER,
|
|
886
|
+
backgroundColor: '#a78bfa', pointRadius: 5 }]
|
|
887
|
+
},
|
|
888
|
+
options: { ...baseOpts,
|
|
889
|
+
scales: {
|
|
890
|
+
x: { type: 'category', labels: [...new Set(COMPACT_SCATTER.map(p => p.x))],
|
|
891
|
+
ticks: { color: TEXT, font: { size: 10 } }, grid: { color: GRID } },
|
|
892
|
+
y: { ticks: { color: TEXT, font: { size: 10 },
|
|
893
|
+
callback: v => (v/1000).toFixed(0) + 'K' }, grid: { color: GRID } }
|
|
894
|
+
},
|
|
895
|
+
plugins: { ...baseOpts.plugins,
|
|
896
|
+
tooltip: { callbacks: { label: ctx => ' ' + ctx.parsed.y.toLocaleString() + ' tokens' } } }
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
// Compactions per session distribution
|
|
901
|
+
new Chart(document.getElementById('compactDist'), {
|
|
902
|
+
type: 'bar',
|
|
903
|
+
data: {
|
|
904
|
+
labels: COMPACT_DIST_LABELS,
|
|
905
|
+
datasets: [{ label: 'Sessions', data: COMPACT_DIST_VALUES,
|
|
906
|
+
backgroundColor: '#8b5cf6', borderRadius: 4 }]
|
|
907
|
+
},
|
|
908
|
+
options: baseOpts
|
|
909
|
+
});'''
|
|
910
|
+
else:
|
|
911
|
+
compaction_stat_html = ''
|
|
912
|
+
compaction_section_html = ''
|
|
913
|
+
compaction_js_constants = ''
|
|
914
|
+
compaction_js_charts = ''
|
|
915
|
+
|
|
796
916
|
html = f"""<!DOCTYPE html>
|
|
797
917
|
<html lang="en">
|
|
798
918
|
<head>
|
|
@@ -826,6 +946,7 @@ html = f"""<!DOCTYPE html>
|
|
|
826
946
|
.section-header.friction {{ border-left: 3px solid #ef4444; color: #f87171; }}
|
|
827
947
|
.section-header.errors {{ border-left: 3px solid #e11d48; color: #fb7185; }}
|
|
828
948
|
.section-header.skills {{ border-left: 3px solid #f59e0b; color: #fbbf24; }}
|
|
949
|
+
.section-header.compaction {{ border-left: 3px solid #8b5cf6; color: #a78bfa; }}
|
|
829
950
|
.grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }}
|
|
830
951
|
.card {{ background: #1e2330; border: 1px solid #2d3748; border-radius: 10px;
|
|
831
952
|
padding: 16px; }}
|
|
@@ -892,6 +1013,7 @@ html = f"""<!DOCTYPE html>
|
|
|
892
1013
|
{friction_stat_html}
|
|
893
1014
|
{retry_stat_html}
|
|
894
1015
|
{error_stat_html}
|
|
1016
|
+
{compaction_stat_html}
|
|
895
1017
|
</div>
|
|
896
1018
|
|
|
897
1019
|
<div class="section">
|
|
@@ -926,7 +1048,7 @@ html = f"""<!DOCTYPE html>
|
|
|
926
1048
|
</div>
|
|
927
1049
|
</div>
|
|
928
1050
|
|
|
929
|
-
{agents_section_html}{skills_section_html}{friction_section_html}{error_section_html}<div class="section">
|
|
1051
|
+
{agents_section_html}{skills_section_html}{friction_section_html}{error_section_html}{compaction_section_html}<div class="section">
|
|
930
1052
|
<div class="section-header prompts">Key Prompts</div>
|
|
931
1053
|
<div class="grid">
|
|
932
1054
|
|
|
@@ -1035,6 +1157,7 @@ const DUR_HIST_RANGES = {dur_hist_ranges_js};
|
|
|
1035
1157
|
{skills_js_constants}
|
|
1036
1158
|
{friction_js_constants}
|
|
1037
1159
|
{error_js_constants}
|
|
1160
|
+
{compaction_js_constants}
|
|
1038
1161
|
|
|
1039
1162
|
function formatDuration(s) {{
|
|
1040
1163
|
if (s <= 0) return '0s';
|
|
@@ -1312,6 +1435,7 @@ new Chart(document.getElementById('promptStack'), {{
|
|
|
1312
1435
|
{skills_js_charts}
|
|
1313
1436
|
{friction_js_charts}
|
|
1314
1437
|
{error_js_charts}
|
|
1438
|
+
{compaction_js_charts}
|
|
1315
1439
|
</script>
|
|
1316
1440
|
</body>
|
|
1317
1441
|
</html>
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Parse context compaction events from Claude Code JSONL transcripts.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python3 parse_compactions.py <transcript_path> <tracking_dir> <session_id> <project>
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from datetime import date, datetime
|
|
12
|
+
|
|
13
|
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
14
|
+
sys.path.insert(0, SCRIPT_DIR)
|
|
15
|
+
import storage
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def make_date(timestamp):
|
|
19
|
+
try:
|
|
20
|
+
return datetime.fromisoformat(
|
|
21
|
+
timestamp.replace('Z', '+00:00')).strftime('%Y-%m-%d')
|
|
22
|
+
except Exception:
|
|
23
|
+
return date.today().isoformat()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_compactions(transcript_path, session_id, project):
|
|
27
|
+
"""Parse JSONL transcript for compact_boundary system events.
|
|
28
|
+
|
|
29
|
+
Derives turn_index by counting user/assistant pairs before each event.
|
|
30
|
+
Returns list of dicts ready for storage.replace_session_compactions().
|
|
31
|
+
"""
|
|
32
|
+
lines = []
|
|
33
|
+
with open(transcript_path, encoding='utf-8') as f:
|
|
34
|
+
for raw in f:
|
|
35
|
+
try:
|
|
36
|
+
obj = json.loads(raw)
|
|
37
|
+
lines.append(obj)
|
|
38
|
+
except Exception:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
# Build turn boundaries for turn_index calculation
|
|
42
|
+
msg_list = []
|
|
43
|
+
for obj in lines:
|
|
44
|
+
ts = obj.get('timestamp', '')
|
|
45
|
+
t = obj.get('type')
|
|
46
|
+
if t == 'user' and not obj.get('isSidechain') and ts:
|
|
47
|
+
msg_list.append(('user', ts))
|
|
48
|
+
elif t == 'assistant' and ts:
|
|
49
|
+
msg_list.append(('assistant', ts))
|
|
50
|
+
|
|
51
|
+
# Pair user->assistant for turn boundaries
|
|
52
|
+
turn_boundaries = []
|
|
53
|
+
idx = 0
|
|
54
|
+
while idx < len(msg_list):
|
|
55
|
+
if msg_list[idx][0] == 'user':
|
|
56
|
+
j = idx + 1
|
|
57
|
+
while j < len(msg_list) and msg_list[j][0] != 'assistant':
|
|
58
|
+
j += 1
|
|
59
|
+
if j < len(msg_list):
|
|
60
|
+
turn_boundaries.append((msg_list[idx][1], msg_list[j][1]))
|
|
61
|
+
idx = j + 1
|
|
62
|
+
else:
|
|
63
|
+
idx += 1
|
|
64
|
+
else:
|
|
65
|
+
idx += 1
|
|
66
|
+
|
|
67
|
+
def get_turn_index(timestamp):
|
|
68
|
+
if not timestamp:
|
|
69
|
+
return 0
|
|
70
|
+
for ti, (user_ts, asst_ts) in enumerate(turn_boundaries):
|
|
71
|
+
if timestamp <= asst_ts:
|
|
72
|
+
return ti
|
|
73
|
+
if ti + 1 < len(turn_boundaries):
|
|
74
|
+
next_user_ts = turn_boundaries[ti + 1][0]
|
|
75
|
+
if asst_ts < timestamp < next_user_ts:
|
|
76
|
+
return ti
|
|
77
|
+
return max(0, len(turn_boundaries) - 1)
|
|
78
|
+
|
|
79
|
+
entries = []
|
|
80
|
+
for obj in lines:
|
|
81
|
+
if obj.get('type') != 'system':
|
|
82
|
+
continue
|
|
83
|
+
if obj.get('subtype') != 'compact_boundary':
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
ts = obj.get('timestamp', '')
|
|
87
|
+
meta = obj.get('compactMetadata', {})
|
|
88
|
+
if not isinstance(meta, dict):
|
|
89
|
+
meta = {}
|
|
90
|
+
|
|
91
|
+
entries.append({
|
|
92
|
+
'session_id': session_id,
|
|
93
|
+
'date': make_date(ts),
|
|
94
|
+
'project': project,
|
|
95
|
+
'timestamp': ts,
|
|
96
|
+
'trigger': meta.get('trigger', 'auto'),
|
|
97
|
+
'pre_tokens': meta.get('preTokens', 0),
|
|
98
|
+
'turn_index': get_turn_index(ts),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
return entries
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == '__main__':
|
|
105
|
+
if len(sys.argv) != 5:
|
|
106
|
+
print(f"Usage: {sys.argv[0]} <transcript_path> <tracking_dir> <session_id> <project>",
|
|
107
|
+
file=sys.stderr)
|
|
108
|
+
sys.exit(1)
|
|
109
|
+
|
|
110
|
+
transcript_path, tracking_dir, session_id, project = sys.argv[1:5]
|
|
111
|
+
entries = parse_compactions(transcript_path, session_id, project)
|
|
112
|
+
storage.replace_session_compactions(tracking_dir, session_id, entries)
|
package/src/parse_friction.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Parse friction events from Claude Code JSONL transcripts.
|
|
4
4
|
|
|
5
5
|
Usage:
|
|
6
|
-
python3 parse_friction.py <transcript_path> <
|
|
6
|
+
python3 parse_friction.py <transcript_path> <tracking_dir> <session_id> <project> <source> \
|
|
7
7
|
[--agent-type TYPE] [--agent-id ID]
|
|
8
8
|
|
|
9
9
|
Friction categories (priority order, first match wins):
|
|
@@ -11,6 +11,10 @@ Friction categories (priority order, first match wins):
|
|
|
11
11
|
"""
|
|
12
12
|
import sys, json, os, argparse
|
|
13
13
|
|
|
14
|
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
15
|
+
sys.path.insert(0, SCRIPT_DIR)
|
|
16
|
+
import storage
|
|
17
|
+
|
|
14
18
|
|
|
15
19
|
def parse_friction(transcript_path, session_id, project, source,
|
|
16
20
|
agent_type=None, agent_id=None):
|
|
@@ -237,34 +241,23 @@ def parse_friction(transcript_path, session_id, project, source,
|
|
|
237
241
|
return events
|
|
238
242
|
|
|
239
243
|
|
|
240
|
-
def upsert_friction(
|
|
241
|
-
"""
|
|
242
|
-
data = []
|
|
243
|
-
if os.path.exists(friction_file):
|
|
244
|
-
try:
|
|
245
|
-
with open(friction_file, encoding='utf-8') as f:
|
|
246
|
-
data = json.load(f)
|
|
247
|
-
except Exception:
|
|
248
|
-
data = []
|
|
249
|
-
|
|
250
|
-
data = [e for e in data if e.get('session_id') != session_id]
|
|
251
|
-
data.extend(new_events)
|
|
252
|
-
|
|
253
|
-
data.sort(key=lambda x: (x.get('date', ''), x.get('session_id', ''),
|
|
254
|
-
x.get('turn_index', 0)))
|
|
244
|
+
def upsert_friction(tracking_dir, session_id, new_events):
|
|
245
|
+
"""Write friction events to SQLite via storage module.
|
|
255
246
|
|
|
256
|
-
|
|
257
|
-
with
|
|
258
|
-
|
|
259
|
-
|
|
247
|
+
For backward compatibility, also accepts a friction.json path --
|
|
248
|
+
if tracking_dir ends with '.json', derive the tracking dir from it.
|
|
249
|
+
"""
|
|
250
|
+
if tracking_dir.endswith('.json'):
|
|
251
|
+
tracking_dir = os.path.dirname(os.path.abspath(tracking_dir))
|
|
260
252
|
|
|
261
|
-
|
|
253
|
+
storage.replace_session_friction(tracking_dir, session_id, new_events)
|
|
254
|
+
return new_events
|
|
262
255
|
|
|
263
256
|
|
|
264
257
|
def main():
|
|
265
258
|
parser = argparse.ArgumentParser(description='Parse friction events from JSONL transcript')
|
|
266
259
|
parser.add_argument('transcript_path')
|
|
267
|
-
parser.add_argument('
|
|
260
|
+
parser.add_argument('tracking_dir')
|
|
268
261
|
parser.add_argument('session_id')
|
|
269
262
|
parser.add_argument('project')
|
|
270
263
|
parser.add_argument('source')
|
|
@@ -275,11 +268,9 @@ def main():
|
|
|
275
268
|
events = parse_friction(args.transcript_path, args.session_id, args.project,
|
|
276
269
|
args.source, args.agent_type, args.agent_id)
|
|
277
270
|
|
|
271
|
+
upsert_friction(args.tracking_dir, args.session_id, events)
|
|
278
272
|
if events:
|
|
279
|
-
upsert_friction(args.friction_file, args.session_id, events)
|
|
280
273
|
print(f"{len(events)} friction event(s) recorded.")
|
|
281
|
-
else:
|
|
282
|
-
upsert_friction(args.friction_file, args.session_id, [])
|
|
283
274
|
|
|
284
275
|
|
|
285
276
|
if __name__ == '__main__':
|
package/src/stop-hook.sh
CHANGED
|
@@ -54,13 +54,17 @@ fi
|
|
|
54
54
|
python3 "$SCRIPT_DIR/write-turns.py" "$TRANSCRIPT" "$TRACKING_DIR" "$SESSION_ID" "$(basename "$PROJECT_ROOT")"
|
|
55
55
|
|
|
56
56
|
# Parse friction events from JSONL
|
|
57
|
-
python3 "$SCRIPT_DIR/parse_friction.py" "$TRANSCRIPT" "$TRACKING_DIR
|
|
57
|
+
python3 "$SCRIPT_DIR/parse_friction.py" "$TRANSCRIPT" "$TRACKING_DIR" \
|
|
58
58
|
"$SESSION_ID" "$(basename "$PROJECT_ROOT")" "main" 2>/dev/null || true
|
|
59
59
|
|
|
60
60
|
# Parse skill invocations from JSONL
|
|
61
61
|
python3 "$SCRIPT_DIR/parse_skills.py" "$TRANSCRIPT" "$TRACKING_DIR" \
|
|
62
62
|
"$SESSION_ID" "$(basename "$PROJECT_ROOT")" 2>/dev/null || true
|
|
63
63
|
|
|
64
|
+
# Parse context compaction events from JSONL
|
|
65
|
+
python3 "$SCRIPT_DIR/parse_compactions.py" "$TRANSCRIPT" "$TRACKING_DIR" \
|
|
66
|
+
"$SESSION_ID" "$(basename "$PROJECT_ROOT")" 2>/dev/null || true
|
|
67
|
+
|
|
64
68
|
# Regenerate charts
|
|
65
69
|
python3 "$SCRIPT_DIR/generate-charts.py" "$TRACKING_DIR" "$TRACKING_DIR/charts.html" 2>/dev/null || true
|
|
66
70
|
|
package/src/storage.py
CHANGED
|
@@ -67,6 +67,37 @@ SKILL_DEFAULTS = {
|
|
|
67
67
|
"error_message": None,
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
COMPACTION_COLS = [
|
|
71
|
+
"session_id", "date", "project", "timestamp",
|
|
72
|
+
"trigger", "pre_tokens", "turn_index",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
COMPACTION_DEFAULTS = {
|
|
76
|
+
"timestamp": None,
|
|
77
|
+
"trigger": "auto",
|
|
78
|
+
"pre_tokens": 0,
|
|
79
|
+
"turn_index": None,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
FRICTION_COLS = [
|
|
83
|
+
"session_id", "date", "project", "timestamp", "turn_index",
|
|
84
|
+
"source", "agent_type", "agent_id", "category",
|
|
85
|
+
"tool_name", "skill", "model", "detail", "resolved",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
FRICTION_DEFAULTS = {
|
|
89
|
+
"timestamp": None,
|
|
90
|
+
"turn_index": 0,
|
|
91
|
+
"source": None,
|
|
92
|
+
"agent_type": None,
|
|
93
|
+
"agent_id": None,
|
|
94
|
+
"tool_name": None,
|
|
95
|
+
"skill": None,
|
|
96
|
+
"model": None,
|
|
97
|
+
"detail": None,
|
|
98
|
+
"resolved": None,
|
|
99
|
+
}
|
|
100
|
+
|
|
70
101
|
SCHEMA_SQL = """\
|
|
71
102
|
CREATE TABLE IF NOT EXISTS turns (
|
|
72
103
|
session_id TEXT NOT NULL,
|
|
@@ -119,6 +150,40 @@ CREATE TABLE IF NOT EXISTS skills (
|
|
|
119
150
|
CREATE INDEX IF NOT EXISTS idx_skills_session ON skills(session_id);
|
|
120
151
|
CREATE INDEX IF NOT EXISTS idx_skills_name ON skills(skill_name);
|
|
121
152
|
|
|
153
|
+
CREATE TABLE IF NOT EXISTS compactions (
|
|
154
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
155
|
+
session_id TEXT NOT NULL,
|
|
156
|
+
date TEXT NOT NULL,
|
|
157
|
+
project TEXT NOT NULL,
|
|
158
|
+
timestamp TEXT,
|
|
159
|
+
trigger TEXT NOT NULL DEFAULT 'auto',
|
|
160
|
+
pre_tokens INTEGER NOT NULL DEFAULT 0,
|
|
161
|
+
turn_index INTEGER
|
|
162
|
+
);
|
|
163
|
+
CREATE INDEX IF NOT EXISTS idx_compactions_session ON compactions(session_id);
|
|
164
|
+
CREATE INDEX IF NOT EXISTS idx_compactions_date ON compactions(date);
|
|
165
|
+
|
|
166
|
+
CREATE TABLE IF NOT EXISTS friction (
|
|
167
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
168
|
+
session_id TEXT NOT NULL,
|
|
169
|
+
date TEXT NOT NULL,
|
|
170
|
+
project TEXT NOT NULL,
|
|
171
|
+
timestamp TEXT,
|
|
172
|
+
turn_index INTEGER DEFAULT 0,
|
|
173
|
+
source TEXT,
|
|
174
|
+
agent_type TEXT,
|
|
175
|
+
agent_id TEXT,
|
|
176
|
+
category TEXT NOT NULL,
|
|
177
|
+
tool_name TEXT,
|
|
178
|
+
skill TEXT,
|
|
179
|
+
model TEXT,
|
|
180
|
+
detail TEXT,
|
|
181
|
+
resolved INTEGER
|
|
182
|
+
);
|
|
183
|
+
CREATE INDEX IF NOT EXISTS idx_friction_session ON friction(session_id);
|
|
184
|
+
CREATE INDEX IF NOT EXISTS idx_friction_date ON friction(date);
|
|
185
|
+
CREATE INDEX IF NOT EXISTS idx_friction_category ON friction(category);
|
|
186
|
+
|
|
122
187
|
CREATE TABLE IF NOT EXISTS metadata (
|
|
123
188
|
key TEXT PRIMARY KEY,
|
|
124
189
|
value TEXT
|
|
@@ -167,6 +232,28 @@ def _insert_skills(conn: sqlite3.Connection, entries: list[dict]) -> None:
|
|
|
167
232
|
conn.executemany(sql, rows)
|
|
168
233
|
|
|
169
234
|
|
|
235
|
+
def _insert_compactions(conn: sqlite3.Connection, entries: list[dict]) -> None:
|
|
236
|
+
"""INSERT compactions via executemany (always appends — autoincrement id)."""
|
|
237
|
+
placeholders = ", ".join(["?"] * len(COMPACTION_COLS))
|
|
238
|
+
sql = f"INSERT INTO compactions ({', '.join(COMPACTION_COLS)}) VALUES ({placeholders})"
|
|
239
|
+
rows = []
|
|
240
|
+
for e in entries:
|
|
241
|
+
rows.append(tuple(e.get(col, COMPACTION_DEFAULTS.get(col)) for col in COMPACTION_COLS))
|
|
242
|
+
if rows:
|
|
243
|
+
conn.executemany(sql, rows)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _insert_friction(conn: sqlite3.Connection, entries: list[dict]) -> None:
|
|
247
|
+
"""INSERT friction via executemany (always appends — autoincrement id)."""
|
|
248
|
+
placeholders = ", ".join(["?"] * len(FRICTION_COLS))
|
|
249
|
+
sql = f"INSERT INTO friction ({', '.join(FRICTION_COLS)}) VALUES ({placeholders})"
|
|
250
|
+
rows = []
|
|
251
|
+
for e in entries:
|
|
252
|
+
rows.append(tuple(e.get(col, FRICTION_DEFAULTS.get(col)) for col in FRICTION_COLS))
|
|
253
|
+
if rows:
|
|
254
|
+
conn.executemany(sql, rows)
|
|
255
|
+
|
|
256
|
+
|
|
170
257
|
def _maybe_migrate(conn: sqlite3.Connection, tracking_dir: str) -> None:
|
|
171
258
|
"""One-time migration from JSON files to SQLite.
|
|
172
259
|
|
|
@@ -200,6 +287,16 @@ def _maybe_migrate(conn: sqlite3.Connection, tracking_dir: str) -> None:
|
|
|
200
287
|
except (json.JSONDecodeError, OSError):
|
|
201
288
|
pass
|
|
202
289
|
|
|
290
|
+
friction_path = os.path.join(tracking_dir, "friction.json")
|
|
291
|
+
if os.path.exists(friction_path):
|
|
292
|
+
try:
|
|
293
|
+
with open(friction_path, encoding="utf-8") as f:
|
|
294
|
+
data = json.load(f)
|
|
295
|
+
if data:
|
|
296
|
+
_insert_friction(conn, data)
|
|
297
|
+
except (json.JSONDecodeError, OSError):
|
|
298
|
+
pass
|
|
299
|
+
|
|
203
300
|
now = datetime.now(timezone.utc).isoformat()
|
|
204
301
|
conn.execute(
|
|
205
302
|
"INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)",
|
|
@@ -212,7 +309,7 @@ def _maybe_migrate(conn: sqlite3.Connection, tracking_dir: str) -> None:
|
|
|
212
309
|
conn.commit()
|
|
213
310
|
|
|
214
311
|
# Rename originals so migration won't re-run even without the metadata check
|
|
215
|
-
for path in (tokens_path, agents_path):
|
|
312
|
+
for path in (tokens_path, agents_path, friction_path):
|
|
216
313
|
if os.path.exists(path):
|
|
217
314
|
try:
|
|
218
315
|
os.rename(path, path + ".migrated")
|
|
@@ -324,6 +421,50 @@ def get_all_skills(tracking_dir: str) -> list[dict]:
|
|
|
324
421
|
return result
|
|
325
422
|
|
|
326
423
|
|
|
424
|
+
def replace_session_compactions(
|
|
425
|
+
tracking_dir: str, session_id: str, entries: list[dict]
|
|
426
|
+
) -> None:
|
|
427
|
+
"""Delete all compactions for a session and insert replacements atomically."""
|
|
428
|
+
with get_db(tracking_dir) as conn:
|
|
429
|
+
conn.execute("DELETE FROM compactions WHERE session_id = ?", (session_id,))
|
|
430
|
+
_insert_compactions(conn, entries)
|
|
431
|
+
conn.commit()
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def get_all_compactions(tracking_dir: str) -> list[dict]:
|
|
435
|
+
"""Return all compaction rows as dicts (without the autoincrement id), ordered by id."""
|
|
436
|
+
with get_db(tracking_dir) as conn:
|
|
437
|
+
rows = conn.execute("SELECT * FROM compactions ORDER BY id").fetchall()
|
|
438
|
+
result = []
|
|
439
|
+
for r in rows:
|
|
440
|
+
d = dict(r)
|
|
441
|
+
d.pop("id", None)
|
|
442
|
+
result.append(d)
|
|
443
|
+
return result
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def replace_session_friction(
|
|
447
|
+
tracking_dir: str, session_id: str, entries: list[dict]
|
|
448
|
+
) -> None:
|
|
449
|
+
"""Delete all friction for a session and insert replacements atomically."""
|
|
450
|
+
with get_db(tracking_dir) as conn:
|
|
451
|
+
conn.execute("DELETE FROM friction WHERE session_id = ?", (session_id,))
|
|
452
|
+
_insert_friction(conn, entries)
|
|
453
|
+
conn.commit()
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def get_all_friction(tracking_dir: str) -> list[dict]:
|
|
457
|
+
"""Return all friction rows as dicts (without the autoincrement id), ordered by id."""
|
|
458
|
+
with get_db(tracking_dir) as conn:
|
|
459
|
+
rows = conn.execute("SELECT * FROM friction ORDER BY id").fetchall()
|
|
460
|
+
result = []
|
|
461
|
+
for r in rows:
|
|
462
|
+
d = dict(r)
|
|
463
|
+
d.pop("id", None)
|
|
464
|
+
result.append(d)
|
|
465
|
+
return result
|
|
466
|
+
|
|
467
|
+
|
|
327
468
|
def count_turns_for_session(tracking_dir: str, session_id: str) -> int:
|
|
328
469
|
"""Return the number of turns for a given session."""
|
|
329
470
|
with get_db(tracking_dir) as conn:
|