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.
- code_meter/__init__.py +3 -0
- code_meter/analytics/__init__.py +7 -0
- code_meter/analytics/costs.py +43 -0
- code_meter/analytics/reports.py +357 -0
- code_meter/analytics/tokens.py +51 -0
- code_meter/cli.py +334 -0
- code_meter/config.py +116 -0
- code_meter/models/__init__.py +13 -0
- code_meter/models/project.py +85 -0
- code_meter/models/usage.py +61 -0
- code_meter/pricing/__init__.py +14 -0
- code_meter/pricing/anthropic.py +78 -0
- code_meter/pricing/engine.py +97 -0
- code_meter/pricing/google.py +60 -0
- code_meter/pricing/openai.py +69 -0
- code_meter/providers/__init__.py +15 -0
- code_meter/providers/antigravity.py +358 -0
- code_meter/providers/base.py +42 -0
- code_meter/providers/claude_code.py +378 -0
- code_meter/providers/codex.py +356 -0
- code_meter/storage/__init__.py +6 -0
- code_meter/storage/database.py +147 -0
- code_meter/storage/repository.py +482 -0
- code_meter/ui/__init__.py +19 -0
- code_meter/ui/dashboard.py +111 -0
- code_meter/ui/tables.py +195 -0
- code_meter/watcher.py +56 -0
- code_meter-0.1.0.dist-info/METADATA +207 -0
- code_meter-0.1.0.dist-info/RECORD +33 -0
- code_meter-0.1.0.dist-info/WHEEL +5 -0
- code_meter-0.1.0.dist-info/entry_points.txt +6 -0
- code_meter-0.1.0.dist-info/licenses/LICENSE +21 -0
- code_meter-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,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()
|