code-meter 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,42 @@
1
+ """Base UsageProvider interface for extensible multi-provider AI usage tracking."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Dict, List, Optional
5
+ from pydantic import BaseModel, Field
6
+
7
+ from code_meter.models.usage import SessionCodeChange, SessionPrompt, UsageRecord
8
+
9
+
10
+ class FileScanState(BaseModel):
11
+ """Incremental scan state per file."""
12
+
13
+ file_path: str
14
+ file_size: int
15
+ modified_time: float
16
+ read_offset: int
17
+
18
+
19
+ class ScanResult(BaseModel):
20
+ """Result of provider scan containing new records, prompts, code changes, and updated file states."""
21
+
22
+ records: List[UsageRecord] = Field(default_factory=list)
23
+ prompts: List[SessionPrompt] = Field(default_factory=list)
24
+ code_changes: List[SessionCodeChange] = Field(default_factory=list)
25
+ updated_states: List[FileScanState] = Field(default_factory=list)
26
+ files_scanned: int = 0
27
+ malformed_lines: int = 0
28
+
29
+
30
+ class UsageProvider(ABC):
31
+ """Abstract interface for all AI coding agent usage providers."""
32
+
33
+ @property
34
+ @abstractmethod
35
+ def provider_name(self) -> str:
36
+ """Name identifier of provider (e.g., 'claude_code', 'codex', 'gemini')."""
37
+ pass
38
+
39
+ @abstractmethod
40
+ def scan(self, existing_states: Optional[Dict[str, FileScanState]] = None) -> ScanResult:
41
+ """Scan provider local storage and return normalized records, prompts, code changes, and state updates."""
42
+ pass
@@ -0,0 +1,378 @@
1
+ """Claude Code provider for scanning local ~/.claude JSONL session files."""
2
+
3
+ import hashlib
4
+ import json
5
+ import logging
6
+ import os
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Dict, List, Optional
10
+
11
+ from code_meter.models.usage import SessionCodeChange, SessionPrompt, UsageRecord
12
+ from code_meter.providers.base import FileScanState, ScanResult, UsageProvider
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class ClaudeCodeProvider(UsageProvider):
18
+ """Scanner for Claude Code local JSONL session logs."""
19
+
20
+ def __init__(self, claude_dir: Path):
21
+ self.claude_dir = Path(os.path.expanduser(claude_dir)).resolve()
22
+
23
+ @property
24
+ def provider_name(self) -> str:
25
+ return "claude_code"
26
+
27
+ def scan(self, existing_states: Optional[Dict[str, FileScanState]] = None) -> ScanResult:
28
+ """Incrementally scan all *.jsonl files in Claude Code directory."""
29
+ existing = existing_states or {}
30
+ records: List[UsageRecord] = []
31
+ prompts: List[SessionPrompt] = []
32
+ code_changes: List[SessionCodeChange] = []
33
+ updated_states: List[FileScanState] = []
34
+ files_scanned = 0
35
+ malformed_lines = 0
36
+
37
+ if not self.claude_dir.exists():
38
+ logger.warning(f"Claude directory does not exist: {self.claude_dir}")
39
+ return ScanResult(records=[], prompts=[], code_changes=[], updated_states=[], files_scanned=0, malformed_lines=0)
40
+
41
+ jsonl_files = self._find_jsonl_files()
42
+
43
+ for file_path in jsonl_files:
44
+ try:
45
+ stat = file_path.stat()
46
+ file_str = str(file_path.resolve())
47
+ size = stat.st_size
48
+ mtime = stat.st_mtime
49
+
50
+ prev_state = existing.get(file_str)
51
+
52
+ if prev_state:
53
+ if size == prev_state.file_size and mtime == prev_state.modified_time:
54
+ updated_states.append(prev_state)
55
+ continue
56
+
57
+ if size < prev_state.file_size:
58
+ start_offset = 0
59
+ else:
60
+ start_offset = prev_state.read_offset
61
+ else:
62
+ start_offset = 0
63
+
64
+ files_scanned += 1
65
+ new_records, new_prompts, new_changes, new_offset, malformed = self._scan_file(
66
+ file_path=file_path,
67
+ start_offset=start_offset,
68
+ )
69
+
70
+ records.extend(new_records)
71
+ prompts.extend(new_prompts)
72
+ code_changes.extend(new_changes)
73
+ malformed_lines += malformed
74
+
75
+ updated_states.append(
76
+ FileScanState(
77
+ file_path=file_str,
78
+ file_size=size,
79
+ modified_time=mtime,
80
+ read_offset=new_offset,
81
+ )
82
+ )
83
+
84
+ except Exception as e:
85
+ logger.error(f"Error scanning file {file_path}: {e}", exc_info=True)
86
+
87
+ return ScanResult(
88
+ records=records,
89
+ prompts=prompts,
90
+ code_changes=code_changes,
91
+ updated_states=updated_states,
92
+ files_scanned=files_scanned,
93
+ malformed_lines=malformed_lines,
94
+ )
95
+
96
+ def _find_jsonl_files(self) -> List[Path]:
97
+ """Find all jsonl session files recursively under self.claude_dir."""
98
+ files: List[Path] = []
99
+ try:
100
+ for root, _, filenames in os.walk(self.claude_dir):
101
+ for fname in filenames:
102
+ if fname.endswith(".jsonl") and fname != "history.jsonl":
103
+ files.append(Path(root) / fname)
104
+ except PermissionError as e:
105
+ logger.warning(f"Permission denied accessing {self.claude_dir}: {e}")
106
+ return files
107
+
108
+ def _scan_file(
109
+ self, file_path: Path, start_offset: int
110
+ ) -> tuple[List[UsageRecord], List[SessionPrompt], List[SessionCodeChange], int, int]:
111
+ """Scan a single JSONL file from start_offset to EOF."""
112
+ records: List[UsageRecord] = []
113
+ prompts: List[SessionPrompt] = []
114
+ changes: List[SessionCodeChange] = []
115
+ malformed = 0
116
+ current_offset = start_offset
117
+
118
+ session_id_from_path = file_path.stem if file_path.name != "history.jsonl" else None
119
+ project_path_from_path = self._infer_project_path(file_path)
120
+ project_name = self._project_name_from_path(project_path_from_path) if project_path_from_path else None
121
+
122
+ line_index = 0
123
+ with open(file_path, "r", encoding="utf-8", errors="replace") as f:
124
+ if start_offset > 0:
125
+ f.seek(start_offset)
126
+
127
+ while True:
128
+ line = f.readline()
129
+ if not line:
130
+ break
131
+
132
+ current_offset = f.tell()
133
+ line_index += 1
134
+ line_str = line.strip()
135
+ if not line_str:
136
+ continue
137
+
138
+ try:
139
+ data = json.loads(line_str)
140
+ except Exception:
141
+ malformed += 1
142
+ continue
143
+
144
+ session_id = data.get("sessionId") or session_id_from_path or "unknown_session"
145
+ cwd = data.get("cwd") or project_path_from_path
146
+ p_name = self._project_name_from_path(cwd) if cwd else project_name
147
+
148
+ # 1. Parse assistant usage
149
+ rec = self._parse_usage_record(data, str(file_path.resolve()), session_id, cwd, p_name)
150
+ if rec:
151
+ records.append(rec)
152
+
153
+ # 2. Parse user prompt
154
+ prompt = self._parse_user_prompt(data, session_id, p_name, line_index)
155
+ if prompt:
156
+ prompts.append(prompt)
157
+
158
+ # 3. Parse code changes / file snapshots / tool edits
159
+ file_changes = self._parse_code_changes(data, session_id, p_name, line_index)
160
+ if file_changes:
161
+ changes.extend(file_changes)
162
+
163
+ return records, prompts, changes, current_offset, malformed
164
+
165
+ def _parse_usage_record(
166
+ self,
167
+ data: dict,
168
+ file_path: str,
169
+ session_id: str,
170
+ cwd: Optional[str],
171
+ project_name: Optional[str],
172
+ ) -> Optional[UsageRecord]:
173
+ if not isinstance(data, dict):
174
+ return None
175
+
176
+ msg = data.get("message")
177
+ if not isinstance(msg, dict):
178
+ msg = {}
179
+
180
+ usage = msg.get("usage")
181
+ if not isinstance(usage, dict):
182
+ usage = data.get("usage")
183
+ if not isinstance(usage, dict):
184
+ return None
185
+
186
+ input_tokens = int(usage.get("input_tokens", 0) or 0)
187
+ output_tokens = int(usage.get("output_tokens", 0) or 0)
188
+ cache_read_tokens = int(usage.get("cache_read_input_tokens", 0) or 0)
189
+ cache_write_tokens = int(usage.get("cache_creation_input_tokens", 0) or 0)
190
+
191
+ if (input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) == 0:
192
+ return None
193
+
194
+ model = msg.get("model") or data.get("model") or "unknown"
195
+ if not isinstance(model, str):
196
+ model = str(model)
197
+
198
+ raw_ts = data.get("timestamp") or msg.get("timestamp")
199
+ ts = self._parse_timestamp(raw_ts)
200
+
201
+ request_id = msg.get("id") or data.get("uuid") or data.get("id")
202
+ if not request_id or not isinstance(request_id, str):
203
+ request_id = self._generate_deterministic_id(
204
+ file_path=file_path,
205
+ timestamp=ts,
206
+ model=model,
207
+ input_tokens=input_tokens,
208
+ output_tokens=output_tokens,
209
+ cache_read_tokens=cache_read_tokens,
210
+ cache_write_tokens=cache_write_tokens,
211
+ )
212
+
213
+ return UsageRecord(
214
+ provider="claude_code",
215
+ request_id=request_id,
216
+ session_id=session_id,
217
+ timestamp=ts,
218
+ user_id=data.get("user_id"),
219
+ project_id=project_name,
220
+ project_path=cwd,
221
+ model=model,
222
+ input_tokens=input_tokens,
223
+ output_tokens=output_tokens,
224
+ cache_read_tokens=cache_read_tokens,
225
+ cache_write_tokens=cache_write_tokens,
226
+ metadata={"file_path": file_path, "type": data.get("type")},
227
+ )
228
+
229
+ def _parse_user_prompt(
230
+ self, data: dict, session_id: str, project_id: Optional[str], line_index: int
231
+ ) -> Optional[SessionPrompt]:
232
+ """Extract user prompt text if line represents a user message."""
233
+ line_type = data.get("type")
234
+ msg = data.get("message") if isinstance(data.get("message"), dict) else {}
235
+ role = msg.get("role") or data.get("role")
236
+
237
+ if line_type != "user" and role != "user":
238
+ return None
239
+
240
+ content = msg.get("content") or data.get("content")
241
+ prompt_text = ""
242
+
243
+ if isinstance(content, str):
244
+ prompt_text = content.strip()
245
+ elif isinstance(content, list):
246
+ texts = []
247
+ for block in content:
248
+ if isinstance(block, str):
249
+ texts.append(block)
250
+ elif isinstance(block, dict) and block.get("type") == "text":
251
+ texts.append(block.get("text", ""))
252
+ prompt_text = "\n".join(texts).strip()
253
+
254
+ if not prompt_text or prompt_text.startswith("<local-command-caveat>"):
255
+ return None
256
+
257
+ raw_ts = data.get("timestamp") or msg.get("timestamp")
258
+ ts = self._parse_timestamp(raw_ts)
259
+
260
+ prompt_id = data.get("promptId") or data.get("uuid") or f"prompt_{session_id}_{line_index}"
261
+
262
+ return SessionPrompt(
263
+ provider="claude_code",
264
+ session_id=session_id,
265
+ prompt_id=str(prompt_id),
266
+ timestamp=ts,
267
+ prompt_text=prompt_text,
268
+ project_id=project_id,
269
+ )
270
+
271
+ def _parse_code_changes(
272
+ self, data: dict, session_id: str, project_id: Optional[str], line_index: int
273
+ ) -> List[SessionCodeChange]:
274
+ """Extract code changes, file history snapshots, or edit tool calls."""
275
+ changes: List[SessionCodeChange] = []
276
+ raw_ts = data.get("timestamp")
277
+ ts = self._parse_timestamp(raw_ts)
278
+
279
+ # 1. file-history-snapshot
280
+ if data.get("type") == "file-history-snapshot":
281
+ snapshot = data.get("snapshot", {})
282
+ tracked = snapshot.get("trackedFileBackups", {})
283
+ if isinstance(tracked, dict):
284
+ for fpath, backup in tracked.items():
285
+ change_id = f"snap_{session_id}_{hashlib.md5(fpath.encode()).hexdigest()[:8]}_{line_index}"
286
+ summary = f"File snapshot backup: {backup.get('backupFileName', 'modified')}" if isinstance(backup, dict) else "File snapshot update"
287
+ changes.append(
288
+ SessionCodeChange(
289
+ provider="claude_code",
290
+ session_id=session_id,
291
+ change_id=change_id,
292
+ timestamp=ts,
293
+ file_path=str(fpath),
294
+ change_type="snapshot",
295
+ diff_summary=summary,
296
+ project_id=project_id,
297
+ )
298
+ )
299
+
300
+ # 2. Assistant tool call edits (Edit, Write, Replace, etc.)
301
+ msg = data.get("message")
302
+ if isinstance(msg, dict) and isinstance(msg.get("content"), list):
303
+ for idx, block in enumerate(msg.get("content", [])):
304
+ if isinstance(block, dict) and block.get("type") == "tool_use":
305
+ tname = block.get("name", "")
306
+ tinput = block.get("input", {})
307
+ if isinstance(tinput, dict) and tname in ["Edit", "Write", "Replace", "StrReplace", "NotebookEditCell", "WriteFile", "EditFile"]:
308
+ fpath = tinput.get("file_path") or tinput.get("path") or tinput.get("target_file") or "unknown_file"
309
+ old_str = tinput.get("old_string") or tinput.get("TargetContent") or ""
310
+ new_str = tinput.get("new_string") or tinput.get("ReplacementContent") or tinput.get("content") or ""
311
+
312
+ diff_lines = []
313
+ if old_str:
314
+ diff_lines.append(f"- {old_str[:150]}")
315
+ if new_str:
316
+ diff_lines.append(f"+ {new_str[:150]}")
317
+ diff_summary = "\n".join(diff_lines) if diff_lines else f"Tool action: {tname}"
318
+
319
+ change_id = block.get("id") or f"edit_{session_id}_{line_index}_{idx}"
320
+ changes.append(
321
+ SessionCodeChange(
322
+ provider="claude_code",
323
+ session_id=session_id,
324
+ change_id=str(change_id),
325
+ timestamp=ts,
326
+ file_path=str(fpath),
327
+ change_type=tname.lower(),
328
+ diff_summary=diff_summary,
329
+ project_id=project_id,
330
+ )
331
+ )
332
+
333
+ return changes
334
+
335
+ def _parse_timestamp(self, raw_ts: Optional[str]) -> datetime:
336
+ if not raw_ts:
337
+ return datetime.now(timezone.utc)
338
+ if isinstance(raw_ts, (int, float)):
339
+ return datetime.fromtimestamp(raw_ts / 1000.0, tz=timezone.utc)
340
+ try:
341
+ ts_str = str(raw_ts).replace("Z", "+00:00")
342
+ return datetime.fromisoformat(ts_str)
343
+ except ValueError:
344
+ return datetime.now(timezone.utc)
345
+
346
+ def _generate_deterministic_id(
347
+ self,
348
+ file_path: str,
349
+ timestamp: datetime,
350
+ model: str,
351
+ input_tokens: int,
352
+ output_tokens: int,
353
+ cache_read_tokens: int,
354
+ cache_write_tokens: int,
355
+ ) -> str:
356
+ seed = f"claude_code:{file_path}:{timestamp.isoformat()}:{model}:{input_tokens}:{output_tokens}:{cache_read_tokens}:{cache_write_tokens}"
357
+ return "gen_" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:24]
358
+
359
+ def _infer_project_path(self, file_path: Path) -> Optional[str]:
360
+ try:
361
+ parent_dir = file_path.parent.name
362
+ if parent_dir.startswith("C--") or parent_dir.startswith("-"):
363
+ parts = parent_dir.split("--")
364
+ if len(parts) >= 2:
365
+ drive = parts[0]
366
+ rest = parts[1].replace("-", "/")
367
+ return f"{drive}:/{rest}"
368
+ else:
369
+ return parent_dir.replace("-", "/")
370
+ except Exception:
371
+ pass
372
+ return None
373
+
374
+ def _project_name_from_path(self, path_str: Optional[str]) -> Optional[str]:
375
+ if not path_str:
376
+ return None
377
+ clean = path_str.rstrip("/\\")
378
+ return os.path.basename(clean) or clean