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,356 @@
1
+ """Codex provider for scanning local OpenAI Codex CLI session logs (~/.codex)."""
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, Tuple
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 CodexProvider(UsageProvider):
18
+ """Scanner for local Codex / OpenAI Codex CLI session files."""
19
+
20
+ def __init__(self, codex_dir: Path):
21
+ self.codex_dir = Path(os.path.expanduser(codex_dir)).resolve()
22
+
23
+ @property
24
+ def provider_name(self) -> str:
25
+ return "codex"
26
+
27
+ def scan(self, existing_states: Optional[Dict[str, FileScanState]] = None) -> ScanResult:
28
+ """Incrementally scan session log files in Codex 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.codex_dir.exists():
38
+ logger.warning(f"Codex directory does not exist: {self.codex_dir}")
39
+ return ScanResult(records=[], prompts=[], code_changes=[], updated_states=[], files_scanned=0, malformed_lines=0)
40
+
41
+ log_files = self._find_log_files()
42
+
43
+ for file_path in log_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
+ start_offset = 0 if size < prev_state.file_size else prev_state.read_offset
58
+ else:
59
+ start_offset = 0
60
+
61
+ files_scanned += 1
62
+ new_records, new_prompts, new_changes, new_offset, malformed = self._scan_file(
63
+ file_path=file_path,
64
+ start_offset=start_offset,
65
+ )
66
+
67
+ records.extend(new_records)
68
+ prompts.extend(new_prompts)
69
+ code_changes.extend(new_changes)
70
+ malformed_lines += malformed
71
+
72
+ updated_states.append(
73
+ FileScanState(
74
+ file_path=file_str,
75
+ file_size=size,
76
+ modified_time=mtime,
77
+ read_offset=new_offset,
78
+ )
79
+ )
80
+
81
+ except Exception as e:
82
+ logger.error(f"Error scanning Codex file {file_path}: {e}", exc_info=True)
83
+
84
+ return ScanResult(
85
+ records=records,
86
+ prompts=prompts,
87
+ code_changes=code_changes,
88
+ updated_states=updated_states,
89
+ files_scanned=files_scanned,
90
+ malformed_lines=malformed_lines,
91
+ )
92
+
93
+ def _find_log_files(self) -> List[Path]:
94
+ """Recursively find session JSON / JSONL files under self.codex_dir."""
95
+ files: List[Path] = []
96
+ try:
97
+ for root, _, filenames in os.walk(self.codex_dir):
98
+ for fname in filenames:
99
+ if fname.endswith(".jsonl") or fname.endswith(".json"):
100
+ files.append(Path(root) / fname)
101
+ except PermissionError as e:
102
+ logger.warning(f"Permission denied accessing {self.codex_dir}: {e}")
103
+ return files
104
+
105
+ def _scan_file(
106
+ self, file_path: Path, start_offset: int
107
+ ) -> Tuple[List[UsageRecord], List[SessionPrompt], List[SessionCodeChange], int, int]:
108
+ """Scan log file from start_offset."""
109
+ records: List[UsageRecord] = []
110
+ prompts: List[SessionPrompt] = []
111
+ changes: List[SessionCodeChange] = []
112
+ malformed = 0
113
+ current_offset = start_offset
114
+
115
+ session_id_from_path = file_path.stem
116
+ project_name = file_path.parent.name if file_path.parent != self.codex_dir else None
117
+
118
+ line_index = 0
119
+
120
+ # Handle full JSON files vs JSONL line files
121
+ if file_path.suffix == ".json":
122
+ try:
123
+ with open(file_path, "r", encoding="utf-8", errors="replace") as f:
124
+ content = f.read()
125
+ data = json.loads(content)
126
+ current_offset = len(content.encode("utf-8"))
127
+
128
+ items = data if isinstance(data, list) else [data]
129
+ for item in items:
130
+ line_index += 1
131
+ rec, pmt, chg = self._process_data_item(item, str(file_path.resolve()), session_id_from_path, project_name, line_index)
132
+ if rec:
133
+ records.append(rec)
134
+ if pmt:
135
+ prompts.append(pmt)
136
+ if chg:
137
+ changes.extend(chg)
138
+ except Exception:
139
+ malformed += 1
140
+ return records, prompts, changes, current_offset, malformed
141
+
142
+ # JSONL files
143
+ with open(file_path, "r", encoding="utf-8", errors="replace") as f:
144
+ if start_offset > 0:
145
+ f.seek(start_offset)
146
+
147
+ while True:
148
+ line = f.readline()
149
+ if not line:
150
+ break
151
+
152
+ current_offset = f.tell()
153
+ line_index += 1
154
+ line_str = line.strip()
155
+ if not line_str:
156
+ continue
157
+
158
+ try:
159
+ data = json.loads(line_str)
160
+ except Exception:
161
+ malformed += 1
162
+ continue
163
+
164
+ rec, pmt, chg = self._process_data_item(data, str(file_path.resolve()), session_id_from_path, project_name, line_index)
165
+ if rec:
166
+ records.append(rec)
167
+ if pmt:
168
+ prompts.append(pmt)
169
+ if chg:
170
+ changes.extend(chg)
171
+
172
+ return records, prompts, changes, current_offset, malformed
173
+
174
+ def _process_data_item(
175
+ self,
176
+ data: dict,
177
+ file_path: str,
178
+ session_id_from_path: str,
179
+ project_name: Optional[str],
180
+ line_index: int,
181
+ ) -> Tuple[Optional[UsageRecord], Optional[SessionPrompt], List[SessionCodeChange]]:
182
+ if not isinstance(data, dict):
183
+ return None, None, []
184
+
185
+ session_id = data.get("session_id") or data.get("sessionId") or session_id_from_path or "unknown_session"
186
+ cwd = data.get("cwd") or data.get("workdir") or data.get("project_path")
187
+ p_name = data.get("project") or (os.path.basename(cwd) if cwd else project_name)
188
+
189
+ rec = self._parse_usage_record(data, file_path, session_id, cwd, p_name)
190
+ pmt = self._parse_user_prompt(data, session_id, p_name, line_index)
191
+ chg = self._parse_code_changes(data, session_id, p_name, line_index)
192
+
193
+ return rec, pmt, chg
194
+
195
+ def _parse_usage_record(
196
+ self,
197
+ data: dict,
198
+ file_path: str,
199
+ session_id: str,
200
+ cwd: Optional[str],
201
+ project_name: Optional[str],
202
+ ) -> Optional[UsageRecord]:
203
+ usage = data.get("usage") or data.get("response", {}).get("usage")
204
+ if not isinstance(usage, dict):
205
+ return None
206
+
207
+ input_tokens = int(usage.get("prompt_tokens") or usage.get("input_tokens") or 0)
208
+ output_tokens = int(usage.get("completion_tokens") or usage.get("output_tokens") or 0)
209
+
210
+ prompt_details = usage.get("prompt_tokens_details") if isinstance(usage.get("prompt_tokens_details"), dict) else {}
211
+ cache_read_tokens = int(prompt_details.get("cached_tokens") or usage.get("cache_read_tokens") or 0)
212
+ cache_write_tokens = int(usage.get("cache_write_tokens") or 0)
213
+
214
+ if (input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) == 0:
215
+ return None
216
+
217
+ model = data.get("model") or data.get("response", {}).get("model") or "gpt-4o"
218
+ if not isinstance(model, str):
219
+ model = str(model)
220
+
221
+ raw_ts = data.get("timestamp") or data.get("created_at") or data.get("time")
222
+ ts = self._parse_timestamp(raw_ts)
223
+
224
+ request_id = data.get("id") or data.get("request_id") or data.get("uuid")
225
+ if not request_id or not isinstance(request_id, str):
226
+ request_id = self._generate_deterministic_id(
227
+ file_path=file_path,
228
+ timestamp=ts,
229
+ model=model,
230
+ input_tokens=input_tokens,
231
+ output_tokens=output_tokens,
232
+ cache_read_tokens=cache_read_tokens,
233
+ cache_write_tokens=cache_write_tokens,
234
+ )
235
+
236
+ return UsageRecord(
237
+ provider="codex",
238
+ request_id=request_id,
239
+ session_id=session_id,
240
+ timestamp=ts,
241
+ user_id=data.get("user_id"),
242
+ project_id=project_name,
243
+ project_path=cwd,
244
+ model=model,
245
+ input_tokens=input_tokens,
246
+ output_tokens=output_tokens,
247
+ cache_read_tokens=cache_read_tokens,
248
+ cache_write_tokens=cache_write_tokens,
249
+ metadata={"file_path": file_path, "type": data.get("type")},
250
+ )
251
+
252
+ def _parse_user_prompt(
253
+ self, data: dict, session_id: str, project_id: Optional[str], line_index: int
254
+ ) -> Optional[SessionPrompt]:
255
+ role = data.get("role") or data.get("message", {}).get("role") or data.get("type")
256
+ if role not in ["user", "prompt", "user_input"]:
257
+ return None
258
+
259
+ content = data.get("prompt") or data.get("content") or data.get("message", {}).get("content")
260
+ prompt_text = ""
261
+
262
+ if isinstance(content, str):
263
+ prompt_text = content.strip()
264
+ elif isinstance(content, list):
265
+ texts = []
266
+ for item in content:
267
+ if isinstance(item, str):
268
+ texts.append(item)
269
+ elif isinstance(item, dict) and item.get("text"):
270
+ texts.append(item.get("text", ""))
271
+ prompt_text = "\n".join(texts).strip()
272
+
273
+ if not prompt_text:
274
+ return None
275
+
276
+ raw_ts = data.get("timestamp") or data.get("created_at")
277
+ ts = self._parse_timestamp(raw_ts)
278
+
279
+ prompt_id = data.get("prompt_id") or data.get("id") or f"codex_prompt_{session_id}_{line_index}"
280
+
281
+ return SessionPrompt(
282
+ provider="codex",
283
+ session_id=session_id,
284
+ prompt_id=str(prompt_id),
285
+ timestamp=ts,
286
+ prompt_text=prompt_text,
287
+ project_id=project_id,
288
+ )
289
+
290
+ def _parse_code_changes(
291
+ self, data: dict, session_id: str, project_id: Optional[str], line_index: int
292
+ ) -> List[SessionCodeChange]:
293
+ changes: List[SessionCodeChange] = []
294
+ raw_ts = data.get("timestamp") or data.get("created_at")
295
+ ts = self._parse_timestamp(raw_ts)
296
+
297
+ tool_calls = data.get("tool_calls") or data.get("message", {}).get("tool_calls")
298
+ if isinstance(tool_calls, list):
299
+ for idx, call in enumerate(tool_calls):
300
+ if isinstance(call, dict):
301
+ fn = call.get("function", {})
302
+ fname = fn.get("name", "")
303
+ if fname in ["edit_file", "write_file", "apply_patch", "str_replace", "replace_file_content"]:
304
+ args_raw = fn.get("arguments", {})
305
+ args = json.loads(args_raw) if isinstance(args_raw, str) else (args_raw if isinstance(args_raw, dict) else {})
306
+
307
+ fpath = args.get("file_path") or args.get("path") or args.get("TargetFile") or "unknown_file"
308
+ old_code = args.get("old_content") or args.get("TargetContent") or ""
309
+ new_code = args.get("new_content") or args.get("ReplacementContent") or args.get("content") or ""
310
+
311
+ diff_lines = []
312
+ if old_code:
313
+ diff_lines.append(f"- {old_code[:150]}")
314
+ if new_code:
315
+ diff_lines.append(f"+ {new_code[:150]}")
316
+ diff_summary = "\n".join(diff_lines) if diff_lines else f"Codex action: {fname}"
317
+
318
+ change_id = call.get("id") or f"codex_edit_{session_id}_{line_index}_{idx}"
319
+ changes.append(
320
+ SessionCodeChange(
321
+ provider="codex",
322
+ session_id=session_id,
323
+ change_id=str(change_id),
324
+ timestamp=ts,
325
+ file_path=str(fpath),
326
+ change_type=fname.lower(),
327
+ diff_summary=diff_summary,
328
+ project_id=project_id,
329
+ )
330
+ )
331
+
332
+ return changes
333
+
334
+ def _parse_timestamp(self, raw_ts: Optional[str]) -> datetime:
335
+ if not raw_ts:
336
+ return datetime.now(timezone.utc)
337
+ if isinstance(raw_ts, (int, float)):
338
+ return datetime.fromtimestamp(raw_ts if raw_ts < 1e11 else raw_ts / 1000.0, tz=timezone.utc)
339
+ try:
340
+ ts_str = str(raw_ts).replace("Z", "+00:00")
341
+ return datetime.fromisoformat(ts_str)
342
+ except ValueError:
343
+ return datetime.now(timezone.utc)
344
+
345
+ def _generate_deterministic_id(
346
+ self,
347
+ file_path: str,
348
+ timestamp: datetime,
349
+ model: str,
350
+ input_tokens: int,
351
+ output_tokens: int,
352
+ cache_read_tokens: int,
353
+ cache_write_tokens: int,
354
+ ) -> str:
355
+ seed = f"codex:{file_path}:{timestamp.isoformat()}:{model}:{input_tokens}:{output_tokens}:{cache_read_tokens}:{cache_write_tokens}"
356
+ return "gen_codex_" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:24]
@@ -0,0 +1,6 @@
1
+ """Storage package for SQLite database operations and repositories."""
2
+
3
+ from code_meter.storage.database import Database
4
+ from code_meter.storage.repository import UsageRepository
5
+
6
+ __all__ = ["Database", "UsageRepository"]
@@ -0,0 +1,147 @@
1
+ """SQLite Database connection and schema initialization."""
2
+
3
+ import os
4
+ import sqlite3
5
+ from pathlib import Path
6
+ from typing import Generator
7
+
8
+
9
+ class Database:
10
+ """Manages SQLite database connections and schema migrations."""
11
+
12
+ def __init__(self, db_path: Path):
13
+ self.db_path = Path(os.path.expanduser(db_path)).resolve()
14
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
15
+
16
+ def get_connection(self) -> sqlite3.Connection:
17
+ """Create a sqlite3 connection with WAL journal mode enabled for concurrency."""
18
+ conn = sqlite3.connect(str(self.db_path), timeout=30.0)
19
+ conn.row_factory = sqlite3.Row
20
+ conn.execute("PRAGMA journal_mode = WAL;")
21
+ conn.execute("PRAGMA foreign_keys = ON;")
22
+ return conn
23
+
24
+ def connection_context(self) -> Generator[sqlite3.Connection, None, None]:
25
+ """Context manager for sqlite connection handling."""
26
+ conn = self.get_connection()
27
+ try:
28
+ yield conn
29
+ conn.commit()
30
+ except Exception:
31
+ conn.rollback()
32
+ raise
33
+ finally:
34
+ conn.close()
35
+
36
+ def initialize_schema(self) -> None:
37
+ """Create database tables and indexes if they do not exist."""
38
+ conn = self.get_connection()
39
+ try:
40
+ cursor = conn.cursor()
41
+
42
+ # usage_events table
43
+ cursor.execute(
44
+ """
45
+ CREATE TABLE IF NOT EXISTS usage_events (
46
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
47
+ provider TEXT NOT NULL,
48
+ request_id TEXT NOT NULL,
49
+ session_id TEXT,
50
+ timestamp TEXT NOT NULL,
51
+ user_id TEXT,
52
+ project_id TEXT,
53
+ project_path TEXT,
54
+ model TEXT NOT NULL,
55
+ input_tokens INTEGER NOT NULL DEFAULT 0,
56
+ output_tokens INTEGER NOT NULL DEFAULT 0,
57
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
58
+ cache_write_tokens INTEGER NOT NULL DEFAULT 0,
59
+ metadata_json TEXT,
60
+ agent_id TEXT,
61
+ task_id TEXT,
62
+ tool_name TEXT,
63
+ created_at TEXT NOT NULL,
64
+ UNIQUE(provider, request_id)
65
+ );
66
+ """
67
+ )
68
+
69
+ # session_prompts table
70
+ cursor.execute(
71
+ """
72
+ CREATE TABLE IF NOT EXISTS session_prompts (
73
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
74
+ provider TEXT NOT NULL,
75
+ session_id TEXT NOT NULL,
76
+ prompt_id TEXT NOT NULL,
77
+ timestamp TEXT NOT NULL,
78
+ prompt_text TEXT NOT NULL,
79
+ project_id TEXT,
80
+ created_at TEXT NOT NULL,
81
+ UNIQUE(provider, prompt_id)
82
+ );
83
+ """
84
+ )
85
+
86
+ # session_code_changes table (tracks git diffs / code edits over time)
87
+ cursor.execute(
88
+ """
89
+ CREATE TABLE IF NOT EXISTS session_code_changes (
90
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
91
+ provider TEXT NOT NULL,
92
+ session_id TEXT NOT NULL,
93
+ change_id TEXT NOT NULL,
94
+ timestamp TEXT NOT NULL,
95
+ file_path TEXT NOT NULL,
96
+ change_type TEXT NOT NULL,
97
+ diff_summary TEXT NOT NULL,
98
+ project_id TEXT,
99
+ created_at TEXT NOT NULL,
100
+ UNIQUE(provider, change_id)
101
+ );
102
+ """
103
+ )
104
+
105
+ # scan_state table
106
+ cursor.execute(
107
+ """
108
+ CREATE TABLE IF NOT EXISTS scan_state (
109
+ file_path TEXT PRIMARY KEY,
110
+ file_size INTEGER NOT NULL,
111
+ modified_time REAL NOT NULL,
112
+ read_offset INTEGER NOT NULL,
113
+ updated_at TEXT NOT NULL
114
+ );
115
+ """
116
+ )
117
+
118
+ # pricing table
119
+ cursor.execute(
120
+ """
121
+ CREATE TABLE IF NOT EXISTS pricing (
122
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
123
+ provider TEXT NOT NULL,
124
+ model TEXT NOT NULL,
125
+ input_price_per_million REAL NOT NULL,
126
+ output_price_per_million REAL NOT NULL,
127
+ cache_read_price_per_million REAL NOT NULL DEFAULT 0.0,
128
+ cache_write_price_per_million REAL NOT NULL DEFAULT 0.0,
129
+ effective_from TEXT NOT NULL,
130
+ UNIQUE(provider, model, effective_from)
131
+ );
132
+ """
133
+ )
134
+
135
+ # Indexes for high performance queries
136
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_usage_ts ON usage_events(timestamp);")
137
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_usage_model ON usage_events(model);")
138
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_usage_project ON usage_events(project_id);")
139
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_usage_provider ON usage_events(provider);")
140
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_usage_session ON usage_events(session_id);")
141
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_prompts_session ON session_prompts(session_id);")
142
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_changes_session ON session_code_changes(session_id);")
143
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_changes_file ON session_code_changes(file_path);")
144
+
145
+ conn.commit()
146
+ finally:
147
+ conn.close()