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.
- toknt/__init__.py +5 -0
- toknt/adapters/__init__.py +99 -0
- toknt/adapters/amp_like.py +379 -0
- toknt/adapters/base.py +203 -0
- toknt/adapters/claude_code.py +741 -0
- toknt/adapters/claude_like.py +104 -0
- toknt/adapters/cline_like.py +706 -0
- toknt/adapters/codex.py +821 -0
- toknt/adapters/codex_like.py +31 -0
- toknt/adapters/gemini_like.py +517 -0
- toknt/adapters/goose_like.py +231 -0
- toknt/adapters/grok_like.py +451 -0
- toknt/adapters/jsonl.py +91 -0
- toknt/adapters/kiro_like.py +406 -0
- toknt/adapters/opencode.py +766 -0
- toknt/adapters/pi_like.py +563 -0
- toknt/analytics/__init__.py +95 -0
- toknt/analytics/aggregation.py +1107 -0
- toknt/analytics/costs.py +217 -0
- toknt/analytics/model_names.py +154 -0
- toknt/analytics/quality.py +133 -0
- toknt/analytics/tool_metrics.py +185 -0
- toknt/cli.py +1128 -0
- toknt/config.py +236 -0
- toknt/discovery/__init__.py +556 -0
- toknt/models/__init__.py +37 -0
- toknt/models/events.py +314 -0
- toknt/models/usage.py +106 -0
- toknt/palette.py +27 -0
- toknt/presentation/__init__.py +10 -0
- toknt/presentation/charts.py +311 -0
- toknt/presentation/dashboard.py +214 -0
- toknt/presentation/graphics.py +821 -0
- toknt/presentation/tables.py +243 -0
- toknt/service.py +116 -0
- toknt/storage/__init__.py +45 -0
- toknt/storage/database.py +59 -0
- toknt/storage/ingestion.py +551 -0
- toknt/storage/migrations.py +261 -0
- toknt/storage/pricing.py +103 -0
- toknt/tui/app.py +488 -0
- toknt/tui/data.py +315 -0
- toknt/tui/keys.py +228 -0
- toknt/tui/painter.py +213 -0
- toknt/tui/style.py +47 -0
- toknt/tui/term.py +73 -0
- toknt/tui/views.py +1660 -0
- toknt/tui/widgets.py +489 -0
- toknt/util.py +14 -0
- toknt-0.1.0.dist-info/METADATA +547 -0
- toknt-0.1.0.dist-info/RECORD +54 -0
- toknt-0.1.0.dist-info/WHEEL +4 -0
- toknt-0.1.0.dist-info/entry_points.txt +2 -0
- toknt-0.1.0.dist-info/licenses/LICENSE +21 -0
toknt/__init__.py
ADDED
|
@@ -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."""
|