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,482 @@
1
+ """Repository for database persistence of usage records, prompts, code changes, scan state, and pricing."""
2
+
3
+ import json
4
+ import logging
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from code_meter.models.usage import SessionCodeChange, SessionPrompt, UsageRecord
10
+ from code_meter.providers.base import FileScanState
11
+ from code_meter.storage.database import Database
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class UsageRepository:
17
+ """DAO for usage events, prompts, code changes, scan states, and pricing lookup."""
18
+
19
+ def __init__(self, db: Database):
20
+ self.db = db
21
+ self.db.initialize_schema()
22
+
23
+ def save_records(self, records: List[UsageRecord]) -> int:
24
+ """Batch insert usage records into SQLite, ignoring duplicates based on UNIQUE(provider, request_id)."""
25
+ if not records:
26
+ return 0
27
+
28
+ inserted_count = 0
29
+ now_str = datetime.now(timezone.utc).isoformat()
30
+
31
+ conn = self.db.get_connection()
32
+ try:
33
+ cursor = conn.cursor()
34
+ query = """
35
+ INSERT OR IGNORE INTO usage_events (
36
+ provider, request_id, session_id, timestamp,
37
+ user_id, project_id, project_path, model,
38
+ input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
39
+ metadata_json, agent_id, task_id, tool_name, created_at
40
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
41
+ """
42
+
43
+ batch_data = []
44
+ for r in records:
45
+ meta_json = json.dumps(r.metadata) if r.metadata else None
46
+ ts_str = r.timestamp.isoformat() if isinstance(r.timestamp, datetime) else str(r.timestamp)
47
+ batch_data.append((
48
+ r.provider,
49
+ r.request_id,
50
+ r.session_id,
51
+ ts_str,
52
+ r.user_id,
53
+ r.project_id,
54
+ r.project_path,
55
+ r.model,
56
+ r.input_tokens,
57
+ r.output_tokens,
58
+ r.cache_read_tokens,
59
+ r.cache_write_tokens,
60
+ meta_json,
61
+ r.agent_id,
62
+ r.task_id,
63
+ r.tool_name,
64
+ now_str,
65
+ ))
66
+
67
+ cursor.executemany(query, batch_data)
68
+ inserted_count = cursor.rowcount if cursor.rowcount > 0 else 0
69
+ conn.commit()
70
+ except Exception as e:
71
+ conn.rollback()
72
+ logger.error(f"Error saving records to database: {e}", exc_info=True)
73
+ raise
74
+ finally:
75
+ conn.close()
76
+
77
+ return inserted_count
78
+
79
+ def save_prompts(self, prompts: List[SessionPrompt]) -> int:
80
+ """Batch insert user session prompts into SQLite."""
81
+ if not prompts:
82
+ return 0
83
+
84
+ now_str = datetime.now(timezone.utc).isoformat()
85
+ conn = self.db.get_connection()
86
+ inserted_count = 0
87
+ try:
88
+ cursor = conn.cursor()
89
+ query = """
90
+ INSERT OR IGNORE INTO session_prompts (
91
+ provider, session_id, prompt_id, timestamp, prompt_text, project_id, created_at
92
+ ) VALUES (?, ?, ?, ?, ?, ?, ?);
93
+ """
94
+ batch_data = [
95
+ (
96
+ p.provider,
97
+ p.session_id,
98
+ p.prompt_id,
99
+ p.timestamp.isoformat() if isinstance(p.timestamp, datetime) else str(p.timestamp),
100
+ p.prompt_text,
101
+ p.project_id,
102
+ now_str,
103
+ )
104
+ for p in prompts
105
+ ]
106
+ cursor.executemany(query, batch_data)
107
+ inserted_count = cursor.rowcount if cursor.rowcount > 0 else 0
108
+ conn.commit()
109
+ finally:
110
+ conn.close()
111
+
112
+ return inserted_count
113
+
114
+ def save_code_changes(self, changes: List[SessionCodeChange]) -> int:
115
+ """Batch insert session code changes / diffs into SQLite."""
116
+ if not changes:
117
+ return 0
118
+
119
+ now_str = datetime.now(timezone.utc).isoformat()
120
+ conn = self.db.get_connection()
121
+ inserted_count = 0
122
+ try:
123
+ cursor = conn.cursor()
124
+ query = """
125
+ INSERT OR IGNORE INTO session_code_changes (
126
+ provider, session_id, change_id, timestamp, file_path, change_type, diff_summary, project_id, created_at
127
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
128
+ """
129
+ batch_data = [
130
+ (
131
+ c.provider,
132
+ c.session_id,
133
+ c.change_id,
134
+ c.timestamp.isoformat() if isinstance(c.timestamp, datetime) else str(c.timestamp),
135
+ c.file_path,
136
+ c.change_type,
137
+ c.diff_summary,
138
+ c.project_id,
139
+ now_str,
140
+ )
141
+ for c in changes
142
+ ]
143
+ cursor.executemany(query, batch_data)
144
+ inserted_count = cursor.rowcount if cursor.rowcount > 0 else 0
145
+ conn.commit()
146
+ finally:
147
+ conn.close()
148
+
149
+ return inserted_count
150
+
151
+ def get_scan_states(self) -> Dict[str, FileScanState]:
152
+ """Load scan states for incremental scanning."""
153
+ states: Dict[str, FileScanState] = {}
154
+ conn = self.db.get_connection()
155
+ try:
156
+ cursor = conn.cursor()
157
+ cursor.execute("SELECT file_path, file_size, modified_time, read_offset FROM scan_state;")
158
+ for row in cursor.fetchall():
159
+ states[row["file_path"]] = FileScanState(
160
+ file_path=row["file_path"],
161
+ file_size=row["file_size"],
162
+ modified_time=row["modified_time"],
163
+ read_offset=row["read_offset"],
164
+ )
165
+ finally:
166
+ conn.close()
167
+ return states
168
+
169
+ def update_scan_states(self, states: List[FileScanState]) -> None:
170
+ """Upsert file scan states."""
171
+ if not states:
172
+ return
173
+ now_str = datetime.now(timezone.utc).isoformat()
174
+ conn = self.db.get_connection()
175
+ try:
176
+ cursor = conn.cursor()
177
+ query = """
178
+ INSERT INTO scan_state (file_path, file_size, modified_time, read_offset, updated_at)
179
+ VALUES (?, ?, ?, ?, ?)
180
+ ON CONFLICT(file_path) DO UPDATE SET
181
+ file_size = excluded.file_size,
182
+ modified_time = excluded.modified_time,
183
+ read_offset = excluded.read_offset,
184
+ updated_at = excluded.updated_at;
185
+ """
186
+ batch_data = [(s.file_path, s.file_size, s.modified_time, s.read_offset, now_str) for s in states]
187
+ cursor.executemany(query, batch_data)
188
+ conn.commit()
189
+ finally:
190
+ conn.close()
191
+
192
+ def save_pricing(self, pricing_records: List[Dict[str, Any]]) -> None:
193
+ """Upsert model pricing definitions into SQLite."""
194
+ if not pricing_records:
195
+ return
196
+ conn = self.db.get_connection()
197
+ try:
198
+ cursor = conn.cursor()
199
+ query = """
200
+ INSERT INTO pricing (
201
+ provider, model, input_price_per_million, output_price_per_million,
202
+ cache_read_price_per_million, cache_write_price_per_million, effective_from
203
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
204
+ ON CONFLICT(provider, model, effective_from) DO UPDATE SET
205
+ input_price_per_million = excluded.input_price_per_million,
206
+ output_price_per_million = excluded.output_price_per_million,
207
+ cache_read_price_per_million = excluded.cache_read_price_per_million,
208
+ cache_write_price_per_million = excluded.cache_write_price_per_million;
209
+ """
210
+ batch_data = [
211
+ (
212
+ p["provider"],
213
+ p["model"],
214
+ p["input_price_per_million"],
215
+ p["output_price_per_million"],
216
+ p.get("cache_read_price_per_million", 0.0),
217
+ p.get("cache_write_price_per_million", 0.0),
218
+ p.get("effective_from", "2020-01-01T00:00:00Z"),
219
+ )
220
+ for p in pricing_records
221
+ ]
222
+ cursor.executemany(query, batch_data)
223
+ conn.commit()
224
+ finally:
225
+ conn.close()
226
+
227
+ def get_pricing_table(self) -> List[Dict[str, Any]]:
228
+ """Retrieve all pricing entries."""
229
+ conn = self.db.get_connection()
230
+ try:
231
+ cursor = conn.cursor()
232
+ cursor.execute(
233
+ """
234
+ SELECT provider, model, input_price_per_million, output_price_per_million,
235
+ cache_read_price_per_million, cache_write_price_per_million, effective_from
236
+ FROM pricing
237
+ ORDER BY provider, model, effective_from DESC;
238
+ """
239
+ )
240
+ return [dict(row) for row in cursor.fetchall()]
241
+ finally:
242
+ conn.close()
243
+
244
+ def query_usage_events(
245
+ self,
246
+ provider: Optional[str] = None,
247
+ model: Optional[str] = None,
248
+ project_id: Optional[str] = None,
249
+ session_id: Optional[str] = None,
250
+ start_date: Optional[datetime] = None,
251
+ end_date: Optional[datetime] = None,
252
+ limit: Optional[int] = None,
253
+ ) -> List[UsageRecord]:
254
+ """Query usage events with flexible filtering options."""
255
+ conditions: List[str] = []
256
+ params: List[Any] = []
257
+
258
+ if provider:
259
+ conditions.append("provider = ?")
260
+ params.append(provider)
261
+ if model:
262
+ conditions.append("model LIKE ?")
263
+ params.append(f"%{model}%")
264
+ if project_id:
265
+ conditions.append("(project_id LIKE ? OR project_path LIKE ?)")
266
+ params.append(f"%{project_id}%")
267
+ params.append(f"%{project_id}%")
268
+ if session_id:
269
+ conditions.append("session_id = ?")
270
+ params.append(session_id)
271
+ if start_date:
272
+ conditions.append("timestamp >= ?")
273
+ params.append(start_date.isoformat())
274
+ if end_date:
275
+ conditions.append("timestamp <= ?")
276
+ params.append(end_date.isoformat())
277
+
278
+ where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
279
+ limit_clause = f"LIMIT {limit}" if limit else ""
280
+
281
+ query = f"""
282
+ SELECT provider, request_id, session_id, timestamp,
283
+ user_id, project_id, project_path, model,
284
+ input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
285
+ metadata_json, agent_id, task_id, tool_name
286
+ FROM usage_events
287
+ {where_clause}
288
+ ORDER BY timestamp DESC
289
+ {limit_clause};
290
+ """
291
+
292
+ conn = self.db.get_connection()
293
+ records: List[UsageRecord] = []
294
+ try:
295
+ cursor = conn.cursor()
296
+ cursor.execute(query, params)
297
+ for row in cursor.fetchall():
298
+ meta = json.loads(row["metadata_json"]) if row["metadata_json"] else {}
299
+ ts_val = row["timestamp"]
300
+ try:
301
+ ts = datetime.fromisoformat(ts_val)
302
+ except ValueError:
303
+ ts = datetime.now(timezone.utc)
304
+
305
+ records.append(
306
+ UsageRecord(
307
+ provider=row["provider"],
308
+ request_id=row["request_id"],
309
+ session_id=row["session_id"],
310
+ timestamp=ts,
311
+ user_id=row["user_id"],
312
+ project_id=row["project_id"],
313
+ project_path=row["project_path"],
314
+ model=row["model"],
315
+ input_tokens=row["input_tokens"],
316
+ output_tokens=row["output_tokens"],
317
+ cache_read_tokens=row["cache_read_tokens"],
318
+ cache_write_tokens=row["cache_write_tokens"],
319
+ metadata=meta,
320
+ agent_id=row["agent_id"],
321
+ task_id=row["task_id"],
322
+ tool_name=row["tool_name"],
323
+ )
324
+ )
325
+ finally:
326
+ conn.close()
327
+
328
+ return records
329
+
330
+ def query_prompts(
331
+ self,
332
+ session_id: Optional[str] = None,
333
+ project_id: Optional[str] = None,
334
+ limit: Optional[int] = 50,
335
+ ) -> List[SessionPrompt]:
336
+ """Query user prompts asked per session or project."""
337
+ conditions: List[str] = []
338
+ params: List[Any] = []
339
+
340
+ if session_id:
341
+ conditions.append("session_id = ?")
342
+ params.append(session_id)
343
+ if project_id:
344
+ conditions.append("project_id LIKE ?")
345
+ params.append(f"%{project_id}%")
346
+
347
+ where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
348
+ limit_clause = f"LIMIT {limit}" if limit else ""
349
+
350
+ query = f"""
351
+ SELECT provider, session_id, prompt_id, timestamp, prompt_text, project_id
352
+ FROM session_prompts
353
+ {where_clause}
354
+ ORDER BY timestamp DESC
355
+ {limit_clause};
356
+ """
357
+
358
+ conn = self.db.get_connection()
359
+ prompts: List[SessionPrompt] = []
360
+ try:
361
+ cursor = conn.cursor()
362
+ cursor.execute(query, params)
363
+ for row in cursor.fetchall():
364
+ ts_val = row["timestamp"]
365
+ try:
366
+ ts = datetime.fromisoformat(ts_val)
367
+ except ValueError:
368
+ ts = datetime.now(timezone.utc)
369
+
370
+ prompts.append(
371
+ SessionPrompt(
372
+ provider=row["provider"],
373
+ session_id=row["session_id"],
374
+ prompt_id=row["prompt_id"],
375
+ timestamp=ts,
376
+ prompt_text=row["prompt_text"],
377
+ project_id=row["project_id"],
378
+ )
379
+ )
380
+ finally:
381
+ conn.close()
382
+
383
+ return prompts
384
+
385
+ def query_code_changes(
386
+ self,
387
+ session_id: Optional[str] = None,
388
+ project_id: Optional[str] = None,
389
+ file_path: Optional[str] = None,
390
+ limit: Optional[int] = 50,
391
+ ) -> List[SessionCodeChange]:
392
+ """Query code changes / diff history per session or file."""
393
+ conditions: List[str] = []
394
+ params: List[Any] = []
395
+
396
+ if session_id:
397
+ conditions.append("session_id = ?")
398
+ params.append(session_id)
399
+ if project_id:
400
+ conditions.append("project_id LIKE ?")
401
+ params.append(f"%{project_id}%")
402
+ if file_path:
403
+ conditions.append("file_path LIKE ?")
404
+ params.append(f"%{file_path}%")
405
+
406
+ where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
407
+ limit_clause = f"LIMIT {limit}" if limit else ""
408
+
409
+ query = f"""
410
+ SELECT provider, session_id, change_id, timestamp, file_path, change_type, diff_summary, project_id
411
+ FROM session_code_changes
412
+ {where_clause}
413
+ ORDER BY timestamp DESC
414
+ {limit_clause};
415
+ """
416
+
417
+ conn = self.db.get_connection()
418
+ changes: List[SessionCodeChange] = []
419
+ try:
420
+ cursor = conn.cursor()
421
+ cursor.execute(query, params)
422
+ for row in cursor.fetchall():
423
+ ts_val = row["timestamp"]
424
+ try:
425
+ ts = datetime.fromisoformat(ts_val)
426
+ except ValueError:
427
+ ts = datetime.now(timezone.utc)
428
+
429
+ changes.append(
430
+ SessionCodeChange(
431
+ provider=row["provider"],
432
+ session_id=row["session_id"],
433
+ change_id=row["change_id"],
434
+ timestamp=ts,
435
+ file_path=row["file_path"],
436
+ change_type=row["change_type"],
437
+ diff_summary=row["diff_summary"],
438
+ project_id=row["project_id"],
439
+ )
440
+ )
441
+ finally:
442
+ conn.close()
443
+
444
+ return changes
445
+
446
+ def get_status_summary(self) -> Dict[str, Any]:
447
+ """Return system status summary stats including prompts and code changes."""
448
+ conn = self.db.get_connection()
449
+ try:
450
+ cursor = conn.cursor()
451
+ cursor.execute("SELECT COUNT(*) FROM usage_events;")
452
+ total_records = cursor.fetchone()[0]
453
+
454
+ cursor.execute("SELECT COUNT(*) FROM session_prompts;")
455
+ total_prompts = cursor.fetchone()[0]
456
+
457
+ cursor.execute("SELECT COUNT(*) FROM session_code_changes;")
458
+ total_changes = cursor.fetchone()[0]
459
+
460
+ cursor.execute("SELECT COUNT(DISTINCT session_id) FROM usage_events WHERE session_id IS NOT NULL;")
461
+ unique_sessions = cursor.fetchone()[0]
462
+
463
+ cursor.execute("SELECT COUNT(DISTINCT project_id) FROM usage_events WHERE project_id IS NOT NULL;")
464
+ unique_projects = cursor.fetchone()[0]
465
+
466
+ cursor.execute("SELECT COUNT(*) FROM scan_state;")
467
+ scanned_files = cursor.fetchone()[0]
468
+
469
+ db_bytes = self.db.db_path.stat().st_size if self.db.db_path.exists() else 0
470
+
471
+ return {
472
+ "db_path": str(self.db.db_path),
473
+ "db_size_bytes": db_bytes,
474
+ "total_records": total_records,
475
+ "total_prompts": total_prompts,
476
+ "total_code_changes": total_changes,
477
+ "unique_sessions": unique_sessions,
478
+ "unique_projects": unique_projects,
479
+ "scanned_files": scanned_files,
480
+ }
481
+ finally:
482
+ conn.close()
@@ -0,0 +1,19 @@
1
+ """UI package for terminal rendering with Rich."""
2
+
3
+ from code_meter.ui.dashboard import render_dashboard
4
+ from code_meter.ui.tables import (
5
+ render_models_table,
6
+ render_projects_table,
7
+ render_sessions_table,
8
+ render_daily_table,
9
+ render_status_panel,
10
+ )
11
+
12
+ __all__ = [
13
+ "render_dashboard",
14
+ "render_models_table",
15
+ "render_projects_table",
16
+ "render_sessions_table",
17
+ "render_daily_table",
18
+ "render_status_panel",
19
+ ]
@@ -0,0 +1,111 @@
1
+ """Interactive Rich terminal dashboard layout."""
2
+
3
+ from typing import Optional
4
+ from rich.console import Console
5
+ from rich.panel import Panel
6
+ from rich.table import Table
7
+ from rich.text import Text
8
+
9
+ from code_meter.models.project import AggregateSummary
10
+ from code_meter.ui.tables import format_cost, format_tokens
11
+
12
+
13
+ def render_dashboard(
14
+ summary: AggregateSummary,
15
+ title_suffix: str = "Overview",
16
+ console: Optional[Console] = None,
17
+ ) -> None:
18
+ c = console or Console()
19
+
20
+ header_text = Text()
21
+ header_text.append("CODE METER\n", style="bold cyan splash")
22
+ header_text.append(f"{title_suffix}", style="dim white")
23
+ header_panel = Panel(header_text, style="bold blue", expand=True)
24
+
25
+ metrics_table = Table.grid(padding=(0, 4))
26
+ metrics_table.add_column(style="bold white")
27
+ metrics_table.add_column(style="bold yellow", justify="right")
28
+
29
+ metrics_table.add_row("Requests", f"{summary.total_requests:,}")
30
+ metrics_table.add_row("Input Tokens", format_tokens(summary.input_tokens))
31
+ metrics_table.add_row("Output Tokens", format_tokens(summary.output_tokens))
32
+ metrics_table.add_row("Cache Read", format_tokens(summary.cache_read_tokens))
33
+ metrics_table.add_row("Cache Write", format_tokens(summary.cache_write_tokens))
34
+ metrics_table.add_row("Total Tokens", format_tokens(summary.total_tokens))
35
+ metrics_table.add_row("", "")
36
+ metrics_table.add_row("Estimated Cost", format_cost(summary.estimated_cost_usd))
37
+
38
+ metrics_panel = Panel(
39
+ metrics_table,
40
+ title="[bold green]Key Metrics[/bold green]",
41
+ border_style="green",
42
+ )
43
+
44
+ model_table = Table(title="BY MODEL", header_style="bold magenta", expand=True)
45
+ model_table.add_column("Model", style="cyan")
46
+ model_table.add_column("Tokens", justify="right", style="bold")
47
+ model_table.add_column("Cost", justify="right", style="bold green")
48
+
49
+ for m in summary.by_model[:5]:
50
+ model_table.add_row(
51
+ m.model,
52
+ format_tokens(m.total_tokens),
53
+ format_cost(m.estimated_cost_usd),
54
+ )
55
+
56
+ project_table = Table(title="BY PROJECT", header_style="bold blue", expand=True)
57
+ project_table.add_column("Project", style="cyan")
58
+ project_table.add_column("Tokens", justify="right", style="bold")
59
+ project_table.add_column("Cost", justify="right", style="bold green")
60
+
61
+ for p in summary.by_project[:5]:
62
+ project_table.add_row(
63
+ p.project_name,
64
+ format_tokens(p.total_tokens),
65
+ format_cost(p.estimated_cost_usd),
66
+ )
67
+
68
+ today_grid = Table.grid(padding=(0, 2))
69
+ today_grid.add_column(style="bold white")
70
+ today_grid.add_column(style="bold yellow", justify="right")
71
+
72
+ if summary.today_summary:
73
+ ts = summary.today_summary
74
+ today_grid.add_row("Requests", f"{ts.request_count:,}")
75
+ today_grid.add_row("Tokens", format_tokens(ts.total_tokens))
76
+ today_grid.add_row("Estimated Cost", format_cost(ts.estimated_cost_usd))
77
+ else:
78
+ today_grid.add_row("No usage recorded today", "")
79
+
80
+ today_panel = Panel(
81
+ today_grid,
82
+ title="[bold yellow]TODAY[/bold yellow]",
83
+ border_style="yellow",
84
+ )
85
+
86
+ burn_grid = Table.grid(padding=(0, 2))
87
+ burn_grid.add_column(style="bold white")
88
+ burn_grid.add_column(style="bold magenta", justify="right")
89
+
90
+ if summary.tokens_per_hour is not None:
91
+ burn_grid.add_row("Tokens/hour", format_tokens(int(summary.tokens_per_hour)))
92
+ if summary.cost_per_hour is not None:
93
+ burn_grid.add_row("Cost/hour", f"${summary.cost_per_hour:.2f}")
94
+ burn_grid.add_row("Tokens/day", format_tokens(int(summary.tokens_per_day or 0)))
95
+ if summary.cost_per_day is not None:
96
+ burn_grid.add_row("Cost/day", f"${summary.cost_per_day:.2f}")
97
+ else:
98
+ burn_grid.add_row("Insufficient data for burn rate", "")
99
+
100
+ burn_panel = Panel(
101
+ burn_grid,
102
+ title="[bold magenta]BURN RATE[/bold magenta]",
103
+ border_style="magenta",
104
+ )
105
+
106
+ c.print(header_panel)
107
+ c.print(metrics_panel)
108
+ c.print(model_table)
109
+ c.print(project_table)
110
+ c.print(today_panel)
111
+ c.print(burn_panel)