coding-agent-cost 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.
- agent_cost/__init__.py +9 -0
- agent_cost/aggregate.py +201 -0
- agent_cost/cli.py +393 -0
- agent_cost/config.py +67 -0
- agent_cost/facts.py +93 -0
- agent_cost/rates.json +490 -0
- agent_cost/rates.py +280 -0
- agent_cost/readers/__init__.py +26 -0
- agent_cost/readers/claude.py +177 -0
- agent_cost/readers/codex.py +368 -0
- agent_cost/renderers.py +90 -0
- coding_agent_cost-0.1.0.dist-info/METADATA +247 -0
- coding_agent_cost-0.1.0.dist-info/RECORD +17 -0
- coding_agent_cost-0.1.0.dist-info/WHEEL +5 -0
- coding_agent_cost-0.1.0.dist-info/entry_points.txt +2 -0
- coding_agent_cost-0.1.0.dist-info/licenses/LICENSE +21 -0
- coding_agent_cost-0.1.0.dist-info/top_level.txt +1 -0
agent_cost/rates.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Load, validate, and query the historical rate catalog (rates.json).
|
|
2
|
+
|
|
3
|
+
The catalog is a plain JSON document describing, per model, one or more
|
|
4
|
+
time-bounded rate periods (so a price change shows up as a new period
|
|
5
|
+
rather than overwriting history). All rate values are decimal strings in
|
|
6
|
+
the JSON and are parsed with `decimal.Decimal` throughout -- float is
|
|
7
|
+
never used for money.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from decimal import Decimal, InvalidOperation
|
|
17
|
+
from importlib import resources
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
RATE_FIELDS = (
|
|
22
|
+
"input_nocache",
|
|
23
|
+
"cache_read",
|
|
24
|
+
"cache_write_5m",
|
|
25
|
+
"cache_write_1h",
|
|
26
|
+
"output",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# The only schema_version/currency/unit values this version of agent-cost
|
|
30
|
+
# knows how to interpret. A catalog claiming anything else is rejected
|
|
31
|
+
# outright rather than silently treated as USD-per-mtok -- a catalog in a
|
|
32
|
+
# different currency or unit priced as if it were USD-per-mtok would be
|
|
33
|
+
# wrong by a fixed, silent factor.
|
|
34
|
+
SUPPORTED_SCHEMA_VERSIONS = ("1",)
|
|
35
|
+
SUPPORTED_CURRENCIES = ("USD",)
|
|
36
|
+
SUPPORTED_UNITS = ("per_mtok",)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RatesValidationError(ValueError):
|
|
40
|
+
"""The rates catalog failed structural or business-rule validation."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class RatePeriod:
|
|
45
|
+
rate_id: str
|
|
46
|
+
effective_from: datetime
|
|
47
|
+
effective_until: Optional[datetime]
|
|
48
|
+
values: dict
|
|
49
|
+
|
|
50
|
+
def covers(self, moment: datetime) -> bool:
|
|
51
|
+
if moment < self.effective_from:
|
|
52
|
+
return False
|
|
53
|
+
if self.effective_until is not None and moment >= self.effective_until:
|
|
54
|
+
return False
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class ModelEntry:
|
|
60
|
+
model_key: str
|
|
61
|
+
aliases: tuple
|
|
62
|
+
fast_multiplier: Decimal
|
|
63
|
+
rates: tuple
|
|
64
|
+
credits_per_mtok: Optional[dict] = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class RateCatalog:
|
|
69
|
+
schema_version: str
|
|
70
|
+
catalog_version: str
|
|
71
|
+
currency: str
|
|
72
|
+
unit: str
|
|
73
|
+
usd_per_credit: Decimal
|
|
74
|
+
sources: list
|
|
75
|
+
models: dict = field(default_factory=dict)
|
|
76
|
+
sha256: str = ""
|
|
77
|
+
_alias_index: dict = field(default_factory=dict)
|
|
78
|
+
|
|
79
|
+
def resolve_model_key(self, model_key: str) -> Optional[str]:
|
|
80
|
+
"""Map a fact's ``model_key`` to a canonical catalog ``model_key``.
|
|
81
|
+
|
|
82
|
+
Tries an exact match first, then the catalog's alias table (this
|
|
83
|
+
is separate from -- and applied after -- the generic suffix
|
|
84
|
+
stripping in ``facts.normalize_model_key``).
|
|
85
|
+
"""
|
|
86
|
+
if model_key in self.models:
|
|
87
|
+
return model_key
|
|
88
|
+
return self._alias_index.get(model_key)
|
|
89
|
+
|
|
90
|
+
def rate_for(self, model_key: str, occurred_at_utc: datetime):
|
|
91
|
+
"""Return ``(resolved_model_key, RatePeriod)``, either of which may
|
|
92
|
+
be ``None`` when the model or the specific period is not priced."""
|
|
93
|
+
resolved = self.resolve_model_key(model_key)
|
|
94
|
+
if resolved is None:
|
|
95
|
+
return None, None
|
|
96
|
+
entry = self.models[resolved]
|
|
97
|
+
for period in entry.rates:
|
|
98
|
+
if period.covers(occurred_at_utc):
|
|
99
|
+
return resolved, period
|
|
100
|
+
return resolved, None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _parse_decimal(value, *, field_name: str) -> Optional[Decimal]:
|
|
104
|
+
if value is None:
|
|
105
|
+
return None
|
|
106
|
+
try:
|
|
107
|
+
d = Decimal(str(value))
|
|
108
|
+
except InvalidOperation as exc:
|
|
109
|
+
raise RatesValidationError(f"invalid decimal for {field_name}: {value!r}") from exc
|
|
110
|
+
if d < 0:
|
|
111
|
+
raise RatesValidationError(f"{field_name} must be >= 0, got {d}")
|
|
112
|
+
return d
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _parse_datetime(value, *, field_name: str) -> datetime:
|
|
116
|
+
try:
|
|
117
|
+
text = value
|
|
118
|
+
if isinstance(text, str) and text.endswith("Z"):
|
|
119
|
+
text = text[:-1] + "+00:00"
|
|
120
|
+
dt = datetime.fromisoformat(text)
|
|
121
|
+
except (ValueError, TypeError) as exc:
|
|
122
|
+
raise RatesValidationError(f"invalid datetime for {field_name}: {value!r}") from exc
|
|
123
|
+
if dt.tzinfo is None:
|
|
124
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
125
|
+
return dt.astimezone(timezone.utc)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _build_model_entry(raw: dict) -> ModelEntry:
|
|
129
|
+
model_key = raw.get("model_key")
|
|
130
|
+
if not model_key or not isinstance(model_key, str):
|
|
131
|
+
raise RatesValidationError(f"model entry missing model_key: {raw!r}")
|
|
132
|
+
|
|
133
|
+
aliases = tuple(raw.get("aliases") or [])
|
|
134
|
+
|
|
135
|
+
fast_multiplier = _parse_decimal(
|
|
136
|
+
raw.get("fast_multiplier", "1.0"), field_name=f"{model_key}.fast_multiplier"
|
|
137
|
+
)
|
|
138
|
+
if fast_multiplier is None:
|
|
139
|
+
fast_multiplier = Decimal("1.0")
|
|
140
|
+
|
|
141
|
+
credits_raw = raw.get("credits_per_mtok")
|
|
142
|
+
credits_per_mtok = None
|
|
143
|
+
if credits_raw is not None:
|
|
144
|
+
credits_per_mtok = {
|
|
145
|
+
f: _parse_decimal(credits_raw.get(f), field_name=f"{model_key}.credits_per_mtok.{f}")
|
|
146
|
+
for f in RATE_FIELDS
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
periods = []
|
|
150
|
+
for rate_raw in raw.get("rates") or []:
|
|
151
|
+
rate_id = rate_raw.get("rate_id")
|
|
152
|
+
if not rate_id:
|
|
153
|
+
raise RatesValidationError(f"{model_key}: rate entry missing rate_id")
|
|
154
|
+
if "effective_from" not in rate_raw:
|
|
155
|
+
raise RatesValidationError(f"{model_key}.{rate_id}: missing effective_from")
|
|
156
|
+
effective_from = _parse_datetime(
|
|
157
|
+
rate_raw["effective_from"], field_name=f"{model_key}.{rate_id}.effective_from"
|
|
158
|
+
)
|
|
159
|
+
effective_until = (
|
|
160
|
+
_parse_datetime(rate_raw["effective_until"], field_name=f"{model_key}.{rate_id}.effective_until")
|
|
161
|
+
if rate_raw.get("effective_until")
|
|
162
|
+
else None
|
|
163
|
+
)
|
|
164
|
+
if effective_until is not None and effective_until <= effective_from:
|
|
165
|
+
raise RatesValidationError(
|
|
166
|
+
f"{model_key}.{rate_id}: effective_until must be after effective_from"
|
|
167
|
+
)
|
|
168
|
+
values = {
|
|
169
|
+
f: _parse_decimal(rate_raw.get(f), field_name=f"{model_key}.{rate_id}.{f}")
|
|
170
|
+
for f in RATE_FIELDS
|
|
171
|
+
}
|
|
172
|
+
periods.append(
|
|
173
|
+
RatePeriod(
|
|
174
|
+
rate_id=rate_id,
|
|
175
|
+
effective_from=effective_from,
|
|
176
|
+
effective_until=effective_until,
|
|
177
|
+
values=values,
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
periods.sort(key=lambda p: p.effective_from)
|
|
182
|
+
open_end = datetime.max.replace(tzinfo=timezone.utc)
|
|
183
|
+
for a, b in zip(periods, periods[1:]):
|
|
184
|
+
a_end = a.effective_until or open_end
|
|
185
|
+
if a_end > b.effective_from:
|
|
186
|
+
raise RatesValidationError(
|
|
187
|
+
f"{model_key}: overlapping rate periods {a.rate_id!r} and {b.rate_id!r}"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
return ModelEntry(
|
|
191
|
+
model_key=model_key,
|
|
192
|
+
aliases=aliases,
|
|
193
|
+
fast_multiplier=fast_multiplier,
|
|
194
|
+
rates=tuple(periods),
|
|
195
|
+
credits_per_mtok=credits_per_mtok,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _validate_and_build(data: dict) -> RateCatalog:
|
|
200
|
+
if not isinstance(data, dict):
|
|
201
|
+
raise RatesValidationError("rates catalog root must be an object")
|
|
202
|
+
|
|
203
|
+
catalog_version = data.get("catalog_version")
|
|
204
|
+
if not catalog_version:
|
|
205
|
+
raise RatesValidationError("missing catalog_version")
|
|
206
|
+
|
|
207
|
+
schema_version = data.get("schema_version")
|
|
208
|
+
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
|
|
209
|
+
raise RatesValidationError(
|
|
210
|
+
f"unsupported schema_version: {schema_version!r} (supported: {SUPPORTED_SCHEMA_VERSIONS})"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
currency = data.get("currency")
|
|
214
|
+
if currency not in SUPPORTED_CURRENCIES:
|
|
215
|
+
raise RatesValidationError(
|
|
216
|
+
f"unsupported currency: {currency!r} (supported: {SUPPORTED_CURRENCIES})"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
unit = data.get("unit")
|
|
220
|
+
if unit not in SUPPORTED_UNITS:
|
|
221
|
+
raise RatesValidationError(f"unsupported unit: {unit!r} (supported: {SUPPORTED_UNITS})")
|
|
222
|
+
|
|
223
|
+
usd_per_credit = _parse_decimal(data.get("usd_per_credit"), field_name="usd_per_credit")
|
|
224
|
+
if usd_per_credit is None:
|
|
225
|
+
usd_per_credit = Decimal("0")
|
|
226
|
+
sources = data.get("sources") or []
|
|
227
|
+
|
|
228
|
+
models: dict = {}
|
|
229
|
+
for raw in data.get("models") or []:
|
|
230
|
+
entry = _build_model_entry(raw)
|
|
231
|
+
if entry.model_key in models:
|
|
232
|
+
raise RatesValidationError(f"duplicate model_key: {entry.model_key}")
|
|
233
|
+
models[entry.model_key] = entry
|
|
234
|
+
|
|
235
|
+
alias_index: dict = {}
|
|
236
|
+
for entry in models.values():
|
|
237
|
+
for alias in entry.aliases:
|
|
238
|
+
if alias == entry.model_key:
|
|
239
|
+
raise RatesValidationError(f"{entry.model_key}: alias cannot equal its own model_key")
|
|
240
|
+
if alias in models:
|
|
241
|
+
# Aliases live in a separate namespace from model_keys by
|
|
242
|
+
# construction; anything else would let alias resolution
|
|
243
|
+
# loop back on itself (an alias cycle).
|
|
244
|
+
raise RatesValidationError(
|
|
245
|
+
f"alias {alias!r} on {entry.model_key} collides with an existing model_key"
|
|
246
|
+
)
|
|
247
|
+
if alias in alias_index:
|
|
248
|
+
raise RatesValidationError(f"duplicate alias: {alias!r}")
|
|
249
|
+
alias_index[alias] = entry.model_key
|
|
250
|
+
|
|
251
|
+
return RateCatalog(
|
|
252
|
+
schema_version=schema_version,
|
|
253
|
+
catalog_version=catalog_version,
|
|
254
|
+
currency=currency,
|
|
255
|
+
unit=unit,
|
|
256
|
+
usd_per_credit=usd_per_credit,
|
|
257
|
+
sources=sources,
|
|
258
|
+
models=models,
|
|
259
|
+
_alias_index=alias_index,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def load_rates(path: Optional[Path] = None) -> RateCatalog:
|
|
264
|
+
"""Load and validate a rate catalog.
|
|
265
|
+
|
|
266
|
+
``path=None`` loads the catalog packaged with agent-cost
|
|
267
|
+
(``agent_cost/rates.json``). A caller-supplied path fully replaces
|
|
268
|
+
the packaged catalog -- it is never merged with it.
|
|
269
|
+
"""
|
|
270
|
+
if path is not None:
|
|
271
|
+
raw_bytes = Path(path).read_bytes()
|
|
272
|
+
else:
|
|
273
|
+
raw_bytes = resources.files("agent_cost").joinpath("rates.json").read_bytes()
|
|
274
|
+
try:
|
|
275
|
+
data = json.loads(raw_bytes)
|
|
276
|
+
except json.JSONDecodeError as exc:
|
|
277
|
+
raise RatesValidationError(f"invalid JSON: {exc}") from exc
|
|
278
|
+
catalog = _validate_and_build(data)
|
|
279
|
+
catalog.sha256 = hashlib.sha256(raw_bytes).hexdigest()
|
|
280
|
+
return catalog
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Log readers: turn a tool's local files into a stream of canonical facts.
|
|
2
|
+
|
|
3
|
+
Each reader module exposes a ``ReadResult`` (the facts plus counters for
|
|
4
|
+
anything that looked broken along the way) and a top-level ``read_*``
|
|
5
|
+
function. Readers never make network calls and never write to the source
|
|
6
|
+
logs they read.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import List
|
|
11
|
+
|
|
12
|
+
from ..facts import Fact
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ReadResult:
|
|
17
|
+
facts: List[Fact] = field(default_factory=list)
|
|
18
|
+
malformed_events: int = 0
|
|
19
|
+
skipped_files: int = 0
|
|
20
|
+
negative_deltas: int = 0
|
|
21
|
+
# Codex-only diagnostic: number of threads where the state DB's
|
|
22
|
+
# `tokens_used` column diverges from the sum of that thread's derived
|
|
23
|
+
# facts by more than 1% or 100 tokens. Not part of the report JSON's
|
|
24
|
+
# `data_quality` (which is reader-agnostic); surfaced for `doctor` /
|
|
25
|
+
# tests.
|
|
26
|
+
tokens_used_diffs: int = 0
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Claude Code reader: ``~/.claude/projects/**/*.jsonl`` -> facts.
|
|
2
|
+
|
|
3
|
+
Each session file is a JSONL transcript. Every ``assistant`` event that
|
|
4
|
+
carries a ``message.usage`` block is one billing event and becomes its own
|
|
5
|
+
set of facts, attributed to the model recorded on *that* event
|
|
6
|
+
(``message.model``) -- never to a session-wide majority model, since a
|
|
7
|
+
single session can span multiple models.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from datetime import datetime, timedelta, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Iterator, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
from . import ReadResult
|
|
18
|
+
from ..facts import Fact, normalize_model_key
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_timestamp(ts: Optional[str]) -> Optional[datetime]:
|
|
22
|
+
if not ts:
|
|
23
|
+
return None
|
|
24
|
+
try:
|
|
25
|
+
text = ts[:-1] + "+00:00" if ts.endswith("Z") else ts
|
|
26
|
+
dt = datetime.fromisoformat(text)
|
|
27
|
+
except ValueError:
|
|
28
|
+
return None
|
|
29
|
+
if dt.tzinfo is None:
|
|
30
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
31
|
+
return dt.astimezone(timezone.utc)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _detect_mode(usage: dict) -> str:
|
|
35
|
+
speed = usage.get("speed")
|
|
36
|
+
if not speed:
|
|
37
|
+
return "unknown"
|
|
38
|
+
return "fast" if "fast" in str(speed).strip().lower() else "normal"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_session_facts(jsonl_path: Path) -> Tuple[list, int]:
|
|
42
|
+
"""Stream one session file into facts. Returns ``(facts, malformed_events)``.
|
|
43
|
+
|
|
44
|
+
Raises ``OSError`` if the file cannot be read at all (the caller
|
|
45
|
+
counts that as a skipped file, distinct from a malformed *line*).
|
|
46
|
+
"""
|
|
47
|
+
facts: list = []
|
|
48
|
+
malformed = 0
|
|
49
|
+
session_id: Optional[str] = None
|
|
50
|
+
|
|
51
|
+
with jsonl_path.open() as fh:
|
|
52
|
+
for line in fh:
|
|
53
|
+
line = line.strip()
|
|
54
|
+
if not line:
|
|
55
|
+
continue
|
|
56
|
+
try:
|
|
57
|
+
event = json.loads(line)
|
|
58
|
+
except json.JSONDecodeError:
|
|
59
|
+
malformed += 1
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
if not session_id and event.get("sessionId"):
|
|
63
|
+
session_id = event.get("sessionId")
|
|
64
|
+
|
|
65
|
+
if event.get("type") != "assistant":
|
|
66
|
+
continue
|
|
67
|
+
message = event.get("message") or {}
|
|
68
|
+
usage = message.get("usage")
|
|
69
|
+
if not isinstance(usage, dict):
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
occurred_at = _parse_timestamp(event.get("timestamp"))
|
|
73
|
+
if occurred_at is None:
|
|
74
|
+
malformed += 1
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
model_raw = message.get("model") or "(unknown)"
|
|
78
|
+
model_key = normalize_model_key(model_raw)
|
|
79
|
+
mode = _detect_mode(usage)
|
|
80
|
+
sid = event.get("sessionId") or session_id
|
|
81
|
+
|
|
82
|
+
def _emit(kind: str, tokens: int) -> None:
|
|
83
|
+
if tokens > 0:
|
|
84
|
+
facts.append(
|
|
85
|
+
Fact(
|
|
86
|
+
occurred_at_utc=occurred_at,
|
|
87
|
+
agent="claude",
|
|
88
|
+
session_id=sid,
|
|
89
|
+
model_raw=model_raw,
|
|
90
|
+
model_key=model_key,
|
|
91
|
+
token_kind=kind,
|
|
92
|
+
tokens=tokens,
|
|
93
|
+
mode=mode,
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
_emit("input_nocache", int(usage.get("input_tokens") or 0))
|
|
99
|
+
_emit("cache_read", int(usage.get("cache_read_input_tokens") or 0))
|
|
100
|
+
|
|
101
|
+
cache_creation = usage.get("cache_creation")
|
|
102
|
+
cache_creation_total = int(usage.get("cache_creation_input_tokens") or 0)
|
|
103
|
+
if isinstance(cache_creation, dict):
|
|
104
|
+
ephemeral_5m = int(cache_creation.get("ephemeral_5m_input_tokens") or 0)
|
|
105
|
+
ephemeral_1h = int(cache_creation.get("ephemeral_1h_input_tokens") or 0)
|
|
106
|
+
_emit("cache_write_5m", ephemeral_5m)
|
|
107
|
+
_emit("cache_write_1h", ephemeral_1h)
|
|
108
|
+
leftover = cache_creation_total - (ephemeral_5m + ephemeral_1h)
|
|
109
|
+
if leftover > 0:
|
|
110
|
+
_emit("cache_write_unknown", leftover)
|
|
111
|
+
elif cache_creation_total:
|
|
112
|
+
_emit("cache_write_unknown", cache_creation_total)
|
|
113
|
+
|
|
114
|
+
_emit("output", int(usage.get("output_tokens") or 0))
|
|
115
|
+
except (TypeError, ValueError):
|
|
116
|
+
malformed += 1
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
return facts, malformed
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def iter_project_files(claude_projects_dir: Path) -> Iterator[Path]:
|
|
123
|
+
if not claude_projects_dir.exists():
|
|
124
|
+
return
|
|
125
|
+
for project_dir in sorted(claude_projects_dir.iterdir()):
|
|
126
|
+
if not project_dir.is_dir():
|
|
127
|
+
continue
|
|
128
|
+
yield from sorted(project_dir.glob("*.jsonl"))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def read_claude_facts(
|
|
132
|
+
claude_projects_dir: Path,
|
|
133
|
+
*,
|
|
134
|
+
since_utc: Optional[datetime] = None,
|
|
135
|
+
until_utc: Optional[datetime] = None,
|
|
136
|
+
) -> ReadResult:
|
|
137
|
+
"""Read every session file under ``claude_projects_dir`` into facts.
|
|
138
|
+
|
|
139
|
+
A file's mtime is used only as a coarse "can we skip reading this
|
|
140
|
+
file entirely" optimization on the *since* side: a file untouched
|
|
141
|
+
since well before the window cannot contain any event inside it
|
|
142
|
+
(every event's timestamp is <= the file's mtime), so skipping it is
|
|
143
|
+
safe. There is no equivalent skip on the *until* side -- a file
|
|
144
|
+
modified after the window can easily still contain earlier events
|
|
145
|
+
that fall inside it, so skipping on a late mtime would silently drop
|
|
146
|
+
real in-window data. The exact since/until boundary is always
|
|
147
|
+
re-checked per fact below regardless.
|
|
148
|
+
"""
|
|
149
|
+
all_facts: list = []
|
|
150
|
+
malformed_total = 0
|
|
151
|
+
skipped_files = 0
|
|
152
|
+
|
|
153
|
+
for jsonl_path in iter_project_files(claude_projects_dir):
|
|
154
|
+
if since_utc is not None:
|
|
155
|
+
try:
|
|
156
|
+
mtime = datetime.fromtimestamp(jsonl_path.stat().st_mtime, tz=timezone.utc)
|
|
157
|
+
except OSError:
|
|
158
|
+
skipped_files += 1
|
|
159
|
+
continue
|
|
160
|
+
if mtime < since_utc - timedelta(days=1):
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
facts, malformed = parse_session_facts(jsonl_path)
|
|
165
|
+
except OSError:
|
|
166
|
+
skipped_files += 1
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
malformed_total += malformed
|
|
170
|
+
for fact in facts:
|
|
171
|
+
if since_utc is not None and fact.occurred_at_utc < since_utc:
|
|
172
|
+
continue
|
|
173
|
+
if until_utc is not None and fact.occurred_at_utc >= until_utc:
|
|
174
|
+
continue
|
|
175
|
+
all_facts.append(fact)
|
|
176
|
+
|
|
177
|
+
return ReadResult(facts=all_facts, malformed_events=malformed_total, skipped_files=skipped_files)
|