agentabacus 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,3 @@
1
+ """agentabacus — local-first analytics for AI coding agents."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,17 @@
1
+ """Adapter registry.
2
+
3
+ Adding support for a new agent CLI is: write a module exposing
4
+ `parse(path, kind, start_offset) -> Batch`, add a walker in discovery.py, and
5
+ register it here. Nothing else in the codebase needs to change.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from . import claude_code, codex
11
+
12
+ REGISTRY = {
13
+ claude_code.SOURCE: claude_code.parse,
14
+ codex.SOURCE: codex.parse,
15
+ }
16
+
17
+ __all__ = ["REGISTRY", "claude_code", "codex"]
@@ -0,0 +1,101 @@
1
+ """Adapter helpers.
2
+
3
+ Design rule for every adapter: **be a tolerant parser**. These formats are
4
+ undocumented, version-dependent, and change without notice. An adapter that
5
+ raises on an unknown `type` turns a vendor's routine release into a crash for
6
+ every user. Route on known shapes, count what you skipped, never throw.
7
+
8
+ The strictness lives in schema.py instead -- messy at the edges, contract-
9
+ enforced in the core.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ from datetime import datetime, timezone
17
+ from typing import Any, Iterator
18
+
19
+
20
+ def parse_ts(value: Any) -> datetime | None:
21
+ """ISO-8601 (with or without 'Z') or epoch ms/seconds. Never raises."""
22
+ if value is None:
23
+ return None
24
+ if isinstance(value, (int, float)):
25
+ # Heuristic: anything past ~2001 in seconds is milliseconds here.
26
+ seconds = value / 1000 if value > 1e11 else value
27
+ try:
28
+ return datetime.fromtimestamp(seconds, tz=timezone.utc).replace(tzinfo=None)
29
+ except (ValueError, OSError, OverflowError):
30
+ return None
31
+ if isinstance(value, str):
32
+ try:
33
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
34
+ except ValueError:
35
+ return None
36
+ return dt.astimezone(timezone.utc).replace(tzinfo=None) if dt.tzinfo else dt
37
+ return None
38
+
39
+
40
+ def sha256(text: str) -> str:
41
+ return hashlib.sha256(text.encode("utf-8", "replace")).hexdigest()
42
+
43
+
44
+ def as_int(value: Any) -> int:
45
+ return value if isinstance(value, int) else 0
46
+
47
+
48
+ def iter_lines(path, start_offset: int = 0) -> Iterator[tuple[dict, int]]:
49
+ """Yield (record, offset_after_this_line) for every COMPLETE line.
50
+
51
+ A trailing partial line -- the file was mid-write when we read it -- is not
52
+ yielded and does not advance the offset, so the next run re-reads it whole.
53
+ Malformed complete lines are skipped with a sentinel rather than raising.
54
+ """
55
+ with open(path, "rb") as fh:
56
+ fh.seek(start_offset)
57
+ offset = start_offset
58
+ for raw in fh:
59
+ if not raw.endswith(b"\n"):
60
+ break # torn write; leave the offset where it was
61
+ offset += len(raw)
62
+ text = raw.decode("utf-8", "replace").strip()
63
+ if not text:
64
+ continue
65
+ try:
66
+ record = json.loads(text)
67
+ except (ValueError, TypeError):
68
+ yield ({"__unparsed__": True}, offset)
69
+ continue
70
+ if isinstance(record, dict):
71
+ yield (record, offset)
72
+ else:
73
+ yield ({"__unparsed__": True}, offset)
74
+
75
+
76
+ # Tool inputs vary per tool; these are the keys that carry "what did it act on".
77
+ _TARGET_KEYS = ("file_path", "path", "notebook_path", "command", "url", "pattern", "query")
78
+
79
+
80
+ def tool_target(tool_input: Any, limit: int = 400) -> str | None:
81
+ if not isinstance(tool_input, dict):
82
+ return None
83
+ for key in _TARGET_KEYS:
84
+ value = tool_input.get(key)
85
+ if isinstance(value, str) and value:
86
+ return value[:limit]
87
+ return None
88
+
89
+
90
+ def content_chars(content: Any) -> int:
91
+ if isinstance(content, str):
92
+ return len(content)
93
+ if isinstance(content, list):
94
+ total = 0
95
+ for block in content:
96
+ if isinstance(block, dict):
97
+ total += len(block.get("text") or "")
98
+ elif isinstance(block, str):
99
+ total += len(block)
100
+ return total
101
+ return 0
@@ -0,0 +1,230 @@
1
+ """Claude Code adapter.
2
+
3
+ Reads three shapes, all JSONL:
4
+
5
+ <slug>/<uuid>.jsonl main session transcript
6
+ <slug>/<uuid>/subagents/agent-*.jsonl one per subagent thread
7
+ history.jsonl every prompt typed, survives GC
8
+
9
+ Two things this adapter exists to get right:
10
+
11
+ 1. **Dedupe by requestId.** Consecutive assistant lines are content blocks of
12
+ ONE API response and each repeats the full usage. Aggregating with MAX per
13
+ requestId is the difference between correct numbers and numbers that are
14
+ 2-3x too high. Verified against real transcripts: 16 assistant lines
15
+ collapsed to 6 requests, a 2.4-3.0x overcount if summed naively.
16
+
17
+ 2. **The cache TTL split.** `cache_creation.ephemeral_1h_input_tokens` and
18
+ `ephemeral_5m_input_tokens` bill at different multipliers (2x vs 1.25x of
19
+ base input). We keep them apart all the way to the cost view.
20
+
21
+ Subagent files carry the PARENT's sessionId plus their own `agentId`, so the
22
+ thread_id is what separates them -- not the session id.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from pathlib import Path
28
+
29
+ from ..schema import Batch, Prompt, Session, ToolCall, Turn
30
+ from .base import as_int, content_chars, iter_lines, parse_ts, sha256, tool_target
31
+
32
+ SOURCE = "claude_code"
33
+
34
+
35
+ def _project_slug(path: Path) -> str | None:
36
+ """The slug is the directory directly under `projects/`.
37
+
38
+ Derived by walking up rather than by a fixed parent index, because subagent
39
+ transcripts sit at two different depths (plain vs workflow subagents).
40
+ Note this is only a label: the slug encoding ('/' -> '-') is lossy and
41
+ irreversible, so `cwd` from inside the file is the authoritative path.
42
+ """
43
+ for parent in path.parents:
44
+ if parent.parent.name == "projects":
45
+ return parent.name
46
+ return None
47
+
48
+
49
+ def _usage_fields(usage: dict) -> dict:
50
+ """Pull the token counts, keeping the two cache-write TTLs separate."""
51
+ creation = usage.get("cache_creation")
52
+ if isinstance(creation, dict):
53
+ w1h = as_int(creation.get("ephemeral_1h_input_tokens"))
54
+ w5m = as_int(creation.get("ephemeral_5m_input_tokens"))
55
+ else:
56
+ # Older/other shapes give only the total. Attribute it to the 5m rate,
57
+ # which is the cheaper of the two -- under-report rather than inflate.
58
+ w1h, w5m = 0, as_int(usage.get("cache_creation_input_tokens"))
59
+
60
+ details = usage.get("output_tokens_details")
61
+ thinking = as_int(details.get("thinking_tokens")) if isinstance(details, dict) else 0
62
+
63
+ server = usage.get("server_tool_use")
64
+ server = server if isinstance(server, dict) else {}
65
+
66
+ return {
67
+ "input_tokens": as_int(usage.get("input_tokens")),
68
+ "output_tokens": as_int(usage.get("output_tokens")),
69
+ "thinking_tokens": thinking,
70
+ "cache_read_tokens": as_int(usage.get("cache_read_input_tokens")),
71
+ "cache_write_5m_tokens": w5m,
72
+ "cache_write_1h_tokens": w1h,
73
+ "web_search_requests": as_int(server.get("web_search_requests")),
74
+ "web_fetch_requests": as_int(server.get("web_fetch_requests")),
75
+ }
76
+
77
+
78
+ def parse_history(path: Path, start_offset: int = 0) -> Batch:
79
+ """history.jsonl: prompt text, project, sessionId, epoch-ms timestamp.
80
+
81
+ Only a hash and a length are kept. The prompt body never enters the
82
+ pipeline, so "does this leak my code?" is answerable from the schema
83
+ instead of from trust in a downstream filter.
84
+ """
85
+ batch = Batch(byte_offset=start_offset)
86
+ for record, offset in iter_lines(path, start_offset):
87
+ batch.byte_offset = offset
88
+ if record.get("__unparsed__"):
89
+ batch.skipped_lines += 1
90
+ continue
91
+ display = record.get("display")
92
+ if not isinstance(display, str):
93
+ continue
94
+ batch.prompts.append(
95
+ Prompt(
96
+ prompt_sha256=sha256(display),
97
+ session_id=record.get("sessionId"),
98
+ source=SOURCE,
99
+ ts=parse_ts(record.get("timestamp")),
100
+ project=record.get("project"),
101
+ char_len=len(display),
102
+ )
103
+ )
104
+ return batch
105
+
106
+
107
+ def parse_transcript(path: Path, start_offset: int = 0, is_subagent: bool = False) -> Batch:
108
+ batch = Batch(byte_offset=start_offset)
109
+
110
+ turns: dict[str, Turn] = {}
111
+ tools: dict[str, ToolCall] = {}
112
+ meta: dict = {}
113
+ first_ts = last_ts = None
114
+ session_id = thread_id = None
115
+
116
+ for record, offset in iter_lines(path, start_offset):
117
+ batch.byte_offset = offset
118
+ if record.get("__unparsed__"):
119
+ batch.skipped_lines += 1
120
+ continue
121
+
122
+ rec_type = record.get("type")
123
+ ts = parse_ts(record.get("timestamp"))
124
+ if ts:
125
+ first_ts = ts if first_ts is None or ts < first_ts else first_ts
126
+ last_ts = ts if last_ts is None or ts > last_ts else last_ts
127
+
128
+ if record.get("sessionId"):
129
+ session_id = record["sessionId"]
130
+ # Subagent threads are identified by agentId, NOT by a distinct session id.
131
+ if record.get("agentId"):
132
+ thread_id = record["agentId"]
133
+ for key in ("cwd", "version", "gitBranch", "entrypoint", "attributionAgent"):
134
+ if record.get(key):
135
+ meta.setdefault(key, record[key])
136
+ if record.get("isSidechain"):
137
+ is_subagent = True
138
+
139
+ message = record.get("message")
140
+ message = message if isinstance(message, dict) else {}
141
+ blocks = message.get("content")
142
+ blocks = blocks if isinstance(blocks, list) else []
143
+
144
+ if rec_type == "assistant":
145
+ request_id = record.get("requestId") or message.get("id")
146
+ usage = message.get("usage")
147
+ if request_id and isinstance(usage, dict):
148
+ fields = _usage_fields(usage)
149
+ existing = turns.get(request_id)
150
+ if existing is None:
151
+ turns[request_id] = Turn(
152
+ request_id=request_id,
153
+ session_id=session_id or "",
154
+ thread_id=thread_id or "main",
155
+ source=SOURCE,
156
+ model_id=message.get("model"),
157
+ ts=ts,
158
+ effort=record.get("effort"),
159
+ service_tier=usage.get("service_tier"),
160
+ speed=usage.get("speed") or "standard",
161
+ block_lines=1,
162
+ **fields,
163
+ )
164
+ else:
165
+ # THE DEDUPE. Same request, another content block, same usage
166
+ # repeated. MAX (not +=) so re-reads and partial lines are safe.
167
+ for key, value in fields.items():
168
+ setattr(existing, key, max(getattr(existing, key), value))
169
+ existing.block_lines += 1
170
+ if existing.ts is None or (ts and ts < existing.ts):
171
+ existing.ts = ts
172
+
173
+ for block in blocks:
174
+ if isinstance(block, dict) and block.get("type") == "tool_use":
175
+ tool_id = block.get("id")
176
+ if not tool_id:
177
+ continue
178
+ tools[tool_id] = ToolCall(
179
+ tool_use_id=tool_id,
180
+ session_id=session_id or "",
181
+ thread_id=thread_id or "main",
182
+ source=SOURCE,
183
+ request_id=request_id,
184
+ ts=ts,
185
+ tool_name=block.get("name"),
186
+ target=tool_target(block.get("input")),
187
+ )
188
+
189
+ elif rec_type == "user":
190
+ for block in blocks:
191
+ if not isinstance(block, dict) or block.get("type") != "tool_result":
192
+ continue
193
+ tool_id = block.get("tool_use_id")
194
+ call = tools.get(tool_id) if tool_id else None
195
+ if call is None:
196
+ continue
197
+ call.is_error = bool(block.get("is_error"))
198
+ call.result_chars = content_chars(block.get("content"))
199
+
200
+ if session_id is None:
201
+ return batch
202
+
203
+ thread = thread_id or "main"
204
+ batch.sessions.append(
205
+ Session(
206
+ session_id=session_id,
207
+ thread_id=thread,
208
+ source=SOURCE,
209
+ cwd=meta.get("cwd"),
210
+ project_slug=_project_slug(path),
211
+ git_branch=meta.get("gitBranch"),
212
+ cli_version=meta.get("version"),
213
+ entrypoint=meta.get("entrypoint"),
214
+ agent_type=meta.get("attributionAgent"),
215
+ parent_session_id=session_id if is_subagent else None,
216
+ is_subagent=is_subagent,
217
+ started_at=first_ts,
218
+ ended_at=last_ts,
219
+ transcript_path=str(path),
220
+ )
221
+ )
222
+ batch.turns = list(turns.values())
223
+ batch.tool_calls = list(tools.values())
224
+ return batch
225
+
226
+
227
+ def parse(path: Path, kind: str, start_offset: int = 0) -> Batch:
228
+ if kind == "history":
229
+ return parse_history(path, start_offset)
230
+ return parse_transcript(path, start_offset, is_subagent=(kind == "subagent"))
@@ -0,0 +1,122 @@
1
+ """Codex CLI adapter -- STRUCTURE UNVERIFIED.
2
+
3
+ No Codex rollout files were available on the machine this was written against,
4
+ so this adapter is deliberately shape-agnostic: it walks each record looking for
5
+ anything that resembles a usage object and a request identifier, rather than
6
+ asserting a layout it cannot confirm.
7
+
8
+ That makes it useful as a starting point and as the template for a third
9
+ adapter, but treat its output as provisional until someone verifies it against
10
+ real files. Rows land with `source='codex'`, so they are trivial to exclude.
11
+
12
+ Contributing a verified version is the single highest-value PR here: paste two
13
+ or three real records into a test fixture and tighten the parsing.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from ..schema import Batch, Session, Turn
22
+ from .base import as_int, iter_lines, parse_ts
23
+
24
+ SOURCE = "codex"
25
+
26
+ # Both Anthropic-style and OpenAI-style names, since Codex may use either.
27
+ _INPUT_KEYS = ("input_tokens", "prompt_tokens")
28
+ _OUTPUT_KEYS = ("output_tokens", "completion_tokens")
29
+
30
+
31
+ def _find_usage(node: Any, depth: int = 0):
32
+ """Depth-limited hunt for a dict that looks like a usage object."""
33
+ if depth > 6 or not isinstance(node, dict):
34
+ return None
35
+ looks_like_usage = any(k in node for k in _INPUT_KEYS) and any(
36
+ k in node for k in _OUTPUT_KEYS
37
+ )
38
+ if looks_like_usage:
39
+ return node
40
+ for value in node.values():
41
+ if isinstance(value, dict):
42
+ found = _find_usage(value, depth + 1)
43
+ if found is not None:
44
+ return found
45
+ return None
46
+
47
+
48
+ def _first(node: dict, keys) -> Any:
49
+ for key in keys:
50
+ value = node.get(key)
51
+ if value:
52
+ return value
53
+ return None
54
+
55
+
56
+ def parse(path: Path, kind: str = "transcript", start_offset: int = 0) -> Batch:
57
+ batch = Batch(byte_offset=start_offset)
58
+ session_id = path.stem
59
+ turns: dict[str, Turn] = {}
60
+ model_id = None
61
+ first_ts = last_ts = None
62
+
63
+ for record, offset in iter_lines(path, start_offset):
64
+ batch.byte_offset = offset
65
+ if record.get("__unparsed__"):
66
+ batch.skipped_lines += 1
67
+ continue
68
+
69
+ ts = parse_ts(_first(record, ("timestamp", "created_at", "time")))
70
+ if ts:
71
+ first_ts = ts if first_ts is None or ts < first_ts else first_ts
72
+ last_ts = ts if last_ts is None or ts > last_ts else last_ts
73
+ model_id = model_id or _first(record, ("model", "model_id"))
74
+
75
+ usage = _find_usage(record)
76
+ if not usage:
77
+ continue
78
+
79
+ request_id = (
80
+ _first(record, ("request_id", "response_id", "id"))
81
+ or f"{session_id}:{offset}"
82
+ )
83
+ fields = {
84
+ "input_tokens": as_int(_first(usage, _INPUT_KEYS)),
85
+ "output_tokens": as_int(_first(usage, _OUTPUT_KEYS)),
86
+ "cache_read_tokens": as_int(
87
+ _first(usage, ("cache_read_input_tokens", "cached_tokens"))
88
+ ),
89
+ }
90
+ existing = turns.get(request_id)
91
+ if existing is None:
92
+ turns[request_id] = Turn(
93
+ request_id=f"codex:{request_id}",
94
+ session_id=session_id,
95
+ thread_id="main",
96
+ source=SOURCE,
97
+ model_id=_first(record, ("model", "model_id")) or model_id,
98
+ ts=ts,
99
+ speed="standard",
100
+ block_lines=1,
101
+ **fields,
102
+ )
103
+ else:
104
+ # Same dedupe discipline as the Claude Code adapter: if a runtime
105
+ # repeats usage across records, MAX keeps the total honest.
106
+ for key, value in fields.items():
107
+ setattr(existing, key, max(getattr(existing, key), value))
108
+ existing.block_lines += 1
109
+
110
+ if turns:
111
+ batch.sessions.append(
112
+ Session(
113
+ session_id=session_id,
114
+ thread_id="main",
115
+ source=SOURCE,
116
+ started_at=first_ts,
117
+ ended_at=last_ts,
118
+ transcript_path=str(path),
119
+ )
120
+ )
121
+ batch.turns = list(turns.values())
122
+ return batch