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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-tracker",
3
- "version": "1.4.0-beta.3",
3
+ "version": "1.4.0-beta.4",
4
4
  "description": "Automatic token, cost, and prompt tracking for Claude Code sessions",
5
5
  "keywords": [
6
6
  "claude",
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, upsert_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
- upsert_friction(friction_file, session_id, events)
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')
@@ -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
- friction_file = os.path.join(tracking_dir, "friction.json")
142
- if os.path.exists(friction_file):
143
- try:
144
- with open(friction_file, encoding='utf-8') as f:
145
- friction_data = json.load(f)
146
- if friction_data:
147
- print(f"\nFriction:")
148
- friction_total = len(friction_data)
149
- cat_counts = defaultdict(int)
150
- tool_counts = defaultdict(int)
151
- for fe in friction_data:
152
- cat_counts[fe.get('category', 'unknown')] += 1
153
- tn = fe.get('tool_name')
154
- if tn:
155
- tool_counts[tn] += 1
156
- top_cat = max(cat_counts, key=cat_counts.get)
157
- print(f" Total events: {friction_total:>8}")
158
- print(f" Top category: {top_cat:>8} ({cat_counts[top_cat]} events)")
159
- if tool_counts:
160
- top_tool = max(tool_counts, key=tool_counts.get)
161
- print(f" Top tool: {top_tool:>8} ({tool_counts[top_tool]} events)")
162
- except Exception:
163
- pass
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)
@@ -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
- # Load friction data (optional — file may not exist on older installs)
29
- friction_file = os.path.join(tracking_dir, 'friction.json')
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') is True)
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)
@@ -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> <friction_file> <session_id> <project> <source> \
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(friction_file, session_id, new_events):
241
- """Load existing friction.json, remove events for session_id, add new, sort, write."""
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
- os.makedirs(os.path.dirname(os.path.abspath(friction_file)), exist_ok=True)
257
- with open(friction_file, 'w', encoding='utf-8') as f:
258
- json.dump(data, f, indent=2)
259
- f.write('\n')
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
- return data
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('friction_file')
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/friction.json" \
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: