toknt 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.
Files changed (54) hide show
  1. toknt/__init__.py +5 -0
  2. toknt/adapters/__init__.py +99 -0
  3. toknt/adapters/amp_like.py +379 -0
  4. toknt/adapters/base.py +203 -0
  5. toknt/adapters/claude_code.py +741 -0
  6. toknt/adapters/claude_like.py +104 -0
  7. toknt/adapters/cline_like.py +706 -0
  8. toknt/adapters/codex.py +821 -0
  9. toknt/adapters/codex_like.py +31 -0
  10. toknt/adapters/gemini_like.py +517 -0
  11. toknt/adapters/goose_like.py +231 -0
  12. toknt/adapters/grok_like.py +451 -0
  13. toknt/adapters/jsonl.py +91 -0
  14. toknt/adapters/kiro_like.py +406 -0
  15. toknt/adapters/opencode.py +766 -0
  16. toknt/adapters/pi_like.py +563 -0
  17. toknt/analytics/__init__.py +95 -0
  18. toknt/analytics/aggregation.py +1107 -0
  19. toknt/analytics/costs.py +217 -0
  20. toknt/analytics/model_names.py +154 -0
  21. toknt/analytics/quality.py +133 -0
  22. toknt/analytics/tool_metrics.py +185 -0
  23. toknt/cli.py +1128 -0
  24. toknt/config.py +236 -0
  25. toknt/discovery/__init__.py +556 -0
  26. toknt/models/__init__.py +37 -0
  27. toknt/models/events.py +314 -0
  28. toknt/models/usage.py +106 -0
  29. toknt/palette.py +27 -0
  30. toknt/presentation/__init__.py +10 -0
  31. toknt/presentation/charts.py +311 -0
  32. toknt/presentation/dashboard.py +214 -0
  33. toknt/presentation/graphics.py +821 -0
  34. toknt/presentation/tables.py +243 -0
  35. toknt/service.py +116 -0
  36. toknt/storage/__init__.py +45 -0
  37. toknt/storage/database.py +59 -0
  38. toknt/storage/ingestion.py +551 -0
  39. toknt/storage/migrations.py +261 -0
  40. toknt/storage/pricing.py +103 -0
  41. toknt/tui/app.py +488 -0
  42. toknt/tui/data.py +315 -0
  43. toknt/tui/keys.py +228 -0
  44. toknt/tui/painter.py +213 -0
  45. toknt/tui/style.py +47 -0
  46. toknt/tui/term.py +73 -0
  47. toknt/tui/views.py +1660 -0
  48. toknt/tui/widgets.py +489 -0
  49. toknt/util.py +14 -0
  50. toknt-0.1.0.dist-info/METADATA +547 -0
  51. toknt-0.1.0.dist-info/RECORD +54 -0
  52. toknt-0.1.0.dist-info/WHEEL +4 -0
  53. toknt-0.1.0.dist-info/entry_points.txt +2 -0
  54. toknt-0.1.0.dist-info/licenses/LICENSE +21 -0
