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,78 @@
|
|
|
1
|
+
"""Default seed pricing data for Anthropic models."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List
|
|
4
|
+
|
|
5
|
+
DEFAULT_ANTHROPIC_PRICING: List[Dict[str, Any]] = [
|
|
6
|
+
{
|
|
7
|
+
"provider": "claude_code",
|
|
8
|
+
"model": "claude-3-7-sonnet-20250219",
|
|
9
|
+
"input_price_per_million": 3.00,
|
|
10
|
+
"output_price_per_million": 15.00,
|
|
11
|
+
"cache_read_price_per_million": 0.30,
|
|
12
|
+
"cache_write_price_per_million": 3.75,
|
|
13
|
+
"effective_from": "2025-02-19T00:00:00Z",
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"provider": "claude_code",
|
|
17
|
+
"model": "claude-3-5-sonnet-20241022",
|
|
18
|
+
"input_price_per_million": 3.00,
|
|
19
|
+
"output_price_per_million": 15.00,
|
|
20
|
+
"cache_read_price_per_million": 0.30,
|
|
21
|
+
"cache_write_price_per_million": 3.75,
|
|
22
|
+
"effective_from": "2024-10-22T00:00:00Z",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"provider": "claude_code",
|
|
26
|
+
"model": "claude-3-5-sonnet-20240620",
|
|
27
|
+
"input_price_per_million": 3.00,
|
|
28
|
+
"output_price_per_million": 15.00,
|
|
29
|
+
"cache_read_price_per_million": 0.30,
|
|
30
|
+
"cache_write_price_per_million": 3.75,
|
|
31
|
+
"effective_from": "2024-06-20T00:00:00Z",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"provider": "claude_code",
|
|
35
|
+
"model": "claude-sonnet",
|
|
36
|
+
"input_price_per_million": 3.00,
|
|
37
|
+
"output_price_per_million": 15.00,
|
|
38
|
+
"cache_read_price_per_million": 0.30,
|
|
39
|
+
"cache_write_price_per_million": 3.75,
|
|
40
|
+
"effective_from": "2024-01-01T00:00:00Z",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"provider": "claude_code",
|
|
44
|
+
"model": "claude-3-5-haiku-20241022",
|
|
45
|
+
"input_price_per_million": 0.80,
|
|
46
|
+
"output_price_per_million": 4.00,
|
|
47
|
+
"cache_read_price_per_million": 0.08,
|
|
48
|
+
"cache_write_price_per_million": 1.00,
|
|
49
|
+
"effective_from": "2024-10-22T00:00:00Z",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"provider": "claude_code",
|
|
53
|
+
"model": "claude-haiku",
|
|
54
|
+
"input_price_per_million": 0.80,
|
|
55
|
+
"output_price_per_million": 4.00,
|
|
56
|
+
"cache_read_price_per_million": 0.08,
|
|
57
|
+
"cache_write_price_per_million": 1.00,
|
|
58
|
+
"effective_from": "2024-01-01T00:00:00Z",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"provider": "claude_code",
|
|
62
|
+
"model": "claude-3-opus-20240229",
|
|
63
|
+
"input_price_per_million": 15.00,
|
|
64
|
+
"output_price_per_million": 75.00,
|
|
65
|
+
"cache_read_price_per_million": 1.50,
|
|
66
|
+
"cache_write_price_per_million": 18.75,
|
|
67
|
+
"effective_from": "2024-02-29T00:00:00Z",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"provider": "claude_code",
|
|
71
|
+
"model": "claude-opus",
|
|
72
|
+
"input_price_per_million": 15.00,
|
|
73
|
+
"output_price_per_million": 75.00,
|
|
74
|
+
"cache_read_price_per_million": 1.50,
|
|
75
|
+
"cache_write_price_per_million": 18.75,
|
|
76
|
+
"effective_from": "2024-01-01T00:00:00Z",
|
|
77
|
+
},
|
|
78
|
+
]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Dynamic Pricing Engine for calculating USD estimated cost of AI token usage."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
from code_meter.pricing.anthropic import DEFAULT_ANTHROPIC_PRICING
|
|
6
|
+
from code_meter.pricing.google import DEFAULT_GOOGLE_PRICING
|
|
7
|
+
from code_meter.pricing.openai import DEFAULT_OPENAI_PRICING
|
|
8
|
+
from code_meter.storage.repository import UsageRepository
|
|
9
|
+
|
|
10
|
+
DEFAULT_PRICING = DEFAULT_ANTHROPIC_PRICING + DEFAULT_OPENAI_PRICING + DEFAULT_GOOGLE_PRICING
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PricingEngine:
|
|
14
|
+
"""Calculates estimated token usage costs based on model pricing rules."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, repository: Optional[UsageRepository] = None):
|
|
17
|
+
self.repository = repository
|
|
18
|
+
self._pricing_cache: List[Dict[str, Any]] = []
|
|
19
|
+
self.reload_pricing()
|
|
20
|
+
|
|
21
|
+
def reload_pricing(self) -> None:
|
|
22
|
+
"""Seed initial pricing if needed and load active pricing records."""
|
|
23
|
+
if self.repository:
|
|
24
|
+
db_pricing = self.repository.get_pricing_table()
|
|
25
|
+
if not db_pricing:
|
|
26
|
+
self.repository.save_pricing(DEFAULT_PRICING)
|
|
27
|
+
db_pricing = self.repository.get_pricing_table()
|
|
28
|
+
self._pricing_cache = db_pricing
|
|
29
|
+
else:
|
|
30
|
+
self._pricing_cache = DEFAULT_PRICING
|
|
31
|
+
|
|
32
|
+
def calculate_cost(
|
|
33
|
+
self,
|
|
34
|
+
provider: str,
|
|
35
|
+
model: str,
|
|
36
|
+
input_tokens: int,
|
|
37
|
+
output_tokens: int,
|
|
38
|
+
cache_read_tokens: int = 0,
|
|
39
|
+
cache_write_tokens: int = 0,
|
|
40
|
+
timestamp: Optional[datetime] = None,
|
|
41
|
+
) -> Optional[float]:
|
|
42
|
+
"""Calculate USD estimated cost. Returns None if model pricing is unknown."""
|
|
43
|
+
rule = self._find_matching_rule(provider, model, timestamp)
|
|
44
|
+
if not rule:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
input_cost = (input_tokens / 1_000_000.0) * rule["input_price_per_million"]
|
|
48
|
+
output_cost = (output_tokens / 1_000_000.0) * rule["output_price_per_million"]
|
|
49
|
+
cache_read_cost = (cache_read_tokens / 1_000_000.0) * rule.get("cache_read_price_per_million", 0.0)
|
|
50
|
+
cache_write_cost = (cache_write_tokens / 1_000_000.0) * rule.get("cache_write_price_per_million", 0.0)
|
|
51
|
+
|
|
52
|
+
return round(input_cost + output_cost + cache_read_cost + cache_write_cost, 6)
|
|
53
|
+
|
|
54
|
+
def _find_matching_rule(
|
|
55
|
+
self,
|
|
56
|
+
provider: str,
|
|
57
|
+
model: str,
|
|
58
|
+
timestamp: Optional[datetime] = None,
|
|
59
|
+
) -> Optional[Dict[str, Any]]:
|
|
60
|
+
"""Find best matching pricing rule for provider and model based on string matching and effective date."""
|
|
61
|
+
if not model or model == "<synthetic>" or model == "unknown":
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
norm_model = model.lower().strip()
|
|
65
|
+
norm_provider = provider.lower().strip() if provider else ""
|
|
66
|
+
|
|
67
|
+
# Filter by provider if applicable
|
|
68
|
+
provider_cache = [
|
|
69
|
+
p for p in self._pricing_cache
|
|
70
|
+
if not norm_provider or p.get("provider", "").lower().strip() == norm_provider
|
|
71
|
+
]
|
|
72
|
+
search_pool = provider_cache if provider_cache else self._pricing_cache
|
|
73
|
+
|
|
74
|
+
# 1. Exact match
|
|
75
|
+
exact_matches = [p for p in search_pool if p["model"].lower().strip() == norm_model]
|
|
76
|
+
if exact_matches:
|
|
77
|
+
exact_matches.sort(key=lambda x: x.get("effective_from", ""), reverse=True)
|
|
78
|
+
return exact_matches[0]
|
|
79
|
+
|
|
80
|
+
# 2. Substring/family matches
|
|
81
|
+
candidates = []
|
|
82
|
+
for p in search_pool:
|
|
83
|
+
p_model = p["model"].lower().strip()
|
|
84
|
+
if p_model in norm_model or norm_model in p_model:
|
|
85
|
+
candidates.append(p)
|
|
86
|
+
elif "sonnet" in norm_model and "sonnet" in p_model:
|
|
87
|
+
candidates.append(p)
|
|
88
|
+
elif "haiku" in norm_model and "haiku" in p_model:
|
|
89
|
+
candidates.append(p)
|
|
90
|
+
elif "opus" in norm_model and "opus" in p_model:
|
|
91
|
+
candidates.append(p)
|
|
92
|
+
|
|
93
|
+
if not candidates:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
candidates.sort(key=lambda x: x.get("effective_from", ""), reverse=True)
|
|
97
|
+
return candidates[0]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Default seed pricing data for Google / Antigravity / Gemini models."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List
|
|
4
|
+
|
|
5
|
+
DEFAULT_GOOGLE_PRICING: List[Dict[str, Any]] = [
|
|
6
|
+
{
|
|
7
|
+
"provider": "antigravity",
|
|
8
|
+
"model": "gemini-2.5-flash",
|
|
9
|
+
"input_price_per_million": 0.15,
|
|
10
|
+
"output_price_per_million": 0.60,
|
|
11
|
+
"cache_read_price_per_million": 0.0375,
|
|
12
|
+
"cache_write_price_per_million": 0.00,
|
|
13
|
+
"effective_from": "2025-01-01T00:00:00Z",
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"provider": "antigravity",
|
|
17
|
+
"model": "gemini-3.6-flash",
|
|
18
|
+
"input_price_per_million": 0.15,
|
|
19
|
+
"output_price_per_million": 0.60,
|
|
20
|
+
"cache_read_price_per_million": 0.0375,
|
|
21
|
+
"cache_write_price_per_million": 0.00,
|
|
22
|
+
"effective_from": "2026-01-01T00:00:00Z",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"provider": "antigravity",
|
|
26
|
+
"model": "gemini-2.5-pro",
|
|
27
|
+
"input_price_per_million": 1.25,
|
|
28
|
+
"output_price_per_million": 5.00,
|
|
29
|
+
"cache_read_price_per_million": 0.3125,
|
|
30
|
+
"cache_write_price_per_million": 0.00,
|
|
31
|
+
"effective_from": "2025-01-01T00:00:00Z",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"provider": "antigravity",
|
|
35
|
+
"model": "gemini-1.5-pro",
|
|
36
|
+
"input_price_per_million": 1.25,
|
|
37
|
+
"output_price_per_million": 5.00,
|
|
38
|
+
"cache_read_price_per_million": 0.3125,
|
|
39
|
+
"cache_write_price_per_million": 0.00,
|
|
40
|
+
"effective_from": "2024-05-01T00:00:00Z",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"provider": "antigravity",
|
|
44
|
+
"model": "gemini-1.5-flash",
|
|
45
|
+
"input_price_per_million": 0.075,
|
|
46
|
+
"output_price_per_million": 0.30,
|
|
47
|
+
"cache_read_price_per_million": 0.01875,
|
|
48
|
+
"cache_write_price_per_million": 0.00,
|
|
49
|
+
"effective_from": "2024-05-01T00:00:00Z",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"provider": "antigravity",
|
|
53
|
+
"model": "antigravity",
|
|
54
|
+
"input_price_per_million": 0.15,
|
|
55
|
+
"output_price_per_million": 0.60,
|
|
56
|
+
"cache_read_price_per_million": 0.0375,
|
|
57
|
+
"cache_write_price_per_million": 0.00,
|
|
58
|
+
"effective_from": "2024-01-01T00:00:00Z",
|
|
59
|
+
},
|
|
60
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Default seed pricing data for OpenAI / Codex models."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List
|
|
4
|
+
|
|
5
|
+
DEFAULT_OPENAI_PRICING: List[Dict[str, Any]] = [
|
|
6
|
+
{
|
|
7
|
+
"provider": "codex",
|
|
8
|
+
"model": "gpt-4o",
|
|
9
|
+
"input_price_per_million": 2.50,
|
|
10
|
+
"output_price_per_million": 10.00,
|
|
11
|
+
"cache_read_price_per_million": 1.25,
|
|
12
|
+
"cache_write_price_per_million": 0.00,
|
|
13
|
+
"effective_from": "2024-05-13T00:00:00Z",
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"provider": "codex",
|
|
17
|
+
"model": "gpt-4o-mini",
|
|
18
|
+
"input_price_per_million": 0.15,
|
|
19
|
+
"output_price_per_million": 0.60,
|
|
20
|
+
"cache_read_price_per_million": 0.075,
|
|
21
|
+
"cache_write_price_per_million": 0.00,
|
|
22
|
+
"effective_from": "2024-07-18T00:00:00Z",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"provider": "codex",
|
|
26
|
+
"model": "o1",
|
|
27
|
+
"input_price_per_million": 15.00,
|
|
28
|
+
"output_price_per_million": 60.00,
|
|
29
|
+
"cache_read_price_per_million": 7.50,
|
|
30
|
+
"cache_write_price_per_million": 0.00,
|
|
31
|
+
"effective_from": "2024-12-05T00:00:00Z",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"provider": "codex",
|
|
35
|
+
"model": "o3-mini",
|
|
36
|
+
"input_price_per_million": 1.10,
|
|
37
|
+
"output_price_per_million": 4.40,
|
|
38
|
+
"cache_read_price_per_million": 0.55,
|
|
39
|
+
"cache_write_price_per_million": 0.00,
|
|
40
|
+
"effective_from": "2025-01-31T00:00:00Z",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"provider": "codex",
|
|
44
|
+
"model": "gpt-4-turbo",
|
|
45
|
+
"input_price_per_million": 10.00,
|
|
46
|
+
"output_price_per_million": 30.00,
|
|
47
|
+
"cache_read_price_per_million": 0.00,
|
|
48
|
+
"cache_write_price_per_million": 0.00,
|
|
49
|
+
"effective_from": "2024-04-09T00:00:00Z",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"provider": "codex",
|
|
53
|
+
"model": "code-davinci-002",
|
|
54
|
+
"input_price_per_million": 20.00,
|
|
55
|
+
"output_price_per_million": 20.00,
|
|
56
|
+
"cache_read_price_per_million": 0.00,
|
|
57
|
+
"cache_write_price_per_million": 0.00,
|
|
58
|
+
"effective_from": "2022-01-01T00:00:00Z",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"provider": "codex",
|
|
62
|
+
"model": "codex",
|
|
63
|
+
"input_price_per_million": 2.50,
|
|
64
|
+
"output_price_per_million": 10.00,
|
|
65
|
+
"cache_read_price_per_million": 1.25,
|
|
66
|
+
"cache_write_price_per_million": 0.00,
|
|
67
|
+
"effective_from": "2024-01-01T00:00:00Z",
|
|
68
|
+
},
|
|
69
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Providers package for token usage scanners."""
|
|
2
|
+
|
|
3
|
+
from code_meter.providers.antigravity import AntigravityProvider
|
|
4
|
+
from code_meter.providers.base import FileScanState, ScanResult, UsageProvider
|
|
5
|
+
from code_meter.providers.claude_code import ClaudeCodeProvider
|
|
6
|
+
from code_meter.providers.codex import CodexProvider
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"UsageProvider",
|
|
10
|
+
"ScanResult",
|
|
11
|
+
"FileScanState",
|
|
12
|
+
"ClaudeCodeProvider",
|
|
13
|
+
"CodexProvider",
|
|
14
|
+
"AntigravityProvider",
|
|
15
|
+
]
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Antigravity provider for scanning local Google Antigravity / AGY transcript and session logs (~/.gemini/antigravity-ide)."""
|
|
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 AntigravityProvider(UsageProvider):
|
|
18
|
+
"""Scanner for Google Antigravity / AGY session transcript files."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, antigravity_dir: Path):
|
|
21
|
+
self.antigravity_dir = Path(os.path.expanduser(antigravity_dir)).resolve()
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def provider_name(self) -> str:
|
|
25
|
+
return "antigravity"
|
|
26
|
+
|
|
27
|
+
def scan(self, existing_states: Optional[Dict[str, FileScanState]] = None) -> ScanResult:
|
|
28
|
+
"""Incrementally scan session files and transcript logs in Antigravity 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.antigravity_dir.exists():
|
|
38
|
+
logger.warning(f"Antigravity directory does not exist: {self.antigravity_dir}")
|
|
39
|
+
return ScanResult(records=[], prompts=[], code_changes=[], updated_states=[], files_scanned=0, malformed_lines=0)
|
|
40
|
+
|
|
41
|
+
transcript_files = self._find_transcript_files()
|
|
42
|
+
|
|
43
|
+
for file_path in transcript_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 Antigravity 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_transcript_files(self) -> List[Path]:
|
|
94
|
+
"""Find transcript files and session jsonl logs recursively."""
|
|
95
|
+
files: List[Path] = []
|
|
96
|
+
try:
|
|
97
|
+
for root, _, filenames in os.walk(self.antigravity_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.antigravity_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 transcript 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 = self._infer_session_id(file_path)
|
|
116
|
+
project_name = self._infer_project_name(file_path)
|
|
117
|
+
|
|
118
|
+
line_index = 0
|
|
119
|
+
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
|
120
|
+
if start_offset > 0:
|
|
121
|
+
f.seek(start_offset)
|
|
122
|
+
|
|
123
|
+
while True:
|
|
124
|
+
line = f.readline()
|
|
125
|
+
if not line:
|
|
126
|
+
break
|
|
127
|
+
|
|
128
|
+
current_offset = f.tell()
|
|
129
|
+
line_index += 1
|
|
130
|
+
line_str = line.strip()
|
|
131
|
+
if not line_str:
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
data = json.loads(line_str)
|
|
136
|
+
except Exception:
|
|
137
|
+
malformed += 1
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
rec, pmt, chgs = self._process_data_item(data, str(file_path.resolve()), session_id_from_path, project_name, line_index)
|
|
141
|
+
if rec:
|
|
142
|
+
records.append(rec)
|
|
143
|
+
if pmt:
|
|
144
|
+
prompts.append(pmt)
|
|
145
|
+
if chgs:
|
|
146
|
+
changes.extend(chgs)
|
|
147
|
+
|
|
148
|
+
return records, prompts, changes, current_offset, malformed
|
|
149
|
+
|
|
150
|
+
def _process_data_item(
|
|
151
|
+
self,
|
|
152
|
+
data: dict,
|
|
153
|
+
file_path: str,
|
|
154
|
+
session_id_from_path: str,
|
|
155
|
+
project_name: Optional[str],
|
|
156
|
+
line_index: int,
|
|
157
|
+
) -> Tuple[Optional[UsageRecord], Optional[SessionPrompt], List[SessionCodeChange]]:
|
|
158
|
+
if not isinstance(data, dict):
|
|
159
|
+
return None, None, []
|
|
160
|
+
|
|
161
|
+
session_id = data.get("conversation_id") or data.get("session_id") or session_id_from_path
|
|
162
|
+
cwd = data.get("cwd") or data.get("project_path")
|
|
163
|
+
p_name = data.get("project") or (os.path.basename(cwd) if cwd else project_name)
|
|
164
|
+
|
|
165
|
+
rec = self._parse_usage_record(data, file_path, session_id, cwd, p_name)
|
|
166
|
+
pmt = self._parse_user_prompt(data, session_id, p_name, line_index)
|
|
167
|
+
chgs = self._parse_code_changes(data, session_id, p_name, line_index)
|
|
168
|
+
|
|
169
|
+
return rec, pmt, chgs
|
|
170
|
+
|
|
171
|
+
def _parse_usage_record(
|
|
172
|
+
self,
|
|
173
|
+
data: dict,
|
|
174
|
+
file_path: str,
|
|
175
|
+
session_id: str,
|
|
176
|
+
cwd: Optional[str],
|
|
177
|
+
project_name: Optional[str],
|
|
178
|
+
) -> Optional[UsageRecord]:
|
|
179
|
+
usage = data.get("usage") or data.get("token_usage") or data.get("usage_metadata")
|
|
180
|
+
if not isinstance(usage, dict):
|
|
181
|
+
# Check if stored in item directly
|
|
182
|
+
if "input_tokens" in data or "prompt_token_count" in data:
|
|
183
|
+
usage = data
|
|
184
|
+
|
|
185
|
+
if not isinstance(usage, dict):
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
input_tokens = int(usage.get("input_tokens") or usage.get("prompt_token_count") or 0)
|
|
189
|
+
output_tokens = int(usage.get("output_tokens") or usage.get("candidates_token_count") or 0)
|
|
190
|
+
cache_read_tokens = int(usage.get("cache_read_tokens") or usage.get("cached_content_token_count") or 0)
|
|
191
|
+
cache_write_tokens = int(usage.get("cache_write_tokens") or 0)
|
|
192
|
+
|
|
193
|
+
if (input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) == 0:
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
model = data.get("model") or usage.get("model") or "gemini-2.5-flash"
|
|
197
|
+
if not isinstance(model, str):
|
|
198
|
+
model = str(model)
|
|
199
|
+
|
|
200
|
+
raw_ts = data.get("timestamp") or data.get("created_at") or data.get("time")
|
|
201
|
+
ts = self._parse_timestamp(raw_ts)
|
|
202
|
+
|
|
203
|
+
request_id = data.get("id") or data.get("step_index") or data.get("request_id")
|
|
204
|
+
if not request_id or not isinstance(request_id, str):
|
|
205
|
+
request_id = self._generate_deterministic_id(
|
|
206
|
+
file_path=file_path,
|
|
207
|
+
timestamp=ts,
|
|
208
|
+
model=model,
|
|
209
|
+
input_tokens=input_tokens,
|
|
210
|
+
output_tokens=output_tokens,
|
|
211
|
+
cache_read_tokens=cache_read_tokens,
|
|
212
|
+
cache_write_tokens=cache_write_tokens,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
return UsageRecord(
|
|
216
|
+
provider="antigravity",
|
|
217
|
+
request_id=str(request_id),
|
|
218
|
+
session_id=session_id,
|
|
219
|
+
timestamp=ts,
|
|
220
|
+
user_id=data.get("user_id"),
|
|
221
|
+
project_id=project_name,
|
|
222
|
+
project_path=cwd,
|
|
223
|
+
model=model,
|
|
224
|
+
input_tokens=input_tokens,
|
|
225
|
+
output_tokens=output_tokens,
|
|
226
|
+
cache_read_tokens=cache_read_tokens,
|
|
227
|
+
cache_write_tokens=cache_write_tokens,
|
|
228
|
+
metadata={"file_path": file_path, "type": data.get("type")},
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def _parse_user_prompt(
|
|
232
|
+
self, data: dict, session_id: str, project_id: Optional[str], line_index: int
|
|
233
|
+
) -> Optional[SessionPrompt]:
|
|
234
|
+
line_type = data.get("type")
|
|
235
|
+
source = data.get("source")
|
|
236
|
+
|
|
237
|
+
if line_type not in ["USER_INPUT", "user", "prompt"] and source != "USER_EXPLICIT":
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
content = data.get("content") or data.get("text") or data.get("prompt")
|
|
241
|
+
prompt_text = ""
|
|
242
|
+
|
|
243
|
+
if isinstance(content, str):
|
|
244
|
+
prompt_text = content.strip()
|
|
245
|
+
elif isinstance(content, list):
|
|
246
|
+
texts = []
|
|
247
|
+
for item in content:
|
|
248
|
+
if isinstance(item, str):
|
|
249
|
+
texts.append(item)
|
|
250
|
+
elif isinstance(item, dict) and item.get("text"):
|
|
251
|
+
texts.append(item.get("text", ""))
|
|
252
|
+
prompt_text = "\n".join(texts).strip()
|
|
253
|
+
|
|
254
|
+
if not prompt_text:
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
raw_ts = data.get("timestamp") or data.get("created_at")
|
|
258
|
+
ts = self._parse_timestamp(raw_ts)
|
|
259
|
+
|
|
260
|
+
prompt_id = data.get("prompt_id") or data.get("id") or f"agy_prompt_{session_id}_{line_index}"
|
|
261
|
+
|
|
262
|
+
return SessionPrompt(
|
|
263
|
+
provider="antigravity",
|
|
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
|
+
changes: List[SessionCodeChange] = []
|
|
275
|
+
raw_ts = data.get("timestamp") or data.get("created_at")
|
|
276
|
+
ts = self._parse_timestamp(raw_ts)
|
|
277
|
+
|
|
278
|
+
tool_calls = data.get("tool_calls")
|
|
279
|
+
if isinstance(tool_calls, list):
|
|
280
|
+
for idx, call in enumerate(tool_calls):
|
|
281
|
+
if isinstance(call, dict):
|
|
282
|
+
name = call.get("name") or call.get("function", {}).get("name", "")
|
|
283
|
+
args = call.get("args") or call.get("arguments") or call.get("function", {}).get("arguments", {})
|
|
284
|
+
if isinstance(args, str):
|
|
285
|
+
try:
|
|
286
|
+
args = json.loads(args)
|
|
287
|
+
except Exception:
|
|
288
|
+
args = {}
|
|
289
|
+
|
|
290
|
+
if isinstance(args, dict) and name in [
|
|
291
|
+
"replace_file_content",
|
|
292
|
+
"multi_replace_file_content",
|
|
293
|
+
"write_to_file",
|
|
294
|
+
"edit_file",
|
|
295
|
+
"write_file",
|
|
296
|
+
]:
|
|
297
|
+
fpath = args.get("TargetFile") or args.get("file_path") or args.get("path") or "unknown_file"
|
|
298
|
+
target_content = args.get("TargetContent") or args.get("old_content") or ""
|
|
299
|
+
replacement_content = args.get("ReplacementContent") or args.get("CodeContent") or args.get("new_content") or ""
|
|
300
|
+
|
|
301
|
+
diff_lines = []
|
|
302
|
+
if target_content:
|
|
303
|
+
diff_lines.append(f"- {target_content[:150]}")
|
|
304
|
+
if replacement_content:
|
|
305
|
+
diff_lines.append(f"+ {replacement_content[:150]}")
|
|
306
|
+
diff_summary = "\n".join(diff_lines) if diff_lines else f"Antigravity action: {name}"
|
|
307
|
+
|
|
308
|
+
change_id = call.get("id") or f"agy_edit_{session_id}_{line_index}_{idx}"
|
|
309
|
+
changes.append(
|
|
310
|
+
SessionCodeChange(
|
|
311
|
+
provider="antigravity",
|
|
312
|
+
session_id=session_id,
|
|
313
|
+
change_id=str(change_id),
|
|
314
|
+
timestamp=ts,
|
|
315
|
+
file_path=str(fpath),
|
|
316
|
+
change_type=name.lower(),
|
|
317
|
+
diff_summary=diff_summary,
|
|
318
|
+
project_id=project_id,
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
return changes
|
|
323
|
+
|
|
324
|
+
def _infer_session_id(self, file_path: Path) -> str:
|
|
325
|
+
for parent in file_path.parents:
|
|
326
|
+
if len(parent.name) == 36 and "-" in parent.name:
|
|
327
|
+
return parent.name
|
|
328
|
+
return file_path.stem
|
|
329
|
+
|
|
330
|
+
def _infer_project_name(self, file_path: Path) -> Optional[str]:
|
|
331
|
+
for parent in file_path.parents:
|
|
332
|
+
if parent.name not in ["brain", "logs", ".system_generated", "antigravity-ide"]:
|
|
333
|
+
return parent.name
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
def _parse_timestamp(self, raw_ts: Optional[str]) -> datetime:
|
|
337
|
+
if not raw_ts:
|
|
338
|
+
return datetime.now(timezone.utc)
|
|
339
|
+
if isinstance(raw_ts, (int, float)):
|
|
340
|
+
return datetime.fromtimestamp(raw_ts if raw_ts < 1e11 else raw_ts / 1000.0, tz=timezone.utc)
|
|
341
|
+
try:
|
|
342
|
+
ts_str = str(raw_ts).replace("Z", "+00:00")
|
|
343
|
+
return datetime.fromisoformat(ts_str)
|
|
344
|
+
except ValueError:
|
|
345
|
+
return datetime.now(timezone.utc)
|
|
346
|
+
|
|
347
|
+
def _generate_deterministic_id(
|
|
348
|
+
self,
|
|
349
|
+
file_path: str,
|
|
350
|
+
timestamp: datetime,
|
|
351
|
+
model: str,
|
|
352
|
+
input_tokens: int,
|
|
353
|
+
output_tokens: int,
|
|
354
|
+
cache_read_tokens: int,
|
|
355
|
+
cache_write_tokens: int,
|
|
356
|
+
) -> str:
|
|
357
|
+
seed = f"antigravity:{file_path}:{timestamp.isoformat()}:{model}:{input_tokens}:{output_tokens}:{cache_read_tokens}:{cache_write_tokens}"
|
|
358
|
+
return "gen_agy_" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:24]
|