claude-code-tracker 1.2.4 → 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.
@@ -1,24 +1,25 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- Patch duration_seconds for per-turn entries that have duration 0,
4
- and migrate old single-entry-per-session entries to per-turn format.
3
+ Patch duration_seconds for per-turn entries that have duration 0.
5
4
 
6
5
  Usage:
7
6
  python3 patch-durations.py <project_root>
8
7
  """
9
- import sys, json, os, glob
8
+ import sys, json, os
9
+
10
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
11
+ sys.path.insert(0, SCRIPT_DIR)
12
+ import storage
13
+
10
14
  from datetime import datetime
11
15
 
12
16
  project_root = os.path.abspath(sys.argv[1])
13
17
  tracking_dir = os.path.join(project_root, ".claude", "tracking")
14
- tokens_file = os.path.join(tracking_dir, "tokens.json")
15
18
 
16
19
  slug = project_root.replace("/", "-")
17
20
  transcripts_dir = os.path.expanduser("~/.claude/projects/" + slug)
18
- project_name = os.path.basename(project_root)
19
21
 
20
- with open(tokens_file) as f:
21
- data = json.load(f)
22
+ data = storage.get_all_turns(tracking_dir)
22
23
 
23
24
  def parse_transcript(jf):
24
25
  msgs = []
@@ -26,7 +27,7 @@ def parse_transcript(jf):
26
27
  model = "unknown"
27
28
  first_ts = None
28
29
  try:
29
- with open(jf) as f:
30
+ with open(jf, encoding='utf-8') as f:
30
31
  for line in f:
31
32
  try:
32
33
  obj = json.loads(line)
@@ -52,15 +53,9 @@ def parse_transcript(jf):
52
53
  pass
53
54
  return msgs, usages, model, first_ts
54
55
 
55
- # Separate old-format (no turn_index) from new-format entries
56
- old_entries = [e for e in data if "turn_index" not in e]
57
- new_entries = [e for e in data if "turn_index" in e]
58
-
59
- # For new-format entries with duration 0, patch from transcript
60
- existing_keys = {(e.get("session_id"), e.get("turn_index")): i for i, e in enumerate(new_entries)}
61
56
  patched = 0
62
57
 
63
- for entry in new_entries:
58
+ for entry in data:
64
59
  if entry.get("duration_seconds", 0) > 0:
65
60
  continue
66
61
  sid = entry.get("session_id")
@@ -85,7 +80,7 @@ for entry in new_entries:
85
80
  t1 = datetime.fromisoformat(msgs[j][1].replace("Z", "+00:00"))
86
81
  duration = max(0, int((t1 - t0).total_seconds()))
87
82
  if duration > 0:
88
- entry["duration_seconds"] = duration
83
+ storage.patch_turn_duration(tracking_dir, sid, turn_index, duration)
89
84
  patched += 1
90
85
  print(f" patched {sid[:8]}#{turn_index} {duration}s")
91
86
  except Exception:
@@ -99,103 +94,8 @@ for entry in new_entries:
99
94
  else:
100
95
  i += 1
101
96
 
102
- # Migrate old-format entries to per-turn
103
- migrated_sessions = 0
104
- new_turn_entries = []
105
- for old_entry in old_entries:
106
- sid = old_entry.get("session_id")
107
- if not sid:
108
- continue
109
- jf = os.path.join(transcripts_dir, sid + ".jsonl")
110
- if not os.path.exists(jf):
111
- # Keep old entry as-is if we can't reprocess
112
- new_entries.append(old_entry)
113
- continue
114
-
115
- msgs, usages, model, first_ts = parse_transcript(jf)
116
-
117
- turn_index = 0
118
- usage_index = 0
119
- i = 0
120
- session_date = old_entry.get("date")
121
-
122
- while i < len(msgs):
123
- if msgs[i][0] == "user":
124
- user_ts = msgs[i][1]
125
- j = i + 1
126
- while j < len(msgs) and msgs[j][0] != "assistant":
127
- j += 1
128
- if j < len(msgs):
129
- asst_ts = msgs[j][1]
130
- usage = {}
131
- if usage_index < len(usages):
132
- usage = usages[usage_index]
133
- usage_index += 1
134
-
135
- inp = usage.get("input_tokens", 0)
136
- out = usage.get("output_tokens", 0)
137
- cache_create = usage.get("cache_creation_input_tokens", 0)
138
- cache_read = usage.get("cache_read_input_tokens", 0)
139
- total = inp + cache_create + cache_read + out
140
-
141
- if total > 0:
142
- duration = 0
143
- try:
144
- t0 = datetime.fromisoformat(user_ts.replace("Z", "+00:00"))
145
- t1 = datetime.fromisoformat(asst_ts.replace("Z", "+00:00"))
146
- duration = max(0, int((t1 - t0).total_seconds()))
147
- except Exception:
148
- pass
149
-
150
- if "opus" in model:
151
- cost = inp * 15 / 1e6 + cache_create * 18.75 / 1e6 + cache_read * 1.50 / 1e6 + out * 75 / 1e6
152
- else:
153
- cost = inp * 3 / 1e6 + cache_create * 3.75 / 1e6 + cache_read * 0.30 / 1e6 + out * 15 / 1e6
154
-
155
- try:
156
- turn_ts = datetime.fromisoformat(user_ts.replace("Z", "+00:00")).strftime("%Y-%m-%dT%H:%M:%SZ")
157
- turn_date = datetime.fromisoformat(user_ts.replace("Z", "+00:00")).strftime("%Y-%m-%d")
158
- except Exception:
159
- turn_ts = user_ts
160
- turn_date = session_date
161
-
162
- new_turn_entries.append({
163
- "date": turn_date,
164
- "project": project_name,
165
- "session_id": sid,
166
- "turn_index": turn_index,
167
- "turn_timestamp": turn_ts,
168
- "input_tokens": inp,
169
- "cache_creation_tokens": cache_create,
170
- "cache_read_tokens": cache_read,
171
- "output_tokens": out,
172
- "total_tokens": total,
173
- "estimated_cost_usd": round(cost, 4),
174
- "model": model,
175
- "duration_seconds": duration,
176
- })
177
- turn_index += 1
178
- i = j + 1
179
- else:
180
- i += 1
181
- else:
182
- i += 1
183
-
184
- if turn_index > 0:
185
- migrated_sessions += 1
186
- print(f" migrated {sid[:8]} {turn_index} turn(s)")
187
- else:
188
- new_entries.append(old_entry)
189
-
190
- new_entries.extend(new_turn_entries)
191
- new_entries.sort(key=lambda x: (x.get("date", ""), x.get("session_id", ""), x.get("turn_index", 0)))
192
-
193
- if patched > 0 or migrated_sessions > 0:
194
- with open(tokens_file, "w") as f:
195
- json.dump(new_entries, f, indent=2)
196
- f.write("\n")
197
- script_dir = os.path.dirname(os.path.abspath(__file__))
97
+ if patched > 0:
198
98
  charts_html = os.path.join(tracking_dir, "charts.html")
199
- os.system(f'python3 "{script_dir}/generate-charts.py" "{tokens_file}" "{charts_html}" 2>/dev/null')
99
+ os.system(f'python3 "{SCRIPT_DIR}/generate-charts.py" "{tracking_dir}" "{charts_html}" 2>/dev/null')
200
100
 
201
- print(f"{patched} turn(s) patched, {migrated_sessions} session(s) migrated to per-turn format.")
101
+ print(f"{patched} turn(s) patched.")
@@ -0,0 +1,36 @@
1
+ """Cross-platform utility helpers for claude-tracker."""
2
+ import os
3
+ import sys
4
+ import subprocess
5
+
6
+ def get_transcripts_dir():
7
+ """Return the Claude transcripts directory for the current platform."""
8
+ if sys.platform == 'win32':
9
+ appdata = os.environ.get('APPDATA', '')
10
+ return os.path.join(appdata, 'Claude', 'claude_code', 'transcripts')
11
+ elif sys.platform == 'darwin':
12
+ home = os.path.expanduser('~')
13
+ return os.path.join(home, 'Library', 'Application Support', 'Claude', 'claude_code', 'transcripts')
14
+ else:
15
+ home = os.path.expanduser('~')
16
+ return os.path.join(home, '.config', 'Claude', 'claude_code', 'transcripts')
17
+
18
+ def slugify_path(path):
19
+ """Convert an absolute path to a slug suitable for use as a directory name.
20
+ Handles Windows drive letters and backslashes."""
21
+ # Normalize separators
22
+ slug = path.replace('\\', '/').replace('/', '-')
23
+ # Remove drive letter colon on Windows (e.g. C: -> C)
24
+ slug = slug.replace(':', '')
25
+ # Strip leading/trailing dashes
26
+ slug = slug.strip('-')
27
+ return slug
28
+
29
+ def open_file(path):
30
+ """Open a file with the default system application."""
31
+ if sys.platform == 'win32':
32
+ os.startfile(path)
33
+ elif sys.platform == 'darwin':
34
+ subprocess.run(['open', path])
35
+ else:
36
+ subprocess.run(['xdg-open', path])
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const { execFileSync, spawnSync } = require('child_process');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+
7
+ const scriptDir = path.dirname(path.resolve(__filename));
8
+ const bashScript = path.join(scriptDir, 'stop-hook.sh');
9
+
10
+ // On Windows, run via bash (Git Bash / WSL); on Unix, run directly
11
+ const input = fs.readFileSync(process.stdin.fd, 'utf8');
12
+
13
+ if (process.platform === 'win32') {
14
+ const result = spawnSync('bash', [bashScript], {
15
+ input,
16
+ stdio: ['pipe', 'inherit', 'inherit'],
17
+ shell: false,
18
+ });
19
+ process.exit(result.status || 0);
20
+ } else {
21
+ const result = spawnSync('bash', [bashScript], {
22
+ input,
23
+ stdio: ['pipe', 'inherit', 'inherit'],
24
+ });
25
+ process.exit(result.status || 0);
26
+ }
package/src/stop-hook.sh CHANGED
@@ -50,161 +50,33 @@ if [[ ! -d "$TRACKING_DIR" ]]; then
50
50
  python3 "$SCRIPT_DIR/backfill.py" "$PROJECT_ROOT" 2>/dev/null || true
51
51
  fi
52
52
 
53
- # Parse token usage from JSONL emit one entry per turn, upsert into tokens.json
54
- python3 - "$TRANSCRIPT" "$TRACKING_DIR/tokens.json" "$SESSION_ID" "$(basename "$PROJECT_ROOT")" <<'PYEOF'
55
- import sys, json, os
56
- from datetime import datetime, date
57
-
58
- transcript_path = sys.argv[1]
59
- tokens_file = sys.argv[2]
60
- session_id = sys.argv[3]
61
- project_name = sys.argv[4]
62
-
63
- msgs = [] # (role, timestamp)
64
- usages = [] # usage dicts from assistant messages, in order
65
- model = "unknown"
66
-
67
- with open(transcript_path) as f:
68
- for line in f:
69
- try:
70
- obj = json.loads(line)
71
- t = obj.get('type')
72
- ts = obj.get('timestamp')
73
- if t == 'user' and not obj.get('isSidechain') and ts:
74
- msgs.append(('user', ts))
75
- elif t == 'assistant' and ts:
76
- msgs.append(('assistant', ts))
77
- msg = obj.get('message', {})
78
- if isinstance(msg, dict) and msg.get('role') == 'assistant':
79
- usage = msg.get('usage', {})
80
- if usage:
81
- usages.append(usage)
82
- m = msg.get('model', '')
83
- if m:
84
- model = m
85
- except:
86
- pass
87
-
88
- # Build per-turn entries
89
- turn_entries = []
90
- turn_index = 0
91
- usage_index = 0
92
- i = 0
93
- while i < len(msgs):
94
- if msgs[i][0] == 'user':
95
- user_ts = msgs[i][1]
96
- j = i + 1
97
- while j < len(msgs) and msgs[j][0] != 'assistant':
98
- j += 1
99
- if j < len(msgs):
100
- asst_ts = msgs[j][1]
101
- usage = {}
102
- if usage_index < len(usages):
103
- usage = usages[usage_index]
104
- usage_index += 1
105
-
106
- inp = usage.get('input_tokens', 0)
107
- out = usage.get('output_tokens', 0)
108
- cache_create = usage.get('cache_creation_input_tokens', 0)
109
- cache_read = usage.get('cache_read_input_tokens', 0)
110
- total = inp + cache_create + cache_read + out
111
-
112
- if total > 0:
113
- duration = 0
114
- try:
115
- t0 = datetime.fromisoformat(user_ts.replace('Z', '+00:00'))
116
- t1 = datetime.fromisoformat(asst_ts.replace('Z', '+00:00'))
117
- duration = max(0, int((t1 - t0).total_seconds()))
118
- except:
119
- pass
120
-
121
- if 'opus' in model:
122
- cost = inp * 15 / 1e6 + cache_create * 18.75 / 1e6 + cache_read * 1.50 / 1e6 + out * 75 / 1e6
123
- else:
124
- cost = inp * 3 / 1e6 + cache_create * 3.75 / 1e6 + cache_read * 0.30 / 1e6 + out * 15 / 1e6
125
-
126
- try:
127
- turn_ts = datetime.fromisoformat(user_ts.replace('Z', '+00:00')).strftime('%Y-%m-%dT%H:%M:%SZ')
128
- turn_date = datetime.fromisoformat(user_ts.replace('Z', '+00:00')).strftime('%Y-%m-%d')
129
- except:
130
- turn_ts = user_ts
131
- turn_date = date.today().isoformat()
132
-
133
- turn_entries.append({
134
- 'date': turn_date,
135
- 'project': project_name,
136
- 'session_id': session_id,
137
- 'turn_index': turn_index,
138
- 'turn_timestamp': turn_ts,
139
- 'input_tokens': inp,
140
- 'cache_creation_tokens': cache_create,
141
- 'cache_read_tokens': cache_read,
142
- 'output_tokens': out,
143
- 'total_tokens': total,
144
- 'estimated_cost_usd': round(cost, 4),
145
- 'model': model,
146
- 'duration_seconds': duration,
147
- })
148
- turn_index += 1
149
- i = j + 1
150
- else:
151
- i += 1
152
- else:
153
- i += 1
154
-
155
- if not turn_entries:
156
- sys.exit(0)
157
-
158
- # Load existing data
159
- data = []
160
- if os.path.exists(tokens_file):
161
- try:
162
- with open(tokens_file) as f:
163
- data = json.load(f)
164
- except:
165
- data = []
166
-
167
- # Build index of existing (session_id, turn_index) -> position
168
- existing_idx = {}
169
- for pos, e in enumerate(data):
170
- key = (e.get('session_id'), e.get('turn_index'))
171
- existing_idx[key] = pos
172
-
173
- # Check if anything actually changed
174
- changed = False
175
- for entry in turn_entries:
176
- key = (entry['session_id'], entry['turn_index'])
177
- if key not in existing_idx:
178
- changed = True
179
- break
180
- existing = data[existing_idx[key]]
181
- if (existing.get('total_tokens') != entry['total_tokens'] or
182
- existing.get('output_tokens') != entry['output_tokens']):
183
- changed = True
184
- break
185
-
186
- if not changed:
187
- sys.exit(0)
188
-
189
- # Upsert: update existing entries or append new ones
190
- for entry in turn_entries:
191
- key = (entry['session_id'], entry['turn_index'])
192
- if key in existing_idx:
193
- data[existing_idx[key]] = entry
194
- else:
195
- data.append(entry)
196
- existing_idx[key] = len(data) - 1
197
-
198
- # Sort by (date, session_id, turn_index)
199
- data.sort(key=lambda x: (x.get('date', ''), x.get('session_id', ''), x.get('turn_index', 0)))
200
-
201
- with open(tokens_file, 'w') as f:
202
- json.dump(data, f, indent=2)
203
- f.write('\n')
204
- PYEOF
53
+ # Parse token usage from JSONL and write to SQLite
54
+ python3 "$SCRIPT_DIR/write-turns.py" "$TRANSCRIPT" "$TRACKING_DIR" "$SESSION_ID" "$(basename "$PROJECT_ROOT")"
55
+
56
+ # Parse friction events from JSONL
57
+ python3 "$SCRIPT_DIR/parse_friction.py" "$TRANSCRIPT" "$TRACKING_DIR" \
58
+ "$SESSION_ID" "$(basename "$PROJECT_ROOT")" "main" 2>/dev/null || true
59
+
60
+ # Parse skill invocations from JSONL
61
+ python3 "$SCRIPT_DIR/parse_skills.py" "$TRANSCRIPT" "$TRACKING_DIR" \
62
+ "$SESSION_ID" "$(basename "$PROJECT_ROOT")" 2>/dev/null || true
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
205
67
 
206
68
  # Regenerate charts
207
- python3 "$SCRIPT_DIR/generate-charts.py" "$TRACKING_DIR/tokens.json" "$TRACKING_DIR/charts.html" 2>/dev/null || true
69
+ python3 "$SCRIPT_DIR/generate-charts.py" "$TRACKING_DIR" "$TRACKING_DIR/charts.html" 2>/dev/null || true
70
+
71
+ # Regenerate key-prompts index + shadow to OpenMemory
72
+ OM_DB="$HOME/.claude/.claude/openmemory.sqlite"
73
+ LEARNINGS="$HOME/.claude/tool-learnings.md"
74
+ OM_ARGS=""
75
+ if [[ -f "$OM_DB" ]]; then
76
+ OM_ARGS="--om-db $OM_DB"
77
+ if [[ -f "$LEARNINGS" ]]; then
78
+ OM_ARGS="$OM_ARGS --learnings $LEARNINGS"
79
+ fi
80
+ fi
81
+ python3 "$SCRIPT_DIR/update-prompts-index.py" "$TRACKING_DIR" $OM_ARGS 2>/dev/null || true
208
82
 
209
- # Regenerate key-prompts index
210
- python3 "$SCRIPT_DIR/update-prompts-index.py" "$TRACKING_DIR" 2>/dev/null || true