toknt/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """toknt — local-first usage analytics for AI coding agents."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,99 @@
1
+ """Adapter registry.
2
+
3
+ Adapters are instantiated per scan; each one declares which harness it
4
+ reads. Formats that share a shape (Claude-Code-style, Codex-style, Gemini-
5
+ style, Cline-style, pi-style) reuse one parser parameterized by harness
6
+ identity and discovery root.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from toknt.adapters.amp_like import AmpAdapter
12
+ from toknt.adapters.base import Adapter, ScanReport, categorize_tool, normalize_epoch_ms
13
+ from toknt.adapters.claude_code import ClaudeCodeAdapter
14
+ from toknt.adapters.claude_like import CodeBuddyAdapter, CommandCodeAdapter, ZCodeAdapter
15
+ from toknt.adapters.cline_like import (
16
+ ClineSessionsAdapter,
17
+ ClineTaskAdapter,
18
+ KiloCodeTaskAdapter,
19
+ RooTaskAdapter,
20
+ )
21
+ from toknt.adapters.codex import CodexAdapter
22
+ from toknt.adapters.codex_like import TraeAdapter
23
+ from toknt.adapters.gemini_like import GeminiAdapter, QwenAdapter
24
+ from toknt.adapters.goose_like import GooseAdapter
25
+ from toknt.adapters.grok_like import GrokAdapter
26
+ from toknt.adapters.kiro_like import KiroAdapter
27
+ from toknt.adapters.opencode import OpenCodeAdapter
28
+ from toknt.adapters.pi_like import PiFamilyAdapter
29
+
30
+ __all__ = [
31
+ "Adapter",
32
+ "ScanReport",
33
+ "categorize_tool",
34
+ "normalize_epoch_ms",
35
+ "registered_adapters",
36
+ ]
37
+
38
+
39
+ def _pi_subclass(harness, source_id: str) -> type[PiFamilyAdapter]:
40
+ """One adapter class per pi-family harness (identity is class state)."""
41
+ name = str(harness)
42
+
43
+ def discover(
44
+ self,
45
+ *,
46
+ env=None,
47
+ home=None,
48
+ override=None,
49
+ ):
50
+ from toknt.discovery import discover_pi_family
51
+
52
+ return discover_pi_family(self.agent, env=env, home=home, override=override)
53
+
54
+ return type(
55
+ f"{name.capitalize()}PiAdapter",
56
+ (PiFamilyAdapter,),
57
+ {
58
+ "source_id": source_id,
59
+ "agent": harness,
60
+ "discover": discover,
61
+ },
62
+ )
63
+
64
+
65
+ def registered_adapters() -> tuple[Adapter, ...]:
66
+ """Every adapter the scanner knows about, in stable display order."""
67
+ from toknt.models.events import Harness
68
+
69
+ pi_family = tuple(
70
+ _pi_subclass(harness, f"{str(harness)}:sessions")()
71
+ for harness in (
72
+ Harness.PI,
73
+ Harness.SENPI,
74
+ Harness.OMP,
75
+ Harness.GJC,
76
+ Harness.PRIME,
77
+ Harness.KIMCHI,
78
+ )
79
+ )
80
+ return (
81
+ OpenCodeAdapter(),
82
+ CodexAdapter(),
83
+ ClaudeCodeAdapter(),
84
+ CommandCodeAdapter(),
85
+ CodeBuddyAdapter(),
86
+ ZCodeAdapter(),
87
+ TraeAdapter(),
88
+ GeminiAdapter(),
89
+ QwenAdapter(),
90
+ ClineSessionsAdapter(),
91
+ ClineTaskAdapter(),
92
+ RooTaskAdapter(),
93
+ KiloCodeTaskAdapter(),
94
+ *pi_family,
95
+ KiroAdapter(),
96
+ AmpAdapter(),
97
+ GooseAdapter(),
98
+ GrokAdapter(),
99
+ )
@@ -0,0 +1,379 @@
1
+ """Amp adapter (Sourcegraph thread files).
2
+
3
+ Amp stores one JSON object per thread under the XDG data directory
4
+ (``~/.local/share/amp/threads/*.json``). Current builds only materialize
5
+ thread files when a plugin exports them (or for older builds, always); the
6
+ files are rewritten whole, so scans re-parse on mtime/size changes with
7
+ stable ids keeping re-upserts idempotent.
8
+
9
+ Threads carry ``id``, ``title``, ``created`` (epoch ms), a
10
+ ``env.initial.trees[0].uri`` workspace hint, and a ``messages`` array:
11
+ ``user`` messages are stamped with ``meta.sentAt`` (epoch ms), ``assistant``
12
+ messages with ``usage`` — whose ``timestamp`` (ISO 8601) anchors the event.
13
+ ``tool_use`` blocks pair with ``tool_result`` blocks carrying a ``run``
14
+ object (``result.output``, ``result.exitCode``, ``error``).
15
+
16
+ Token semantics: Amp follows the models.dev usage convention (verified for
17
+ the sibling pi adapter against a live corpus): ``input``/``output`` exclude
18
+ cache reads and writes, which are reported separately as
19
+ ``cacheReadInputTokens``/``cacheCreationInputTokens`` — the additive
20
+ anthropic scheme. When a thread file carries no usage at all (common for
21
+ exported threads), the session and its tools still count and the missing
22
+ tokens stay missing.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import dataclasses
28
+ import json
29
+ import sqlite3
30
+ from dataclasses import dataclass, field
31
+ from pathlib import Path
32
+ from typing import ClassVar
33
+
34
+ from toknt.adapters.base import (
35
+ Adapter,
36
+ ScanReport,
37
+ as_dict,
38
+ categorize_tool,
39
+ normalize_epoch_ms,
40
+ opt_str,
41
+ parse_iso8601_ms,
42
+ token_int,
43
+ )
44
+ from toknt.adapters.cline_like import _load_json, _save_whole_file, _whole_file_changed
45
+ from toknt.discovery import DiscoveredSource
46
+ from toknt.models.events import (
47
+ Harness,
48
+ Outcome,
49
+ OutcomeProvenance,
50
+ Quality,
51
+ SessionRecord,
52
+ ToolEvent,
53
+ UsageEvent,
54
+ stable_event_id,
55
+ )
56
+ from toknt.models.usage import TokenCounts, TokenScheme
57
+ from toknt.storage.ingestion import (
58
+ clear_quality_issues,
59
+ reconcile_source_events,
60
+ record_quality_issue,
61
+ upsert_sessions,
62
+ upsert_tool_events,
63
+ upsert_usage_events,
64
+ )
65
+
66
+
67
+ class AmpAdapter(Adapter):
68
+ source_id: ClassVar[str] = "amp:threads"
69
+ agent: ClassVar[Harness] = Harness.AMP
70
+ kind: ClassVar[str] = "json_dir"
71
+
72
+ def discover(
73
+ self,
74
+ *,
75
+ env: dict[str, str] | None = None,
76
+ home: object | None = None,
77
+ override: str | None = None,
78
+ ) -> DiscoveredSource:
79
+ from toknt.discovery import discover_amp
80
+
81
+ return discover_amp(env=env, home=home, override=override)
82
+
83
+ def scan(
84
+ self,
85
+ conn: sqlite3.Connection,
86
+ source: DiscoveredSource,
87
+ *,
88
+ full: bool = False,
89
+ ) -> ScanReport:
90
+ mode = "full" if full else "incremental"
91
+ root = Path(source.location)
92
+ if not root.is_dir():
93
+ return ScanReport(
94
+ source_id=self.source_id,
95
+ mode=mode,
96
+ status="error",
97
+ errors=1,
98
+ notes=(f"threads directory not found: {root}",),
99
+ )
100
+ if full:
101
+ clear_quality_issues(conn, source_id=self.source_id)
102
+
103
+ usage_events: list[UsageEvent] = []
104
+ tool_events: list[ToolEvent] = []
105
+ sessions: dict[str, SessionRecord] = {}
106
+ rows_seen = skipped = errors = files_scanned = 0
107
+ notes: list[str] = []
108
+
109
+ for path in sorted(root.glob("*.json")):
110
+ relpath = path.relative_to(root).as_posix()
111
+ changed, _checkpoint = _whole_file_changed(
112
+ conn, self.source_id, relpath, path, full=full
113
+ )
114
+ if not changed:
115
+ continue
116
+ try:
117
+ thread = as_dict(_load_json(path))
118
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError) as exc:
119
+ self._quality_issue(
120
+ conn,
121
+ code="unparsable_file",
122
+ message=f"cannot read thread: {type(exc).__name__}",
123
+ sample_ref=relpath,
124
+ )
125
+ errors += 1
126
+ continue
127
+ thread_id = opt_str(thread.get("id")) or path.stem
128
+ files_scanned += 1
129
+ outcome = self._process_thread(thread, thread_id=thread_id)
130
+ rows_seen += outcome.rows_seen
131
+ skipped += outcome.skipped
132
+ usage_events.extend(outcome.usage)
133
+ tool_events.extend(outcome.tools)
134
+ if outcome.session is not None:
135
+ sessions[thread_id] = outcome.session
136
+ _save_whole_file(conn, self.source_id, relpath, path)
137
+
138
+ usage_stats = upsert_usage_events(conn, usage_events)
139
+ tool_stats = upsert_tool_events(conn, tool_events)
140
+ session_stats = upsert_sessions(conn, sessions.values())
141
+ reconciled = 0
142
+ if full:
143
+ reconciled += reconcile_source_events(
144
+ conn,
145
+ source_id=self.source_id,
146
+ table="usage_events",
147
+ seen_ids=[event.id for event in usage_events],
148
+ )
149
+ reconciled += reconcile_source_events(
150
+ conn,
151
+ source_id=self.source_id,
152
+ table="tool_events",
153
+ seen_ids=[event.id for event in tool_events],
154
+ )
155
+ if files_scanned == 0:
156
+ summary = "no thread files found"
157
+ else:
158
+ summary = f"{files_scanned} threads read"
159
+ return ScanReport(
160
+ source_id=self.source_id,
161
+ mode=mode,
162
+ status="partial" if errors else "ok",
163
+ rows_seen=rows_seen,
164
+ usage_inserted=usage_stats.inserted,
165
+ usage_updated=usage_stats.updated,
166
+ tool_inserted=tool_stats.inserted,
167
+ tool_updated=tool_stats.updated,
168
+ sessions_written=session_stats.total,
169
+ skipped=skipped,
170
+ errors=errors,
171
+ reconciled=reconciled,
172
+ notes=(summary, *notes),
173
+ )
174
+
175
+ def _process_thread(self, thread: dict, *, thread_id: str) -> _ThreadOutcome:
176
+ result = _ThreadOutcome()
177
+ messages = thread.get("messages")
178
+ created = normalize_epoch_ms(thread.get("created"))
179
+ project = _project_from_uri(thread)
180
+ last_ts = created
181
+ pending: dict[str, dict] = {}
182
+
183
+ for index, message in enumerate(as_list(messages)):
184
+ if not isinstance(message, dict):
185
+ continue
186
+ result.rows_seen += 1
187
+ role = opt_str(message.get("role"))
188
+ ts_ms = (
189
+ normalize_epoch_ms(as_dict(message.get("meta")).get("sentAt"))
190
+ or parse_iso8601_ms(as_dict(message.get("usage")).get("timestamp"))
191
+ or last_ts
192
+ )
193
+ if ts_ms:
194
+ last_ts = ts_ms
195
+
196
+ for block in as_list(message.get("content")):
197
+ if not isinstance(block, dict):
198
+ continue
199
+ block_type = block.get("type")
200
+ call_id = opt_str(block.get("id")) or opt_str(block.get("tool_use_id"))
201
+ if block_type == "tool_use" and ts_ms is not None:
202
+ name = opt_str(block.get("name")) or "unknown"
203
+ key = call_id or f"{thread_id}:tool-{index}"
204
+ pending[key] = {"name": name, "ts_ms": ts_ms}
205
+ result.tools.append(
206
+ _tool_event(
207
+ key,
208
+ name=name,
209
+ start_ms=ts_ms,
210
+ end_ms=None,
211
+ run=None,
212
+ model=None,
213
+ )
214
+ )
215
+ elif block_type == "tool_result":
216
+ key = call_id or f"{thread_id}:tool-{index}"
217
+ call = pending.pop(key, None)
218
+ start_ms = token_int(as_dict(call).get("ts_ms")) or ts_ms
219
+ if start_ms is None:
220
+ result.skipped += 1
221
+ continue
222
+ result.tools.append(
223
+ _tool_event(
224
+ key,
225
+ name=opt_str(as_dict(call).get("name")) or "unknown",
226
+ start_ms=start_ms,
227
+ end_ms=ts_ms,
228
+ run=block.get("run"),
229
+ model=opt_str(as_dict(call).get("model")),
230
+ )
231
+ )
232
+
233
+ if role == "assistant":
234
+ usage = as_dict(message.get("usage"))
235
+ if usage and ts_ms is not None:
236
+ event = self._usage_event(
237
+ usage, thread_id=thread_id, index=index, ts_ms=ts_ms
238
+ )
239
+ if event is not None:
240
+ result.usage.append(event)
241
+ elif usage:
242
+ result.skipped += 1
243
+
244
+ result.session = SessionRecord(
245
+ agent=self.agent,
246
+ session_id=thread_id,
247
+ source_id=self.source_id,
248
+ project=project,
249
+ directory=project,
250
+ title=opt_str(thread.get("title")),
251
+ time_created_ms=created,
252
+ time_updated_ms=last_ts,
253
+ )
254
+ return result
255
+
256
+ def _usage_event(
257
+ self, usage: dict, *, thread_id: str, index: int, ts_ms: int
258
+ ) -> UsageEvent | None:
259
+ input_tokens = token_int(usage.get("inputTokens"))
260
+ output_tokens = token_int(usage.get("outputTokens"))
261
+ if input_tokens is None and output_tokens is None:
262
+ return None
263
+ counts = TokenCounts(
264
+ input=input_tokens,
265
+ output=output_tokens,
266
+ reasoning=0, # reasoning is billed inside output in this convention
267
+ cache_read=token_int(usage.get("cacheReadInputTokens")),
268
+ cache_write=token_int(usage.get("cacheCreationInputTokens")),
269
+ )
270
+ return _finalize_usage(
271
+ UsageEvent(
272
+ id=stable_event_id(
273
+ kind="usage",
274
+ agent=str(self.agent),
275
+ source_event_id=f"{thread_id}:msg-{index}",
276
+ ),
277
+ agent=self.agent,
278
+ source_id=self.source_id,
279
+ source_event_id=f"{thread_id}:msg-{index}",
280
+ session_id=thread_id,
281
+ ts_ms=ts_ms,
282
+ tokens=counts,
283
+ token_scheme=TokenScheme.ANTHROPIC,
284
+ provider_total_tokens=token_int(usage.get("totalTokens")),
285
+ source_event_type="thread.message",
286
+ )
287
+ )
288
+
289
+ def _quality_issue(
290
+ self, conn: sqlite3.Connection, *, code: str, message: str, sample_ref: str
291
+ ) -> None:
292
+ record_quality_issue(
293
+ conn,
294
+ agent=self.agent,
295
+ source_id=self.source_id,
296
+ code=code,
297
+ message=message,
298
+ sample_ref=sample_ref,
299
+ )
300
+
301
+
302
+ def _project_from_uri(thread: dict) -> str | None:
303
+ env = as_dict(thread.get("env"))
304
+ initial = as_dict(env.get("initial"))
305
+ trees = initial.get("trees")
306
+ if isinstance(trees, list) and trees:
307
+ uri = as_dict(trees[0]).get("uri")
308
+ if isinstance(uri, str) and uri.startswith("file://"):
309
+ path = uri[len("file://"):]
310
+ return path.rstrip("/") or None
311
+ return None
312
+
313
+
314
+ def _tool_event(
315
+ key: str,
316
+ *,
317
+ name: str,
318
+ start_ms: int,
319
+ end_ms: int | None,
320
+ run: object,
321
+ model: str | None,
322
+ ) -> ToolEvent:
323
+ run_dict = as_dict(run)
324
+ result = as_dict(run_dict.get("result"))
325
+ outcome = Outcome.UNKNOWN
326
+ confidence: float | None = None
327
+ if end_ms is not None:
328
+ exit_code = result.get("exitCode")
329
+ if isinstance(exit_code, int) and not isinstance(exit_code, bool):
330
+ outcome = Outcome.SUCCESS if exit_code == 0 else Outcome.FAILURE
331
+ confidence = 0.9
332
+ elif result.get("error"):
333
+ outcome = Outcome.FAILURE
334
+ confidence = 0.8
335
+ elif result.get("output"):
336
+ outcome = Outcome.SUCCESS
337
+ confidence = 0.7
338
+ elif end_ms is None:
339
+ outcome = Outcome.PENDING
340
+ duration = end_ms - start_ms if end_ms is not None and end_ms >= start_ms else None
341
+ return ToolEvent(
342
+ id=stable_event_id(kind="tool", agent=str(Harness.AMP), source_event_id=key),
343
+ agent=Harness.AMP,
344
+ source_id="amp:threads",
345
+ source_event_id=key,
346
+ call_id=key if key.startswith("call") or "-" in key else None,
347
+ tool_name=name,
348
+ tool_category=categorize_tool(name),
349
+ ts_ms=start_ms,
350
+ duration_ms=duration,
351
+ outcome=outcome,
352
+ outcome_provenance=OutcomeProvenance.RESULT_FIELD
353
+ if outcome in (Outcome.SUCCESS, Outcome.FAILURE)
354
+ else OutcomeProvenance.ABSENT,
355
+ outcome_confidence=confidence,
356
+ model=model,
357
+ source_event_type="thread.tool_use",
358
+ )
359
+
360
+
361
+ def _finalize_usage(event: UsageEvent) -> UsageEvent:
362
+ derived = event.consistency_notes()
363
+ if derived:
364
+ quality = event.quality if event.quality is not Quality.OK else Quality.PARTIAL
365
+ return dataclasses.replace(event, quality=quality, quality_notes=derived)
366
+ return event
367
+
368
+
369
+ def as_list(value: object) -> list:
370
+ return value if isinstance(value, list) else []
371
+
372
+
373
+ @dataclass(slots=True)
374
+ class _ThreadOutcome:
375
+ rows_seen: int = 0
376
+ skipped: int = 0
377
+ usage: list[UsageEvent] = field(default_factory=list)
378
+ tools: list[ToolEvent] = field(default_factory=list)
379
+ session: SessionRecord | None = None
toknt/adapters/base.py ADDED
@@ -0,0 +1,203 @@
1
+ """Adapter boundary: native agent formats never leak past this package.
2
+
3
+ Adapters translate a source's native storage into canonical records
4
+ (:mod:`toknt.models.events`). They must not aggregate, render or interpret
5
+ data-quality semantics beyond setting the documented fields — everything
6
+ downstream works on the canonical model.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sqlite3
12
+ from abc import ABC, abstractmethod
13
+ from dataclasses import dataclass
14
+ from datetime import UTC, datetime
15
+ from typing import ClassVar
16
+
17
+ from toknt.discovery import DiscoveredSource
18
+ from toknt.models.events import Agent, ToolCategory
19
+
20
+ #: Tool-name → category mapping shared by all adapters. Categories are
21
+ #: presentation-friendly groupings, never used for accounting.
22
+ _TOOL_CATEGORIES: dict[str, ToolCategory] = {
23
+ "bash": ToolCategory.SHELL,
24
+ "shell": ToolCategory.SHELL,
25
+ "shell_command": ToolCategory.SHELL,
26
+ "exec": ToolCategory.SHELL,
27
+ "exec_command": ToolCategory.SHELL,
28
+ "js": ToolCategory.SHELL,
29
+ "write_stdin": ToolCategory.SHELL,
30
+ "local_shell": ToolCategory.SHELL,
31
+ "run": ToolCategory.SHELL,
32
+ "terminal": ToolCategory.SHELL,
33
+ "read": ToolCategory.FILE_READ,
34
+ "read_file": ToolCategory.FILE_READ,
35
+ "view": ToolCategory.FILE_READ,
36
+ "view_image": ToolCategory.FILE_READ,
37
+ "cat": ToolCategory.FILE_READ,
38
+ "edit": ToolCategory.FILE_WRITE,
39
+ "write": ToolCategory.FILE_WRITE,
40
+ "write_file": ToolCategory.FILE_WRITE,
41
+ "create_file": ToolCategory.FILE_WRITE,
42
+ "apply_patch": ToolCategory.FILE_WRITE,
43
+ "patch": ToolCategory.FILE_WRITE,
44
+ "multiedit": ToolCategory.FILE_WRITE,
45
+ "notebook_edit": ToolCategory.FILE_WRITE,
46
+ "grep": ToolCategory.SEARCH,
47
+ "glob": ToolCategory.SEARCH,
48
+ "search": ToolCategory.SEARCH,
49
+ "find": ToolCategory.SEARCH,
50
+ "list": ToolCategory.SEARCH,
51
+ "ls": ToolCategory.SEARCH,
52
+ "list_dir": ToolCategory.SEARCH,
53
+ "codebase_search": ToolCategory.SEARCH,
54
+ "webfetch": ToolCategory.WEB,
55
+ "web_search": ToolCategory.WEB,
56
+ "websearch": ToolCategory.WEB,
57
+ "fetch": ToolCategory.WEB,
58
+ "browser": ToolCategory.WEB,
59
+ "task": ToolCategory.TASK,
60
+ "agent": ToolCategory.TASK,
61
+ "spawn_agent": ToolCategory.TASK,
62
+ "followup_task": ToolCategory.TASK,
63
+ "wait_agent": ToolCategory.TASK,
64
+ "subagent": ToolCategory.TASK,
65
+ "delegate": ToolCategory.TASK,
66
+ }
67
+
68
+ _TOOL_PREFIXES: tuple[tuple[str, ToolCategory], ...] = (
69
+ ("shell", ToolCategory.SHELL),
70
+ ("exec", ToolCategory.SHELL),
71
+ ("bash", ToolCategory.SHELL),
72
+ ("read", ToolCategory.FILE_READ),
73
+ ("cat", ToolCategory.FILE_READ),
74
+ ("write", ToolCategory.FILE_WRITE),
75
+ ("edit", ToolCategory.FILE_WRITE),
76
+ ("patch", ToolCategory.FILE_WRITE),
77
+ ("grep", ToolCategory.SEARCH),
78
+ ("glob", ToolCategory.SEARCH),
79
+ ("search", ToolCategory.SEARCH),
80
+ ("list", ToolCategory.SEARCH),
81
+ ("web", ToolCategory.WEB),
82
+ ("task", ToolCategory.TASK),
83
+ ("agent", ToolCategory.TASK),
84
+ )
85
+
86
+
87
+ def categorize_tool(name: str | None) -> ToolCategory:
88
+ """Best-effort category for a tool name; unknown tools stay ``other``."""
89
+ key = (name or "").strip().lower()
90
+ if key in _TOOL_CATEGORIES:
91
+ return _TOOL_CATEGORIES[key]
92
+ for prefix, category in _TOOL_PREFIXES:
93
+ if key.startswith(prefix):
94
+ return category
95
+ return ToolCategory.OTHER
96
+
97
+
98
+ def as_dict(value: object) -> dict:
99
+ """Return ``value`` when it is a dict, otherwise an empty dict."""
100
+ return value if isinstance(value, dict) else {}
101
+
102
+
103
+ def opt_str(value: object) -> str | None:
104
+ """Return non-empty strings, otherwise ``None``."""
105
+ return value if isinstance(value, str) and value else None
106
+
107
+
108
+ def token_int(value: object) -> int | None:
109
+ """Coerce a token count; booleans and negatives are not counts."""
110
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
111
+ return None
112
+ number = int(value)
113
+ return number if number >= 0 else None
114
+
115
+
116
+ def parse_iso8601_ms(value: object) -> int | None:
117
+ """Parse an ISO-8601 timestamp (optional ``Z``) into epoch milliseconds."""
118
+ if not isinstance(value, str) or not value:
119
+ return None
120
+ try:
121
+ stamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
122
+ except ValueError:
123
+ return None
124
+ if stamp.tzinfo is None:
125
+ stamp = stamp.replace(tzinfo=UTC)
126
+ result = int(stamp.timestamp() * 1000)
127
+ return result if result > 0 else None
128
+
129
+
130
+ def normalize_epoch_ms(value: object) -> int | None:
131
+ """Coerce a source timestamp into UTC epoch milliseconds.
132
+
133
+ Tolerates seconds, milliseconds, microseconds and nanoseconds so that a
134
+ future format change degrades to a quality note instead of wrong dates.
135
+ Values that cannot plausibly be timestamps return ``None``.
136
+ """
137
+ if value is None or isinstance(value, bool):
138
+ return None
139
+ if not isinstance(value, (int, float)):
140
+ return None
141
+ number = int(value)
142
+ if number <= 0:
143
+ return None
144
+ if number >= 100_000_000_000_000: # ≥ 1e14: microseconds (1973+)
145
+ return number // 1000
146
+ if number >= 100_000_000_000: # ≥ 1e11: milliseconds (1973+)
147
+ return number
148
+ if number >= 1_000_000_000: # ≥ 1e9: seconds (2001+)
149
+ return number * 1000
150
+ return None
151
+
152
+
153
+ @dataclass(frozen=True, slots=True)
154
+ class ScanReport:
155
+ """Outcome of one adapter scan over one source."""
156
+
157
+ source_id: str
158
+ mode: str # "incremental" | "full"
159
+ status: str = "ok" # "ok" | "partial" | "error"
160
+ rows_seen: int = 0
161
+ usage_inserted: int = 0
162
+ usage_updated: int = 0
163
+ tool_inserted: int = 0
164
+ tool_updated: int = 0
165
+ sessions_written: int = 0
166
+ skipped: int = 0
167
+ errors: int = 0
168
+ reconciled: int = 0
169
+ fingerprint: str | None = None
170
+ notes: tuple[str, ...] = ()
171
+
172
+
173
+ class Adapter(ABC):
174
+ """Base class for source adapters.
175
+
176
+ ``discover`` and ``scan`` must be side-effect free with respect to the
177
+ source (read-only) and idempotent with respect to the canonical database
178
+ (stable event ids, upsert semantics).
179
+ """
180
+
181
+ source_id: ClassVar[str]
182
+ agent: ClassVar[Agent]
183
+ kind: ClassVar[str]
184
+
185
+ @abstractmethod
186
+ def discover(
187
+ self,
188
+ *,
189
+ env: dict[str, str] | None = None,
190
+ home: object | None = None,
191
+ override: str | None = None,
192
+ ) -> DiscoveredSource:
193
+ """Locate this adapter's source on disk."""
194
+
195
+ @abstractmethod
196
+ def scan(
197
+ self,
198
+ conn: sqlite3.Connection,
199
+ source: DiscoveredSource,
200
+ *,
201
+ full: bool = False,
202
+ ) -> ScanReport:
203
+ """Ingest new/changed data from ``source`` into the canonical database."""