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.
Files changed (54) hide show
  1. agentprof/__init__.py +7 -0
  2. agentprof/adapters/__init__.py +15 -0
  3. agentprof/adapters/base.py +79 -0
  4. agentprof/adapters/claude_code/__init__.py +3 -0
  5. agentprof/adapters/claude_code/adapter.py +133 -0
  6. agentprof/adapters/claude_code/discovery.py +79 -0
  7. agentprof/adapters/claude_code/prompts.py +44 -0
  8. agentprof/adapters/claude_code/tools.py +102 -0
  9. agentprof/adapters/claude_code/transcript.py +264 -0
  10. agentprof/adapters/claude_code/tree.py +492 -0
  11. agentprof/adapters/codex/__init__.py +3 -0
  12. agentprof/adapters/codex/adapter.py +164 -0
  13. agentprof/adapters/codex/discovery.py +117 -0
  14. agentprof/adapters/codex/prompts.py +16 -0
  15. agentprof/adapters/codex/rollout.py +328 -0
  16. agentprof/adapters/codex/tools.py +97 -0
  17. agentprof/adapters/codex/tree.py +311 -0
  18. agentprof/adapters/copilot_vscode/__init__.py +3 -0
  19. agentprof/adapters/copilot_vscode/adapter.py +166 -0
  20. agentprof/adapters/copilot_vscode/debuglog.py +136 -0
  21. agentprof/adapters/copilot_vscode/discovery.py +153 -0
  22. agentprof/adapters/copilot_vscode/session.py +281 -0
  23. agentprof/adapters/copilot_vscode/tools.py +127 -0
  24. agentprof/adapters/copilot_vscode/transcript.py +176 -0
  25. agentprof/adapters/copilot_vscode/tree.py +325 -0
  26. agentprof/adapters/execution.py +64 -0
  27. agentprof/adapters/timestamps.py +13 -0
  28. agentprof/adapters/turns.py +24 -0
  29. agentprof/analysis/__init__.py +3 -0
  30. agentprof/analysis/agent_summary.py +161 -0
  31. agentprof/analysis/call_context.py +101 -0
  32. agentprof/analysis/evidence_findings.py +151 -0
  33. agentprof/analysis/execution.py +39 -0
  34. agentprof/analysis/heuristics.py +306 -0
  35. agentprof/analysis/pipeline.py +31 -0
  36. agentprof/analysis/rollup.py +196 -0
  37. agentprof/cli.py +141 -0
  38. agentprof/model.py +341 -0
  39. agentprof/pricing.json +23 -0
  40. agentprof/pricing.py +120 -0
  41. agentprof/registry.py +304 -0
  42. agentprof/server/__init__.py +3 -0
  43. agentprof/server/app.py +399 -0
  44. agentprof/server/openapi.py +31 -0
  45. agentprof/server/pricing_schema.py +40 -0
  46. agentprof/server/schemas.py +579 -0
  47. agentprof/server/static/assets/index-C4HEqUVf.css +1 -0
  48. agentprof/server/static/assets/index-ZsPn1H3d.js +58 -0
  49. agentprof/server/static/index.html +14 -0
  50. agentprof-0.1.0.dist-info/METADATA +177 -0
  51. agentprof-0.1.0.dist-info/RECORD +54 -0
  52. agentprof-0.1.0.dist-info/WHEEL +4 -0
  53. agentprof-0.1.0.dist-info/entry_points.txt +8 -0
  54. 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
@@ -0,0 +1,3 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 epicodic
3
+ """The local web server: JSON API and live updates."""