agentprof 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.
- agentprof/__init__.py +7 -0
- agentprof/adapters/__init__.py +15 -0
- agentprof/adapters/base.py +79 -0
- agentprof/adapters/claude_code/__init__.py +3 -0
- agentprof/adapters/claude_code/adapter.py +133 -0
- agentprof/adapters/claude_code/discovery.py +79 -0
- agentprof/adapters/claude_code/prompts.py +44 -0
- agentprof/adapters/claude_code/tools.py +102 -0
- agentprof/adapters/claude_code/transcript.py +264 -0
- agentprof/adapters/claude_code/tree.py +492 -0
- agentprof/adapters/codex/__init__.py +3 -0
- agentprof/adapters/codex/adapter.py +164 -0
- agentprof/adapters/codex/discovery.py +117 -0
- agentprof/adapters/codex/prompts.py +16 -0
- agentprof/adapters/codex/rollout.py +328 -0
- agentprof/adapters/codex/tools.py +97 -0
- agentprof/adapters/codex/tree.py +311 -0
- agentprof/adapters/copilot_vscode/__init__.py +3 -0
- agentprof/adapters/copilot_vscode/adapter.py +166 -0
- agentprof/adapters/copilot_vscode/debuglog.py +136 -0
- agentprof/adapters/copilot_vscode/discovery.py +153 -0
- agentprof/adapters/copilot_vscode/session.py +281 -0
- agentprof/adapters/copilot_vscode/tools.py +127 -0
- agentprof/adapters/copilot_vscode/transcript.py +176 -0
- agentprof/adapters/copilot_vscode/tree.py +325 -0
- agentprof/adapters/execution.py +64 -0
- agentprof/adapters/timestamps.py +13 -0
- agentprof/adapters/turns.py +24 -0
- agentprof/analysis/__init__.py +3 -0
- agentprof/analysis/agent_summary.py +161 -0
- agentprof/analysis/call_context.py +101 -0
- agentprof/analysis/evidence_findings.py +151 -0
- agentprof/analysis/execution.py +39 -0
- agentprof/analysis/heuristics.py +306 -0
- agentprof/analysis/pipeline.py +31 -0
- agentprof/analysis/rollup.py +196 -0
- agentprof/cli.py +141 -0
- agentprof/model.py +341 -0
- agentprof/pricing.json +23 -0
- agentprof/pricing.py +120 -0
- agentprof/registry.py +304 -0
- agentprof/server/__init__.py +3 -0
- agentprof/server/app.py +399 -0
- agentprof/server/openapi.py +31 -0
- agentprof/server/pricing_schema.py +40 -0
- agentprof/server/schemas.py +579 -0
- agentprof/server/static/assets/index-C4HEqUVf.css +1 -0
- agentprof/server/static/assets/index-ZsPn1H3d.js +58 -0
- agentprof/server/static/index.html +14 -0
- agentprof-0.1.0.dist-info/METADATA +177 -0
- agentprof-0.1.0.dist-info/RECORD +54 -0
- agentprof-0.1.0.dist-info/WHEEL +4 -0
- agentprof-0.1.0.dist-info/entry_points.txt +8 -0
- agentprof-0.1.0.dist-info/licenses/LICENSE +21 -0
agentprof/registry.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 epicodic
|
|
3
|
+
"""Keep one up-to-date row per session: discover and summarise in the background, analyse on demand, follow changes."""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections import OrderedDict
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import dataclass, replace
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from agentprof.adapters.base import AgentAdapter, SessionRef, SessionSummary
|
|
15
|
+
from agentprof.analysis.pipeline import analyze
|
|
16
|
+
from agentprof.model import Session
|
|
17
|
+
|
|
18
|
+
_LOG = logging.getLogger(__name__)
|
|
19
|
+
_DEFAULT_CACHE_SIZE = 20
|
|
20
|
+
_DEFAULT_INTERVAL_S = 3.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RowState(StrEnum):
|
|
24
|
+
"""Progress of a row, relative to the session's current modification time."""
|
|
25
|
+
|
|
26
|
+
PENDING = "pending"
|
|
27
|
+
SUMMARIZED = "summarized"
|
|
28
|
+
ERROR = "error"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class EventKind(StrEnum):
|
|
32
|
+
UPDATED = "updated"
|
|
33
|
+
REMOVED = "removed"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class RegistryEvent:
|
|
38
|
+
kind: EventKind
|
|
39
|
+
session_id: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class SessionRow:
|
|
44
|
+
"""One session in the list. `*_mtime` record which file version each result belongs to."""
|
|
45
|
+
|
|
46
|
+
ref: SessionRef
|
|
47
|
+
summary: SessionSummary | None = None
|
|
48
|
+
error: str | None = None
|
|
49
|
+
summarized_mtime: float | None = None
|
|
50
|
+
failed_mtime: float | None = None
|
|
51
|
+
pinned: bool = False
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def state(self) -> RowState:
|
|
55
|
+
mtime = self.ref.mtime
|
|
56
|
+
if self.failed_mtime == mtime:
|
|
57
|
+
return RowState.ERROR
|
|
58
|
+
if self.summarized_mtime != mtime:
|
|
59
|
+
return RowState.PENDING
|
|
60
|
+
return RowState.SUMMARIZED
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SessionUnavailable(Exception):
|
|
64
|
+
"""A known session whose files could not be analysed."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _mtime(ref: SessionRef) -> float:
|
|
68
|
+
return ref.mtime
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _describe(error: Exception) -> str:
|
|
72
|
+
return f"{type(error).__name__}: {error}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Registry:
|
|
76
|
+
"""Thread-safe store of session rows; `step` summarises one row in the background."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, adapters: list[AgentAdapter], cache_size: int = _DEFAULT_CACHE_SIZE) -> None:
|
|
79
|
+
self._adapters = {adapter.name: adapter for adapter in adapters}
|
|
80
|
+
self._cache_size = cache_size
|
|
81
|
+
self._rows: dict[str, SessionRow] = {}
|
|
82
|
+
self._cache: OrderedDict[str, tuple[float, Session]] = OrderedDict()
|
|
83
|
+
self._in_flight: dict[tuple[str, float], threading.Event] = {}
|
|
84
|
+
self._subscribers: list[Callable[[RegistryEvent], None]] = []
|
|
85
|
+
self._lock = threading.RLock()
|
|
86
|
+
self._stop = threading.Event()
|
|
87
|
+
self._thread: threading.Thread | None = None
|
|
88
|
+
|
|
89
|
+
def subscribe(self, callback: Callable[[RegistryEvent], None]) -> Callable[[], None]:
|
|
90
|
+
"""Call `callback` for every event, from whichever thread caused it; returns an unsubscribe function."""
|
|
91
|
+
with self._lock:
|
|
92
|
+
self._subscribers.append(callback)
|
|
93
|
+
|
|
94
|
+
def unsubscribe() -> None:
|
|
95
|
+
with self._lock:
|
|
96
|
+
if callback in self._subscribers:
|
|
97
|
+
self._subscribers.remove(callback)
|
|
98
|
+
|
|
99
|
+
return unsubscribe
|
|
100
|
+
|
|
101
|
+
def _emit(self, events: list[RegistryEvent]) -> None:
|
|
102
|
+
with self._lock:
|
|
103
|
+
subscribers = list(self._subscribers)
|
|
104
|
+
for event in events:
|
|
105
|
+
for callback in subscribers:
|
|
106
|
+
try:
|
|
107
|
+
callback(event)
|
|
108
|
+
except Exception: # noqa: BLE001 - one failing subscriber must not stop the others or the worker
|
|
109
|
+
_LOG.exception("registry subscriber failed on %s", event)
|
|
110
|
+
|
|
111
|
+
def rows(self) -> list[SessionRow]:
|
|
112
|
+
"""A snapshot of all rows, most recently changed first."""
|
|
113
|
+
with self._lock:
|
|
114
|
+
rows = [replace(row) for row in self._rows.values()]
|
|
115
|
+
return sorted(rows, key=lambda row: row.ref.mtime, reverse=True)
|
|
116
|
+
|
|
117
|
+
def row(self, session_id: str) -> SessionRow | None:
|
|
118
|
+
with self._lock:
|
|
119
|
+
row = self._rows.get(session_id)
|
|
120
|
+
return replace(row) if row is not None else None
|
|
121
|
+
|
|
122
|
+
def _discover(self) -> tuple[dict[str, SessionRef], set[str]]:
|
|
123
|
+
discovered: dict[str, SessionRef] = {}
|
|
124
|
+
failed: set[str] = set()
|
|
125
|
+
for adapter in self._adapters.values():
|
|
126
|
+
try:
|
|
127
|
+
refs = list(adapter.discover())
|
|
128
|
+
except Exception: # noqa: BLE001 - one broken adapter must not stop the others
|
|
129
|
+
failed.add(adapter.name)
|
|
130
|
+
continue
|
|
131
|
+
discovered.update((ref.id, ref) for ref in refs)
|
|
132
|
+
return discovered, failed
|
|
133
|
+
|
|
134
|
+
def _reopen_pinned(self, discovered: dict[str, SessionRef]) -> None:
|
|
135
|
+
with self._lock:
|
|
136
|
+
pinned = [row.ref for row in self._rows.values() if row.pinned and row.ref.id not in discovered]
|
|
137
|
+
for ref in pinned:
|
|
138
|
+
reopened = self._adapters[ref.agent].open_path(ref.path)
|
|
139
|
+
if reopened is not None:
|
|
140
|
+
discovered[reopened.id] = reopened
|
|
141
|
+
|
|
142
|
+
def refresh(self) -> None:
|
|
143
|
+
"""Discover sessions of every adapter: add new rows, re-queue changed ones, drop vanished ones."""
|
|
144
|
+
discovered, failed = self._discover()
|
|
145
|
+
self._reopen_pinned(discovered)
|
|
146
|
+
events: list[RegistryEvent] = []
|
|
147
|
+
with self._lock:
|
|
148
|
+
for session_id, ref in discovered.items():
|
|
149
|
+
row = self._rows.get(session_id)
|
|
150
|
+
if row is None:
|
|
151
|
+
self._rows[session_id] = SessionRow(ref=ref)
|
|
152
|
+
elif row.ref.mtime != ref.mtime:
|
|
153
|
+
row.ref = ref
|
|
154
|
+
else:
|
|
155
|
+
continue
|
|
156
|
+
events.append(RegistryEvent(kind=EventKind.UPDATED, session_id=session_id))
|
|
157
|
+
vanished = [
|
|
158
|
+
session_id
|
|
159
|
+
for session_id, row in self._rows.items()
|
|
160
|
+
if session_id not in discovered and row.ref.agent not in failed
|
|
161
|
+
]
|
|
162
|
+
for session_id in vanished:
|
|
163
|
+
self._forget(session_id)
|
|
164
|
+
events.append(RegistryEvent(kind=EventKind.REMOVED, session_id=session_id))
|
|
165
|
+
self._emit(events)
|
|
166
|
+
|
|
167
|
+
def _forget(self, session_id: str) -> None:
|
|
168
|
+
"""Drop a row and its cached analysis; the lock must be held."""
|
|
169
|
+
del self._rows[session_id]
|
|
170
|
+
self._cache.pop(session_id, None)
|
|
171
|
+
|
|
172
|
+
def _next_pending(self) -> SessionRef | None:
|
|
173
|
+
"""The most recently changed row without a current summary."""
|
|
174
|
+
with self._lock:
|
|
175
|
+
pending = [row.ref for row in self._rows.values() if row.state is RowState.PENDING]
|
|
176
|
+
return max(pending, key=_mtime) if pending else None
|
|
177
|
+
|
|
178
|
+
def step(self) -> bool:
|
|
179
|
+
"""Summarise one row; returns `False` when every row is current."""
|
|
180
|
+
ref = self._next_pending()
|
|
181
|
+
if ref is None:
|
|
182
|
+
return False
|
|
183
|
+
try:
|
|
184
|
+
summary = self._adapters[ref.agent].summarize(ref)
|
|
185
|
+
self._update(ref, lambda row: _set_summary(row, summary))
|
|
186
|
+
except Exception as error: # noqa: BLE001 - any unreadable session becomes an error row
|
|
187
|
+
message = _describe(error)
|
|
188
|
+
self._update(ref, lambda row: _set_error(row, message))
|
|
189
|
+
return True
|
|
190
|
+
|
|
191
|
+
def _update(self, ref: SessionRef, change: Callable[[SessionRow], None]) -> None:
|
|
192
|
+
"""Apply `change` if the row still describes the same file version, then emit an event."""
|
|
193
|
+
with self._lock:
|
|
194
|
+
row = self._rows.get(ref.id)
|
|
195
|
+
if row is None or row.ref.mtime != ref.mtime:
|
|
196
|
+
return
|
|
197
|
+
change(row)
|
|
198
|
+
self._emit([RegistryEvent(kind=EventKind.UPDATED, session_id=ref.id)])
|
|
199
|
+
|
|
200
|
+
def session(self, session_id: str) -> tuple[Session, float]:
|
|
201
|
+
"""The analysed session and the file version it describes; analyses now unless cached.
|
|
202
|
+
|
|
203
|
+
Raises:
|
|
204
|
+
KeyError: `session_id` is not a known session.
|
|
205
|
+
SessionUnavailable: the session's files could not be analysed.
|
|
206
|
+
"""
|
|
207
|
+
with self._lock:
|
|
208
|
+
row = self._rows.get(session_id)
|
|
209
|
+
if row is None:
|
|
210
|
+
raise KeyError(session_id)
|
|
211
|
+
ref = row.ref
|
|
212
|
+
cached = self._cache.get(session_id)
|
|
213
|
+
if cached is not None and cached[0] == ref.mtime:
|
|
214
|
+
self._cache.move_to_end(session_id)
|
|
215
|
+
return cached[1], ref.mtime
|
|
216
|
+
key = (session_id, ref.mtime)
|
|
217
|
+
running = self._in_flight.get(key)
|
|
218
|
+
if running is None:
|
|
219
|
+
self._in_flight[key] = threading.Event()
|
|
220
|
+
if running is not None: # another request analyses this file version already: share its result
|
|
221
|
+
running.wait()
|
|
222
|
+
return self.session(session_id)
|
|
223
|
+
try:
|
|
224
|
+
session = analyze(self._adapters[ref.agent], ref)
|
|
225
|
+
except Exception as error: # noqa: BLE001 - reported to the caller as unavailable
|
|
226
|
+
raise SessionUnavailable(_describe(error)) from error
|
|
227
|
+
else:
|
|
228
|
+
with self._lock:
|
|
229
|
+
self._cache[session_id] = (ref.mtime, session)
|
|
230
|
+
self._cache.move_to_end(session_id)
|
|
231
|
+
while len(self._cache) > self._cache_size:
|
|
232
|
+
self._cache.popitem(last=False)
|
|
233
|
+
finally:
|
|
234
|
+
with self._lock:
|
|
235
|
+
self._in_flight.pop(key).set()
|
|
236
|
+
return session, ref.mtime
|
|
237
|
+
|
|
238
|
+
def resolve(self, text: str) -> str:
|
|
239
|
+
"""Turn a CLI argument into a session id: `<agent>:<id>`, an unambiguous bare id, or a session file path.
|
|
240
|
+
|
|
241
|
+
A path outside the adapter roots is added as a pinned row.
|
|
242
|
+
|
|
243
|
+
Raises:
|
|
244
|
+
LookupError: the argument names no session or several.
|
|
245
|
+
"""
|
|
246
|
+
with self._lock:
|
|
247
|
+
if text in self._rows:
|
|
248
|
+
return text
|
|
249
|
+
matches = sorted(session_id for session_id, row in self._rows.items() if row.ref.native_id == text)
|
|
250
|
+
if len(matches) == 1:
|
|
251
|
+
return matches[0]
|
|
252
|
+
if matches:
|
|
253
|
+
raise LookupError(f"ambiguous session id {text!r}: {', '.join(matches)}")
|
|
254
|
+
path = Path(text).expanduser()
|
|
255
|
+
if not path.exists():
|
|
256
|
+
raise LookupError(f"unknown session {text!r}")
|
|
257
|
+
for adapter in self._adapters.values():
|
|
258
|
+
ref = adapter.open_path(path.resolve())
|
|
259
|
+
if ref is None:
|
|
260
|
+
continue
|
|
261
|
+
with self._lock:
|
|
262
|
+
if ref.id not in self._rows:
|
|
263
|
+
self._rows[ref.id] = SessionRow(ref=ref, pinned=True)
|
|
264
|
+
self._emit([RegistryEvent(kind=EventKind.UPDATED, session_id=ref.id)])
|
|
265
|
+
return ref.id
|
|
266
|
+
raise LookupError(f"no adapter recognises {path}")
|
|
267
|
+
|
|
268
|
+
def start(self, interval_s: float = _DEFAULT_INTERVAL_S) -> None:
|
|
269
|
+
"""Run `refresh` every `interval_s` seconds and `step` whenever there is work, in a daemon thread."""
|
|
270
|
+
if self._thread is not None and self._thread.is_alive():
|
|
271
|
+
return
|
|
272
|
+
self._stop.clear()
|
|
273
|
+
self._thread = threading.Thread(target=self._run, args=(interval_s,), name="agentprof-registry", daemon=True)
|
|
274
|
+
self._thread.start()
|
|
275
|
+
|
|
276
|
+
def stop(self) -> None:
|
|
277
|
+
"""Stop the background thread and wait for its current unit of work."""
|
|
278
|
+
self._stop.set()
|
|
279
|
+
if self._thread is not None:
|
|
280
|
+
self._thread.join()
|
|
281
|
+
self._thread = None
|
|
282
|
+
|
|
283
|
+
def _run(self, interval_s: float) -> None:
|
|
284
|
+
next_refresh = time.monotonic()
|
|
285
|
+
while not self._stop.is_set():
|
|
286
|
+
try:
|
|
287
|
+
if time.monotonic() >= next_refresh:
|
|
288
|
+
next_refresh = time.monotonic() + interval_s
|
|
289
|
+
self.refresh()
|
|
290
|
+
if not self.step():
|
|
291
|
+
self._stop.wait(max(0.0, next_refresh - time.monotonic()))
|
|
292
|
+
except Exception: # noqa: BLE001 - the worker must outlive any single failure
|
|
293
|
+
_LOG.exception("registry worker iteration failed")
|
|
294
|
+
self._stop.wait(interval_s)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _set_summary(row: SessionRow, summary: SessionSummary) -> None:
|
|
298
|
+
row.summary = summary
|
|
299
|
+
row.summarized_mtime = row.ref.mtime
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _set_error(row: SessionRow, message: str) -> None:
|
|
303
|
+
row.error = message
|
|
304
|
+
row.failed_mtime = row.ref.mtime
|