@iseoane/agent-depot 0.2.0
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.
- package/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/README.md +465 -0
- package/package.json +69 -0
- package/skills/doctor-md-agents/README.md +51 -0
- package/skills/doctor-md-agents/SKILL.md +275 -0
- package/skills/doctor-md-agents/references/rule-model.md +56 -0
- package/skills/doctor-md-agents/references/structure.md +89 -0
- package/skills/doctor-md-agents/scorers/adherence.md +73 -0
- package/skills/doctor-md-agents/scorers/rule-value.md +49 -0
- package/skills/doctor-md-agents/scripts/collect_sessions.py +1135 -0
- package/skills/doctor-md-agents/scripts/extract_rules.py +704 -0
- package/skills/doctor-md-agents/scripts/render_report.py +644 -0
- package/skills/doctor-md-agents/scripts/scan_corrections.py +781 -0
- package/skills/doctor-md-agents/scripts/test_collect_sessions.py +780 -0
- package/skills/doctor-md-agents/scripts/test_extract_rules.py +530 -0
- package/skills/doctor-md-agents/scripts/test_render_report.py +383 -0
- package/skills/doctor-md-agents/scripts/test_scan_corrections.py +343 -0
- package/skills/doctor-md-agents/scripts/test_verify_citations.py +236 -0
- package/skills/doctor-md-agents/scripts/testdata/old_extract_rules_pre_t1.py.txt +507 -0
- package/skills/doctor-md-agents/scripts/verify_citations.py +217 -0
- package/skills/doctor-md-skill/README.md +27 -0
- package/skills/doctor-md-skill/SKILL.md +212 -0
- package/skills/doctor-md-skill/assets/pierre-diffs.js +1531 -0
- package/skills/doctor-md-skill/references/skill-improvements.md +32 -0
- package/skills/doctor-md-skill/references/supported-harnesses.md +44 -0
- package/skills/doctor-md-skill/scorers/code-quality.md +41 -0
- package/skills/doctor-md-skill/scorers/efficiency.md +38 -0
- package/skills/doctor-md-skill/scripts/collect_sessions.py +1112 -0
- package/skills/doctor-md-skill/scripts/detect_machine_sessions.py +315 -0
- package/skills/doctor-md-skill/scripts/render_report.py +545 -0
- package/skills/doctor-md-skill/scripts/test_collect_sessions.py +593 -0
- package/skills/doctor-md-skill/scripts/test_detect_machine_sessions.py +181 -0
- package/skills/doctor-md-skill/scripts/test_render_report.py +241 -0
- package/skills/doctor-md-skill/scripts/test_verify_citations.py +196 -0
- package/skills/doctor-md-skill/scripts/verify_citations.py +221 -0
|
@@ -0,0 +1,1135 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Gather local agent-session history (Claude Code, Codex, Pi, Grok Build, ZCode) for scoring.
|
|
3
|
+
|
|
4
|
+
The script looks through the session logs each supported harness leaves on
|
|
5
|
+
disk, finds the skills installed for the chosen projects, works out which
|
|
6
|
+
sessions touched which skills, picks a sample, and writes:
|
|
7
|
+
|
|
8
|
+
<out>/inventory.json - skills, per-session stats, sampling decisions
|
|
9
|
+
<out>/transcripts/<id>.md - condensed transcripts for the sampled sessions
|
|
10
|
+
|
|
11
|
+
Everything happens on the local machine; nothing is sent anywhere.
|
|
12
|
+
Python 3.9+, standard library only.
|
|
13
|
+
|
|
14
|
+
Design in one paragraph: every harness has a small *reader* class that turns
|
|
15
|
+
its native log format into one flat stream of neutral events (a user said
|
|
16
|
+
something, a tool was called, a tool returned...). A single digest routine
|
|
17
|
+
consumes any such stream and produces the statistics, the condensed
|
|
18
|
+
transcript entries and the set of skills seen, so the counting rules live in
|
|
19
|
+
exactly one place.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
from collections import Counter, deque
|
|
29
|
+
from datetime import datetime, timedelta, timezone
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Iterator, NamedTuple
|
|
32
|
+
from urllib.parse import unquote
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Tunables
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
CHAT_CLIP = 1500 # characters kept from a user/assistant message
|
|
39
|
+
TOOL_CLIP = 500 # characters kept from a tool call or tool output
|
|
40
|
+
ENTRY_CEILING = 160 # transcripts longer than this are cut down
|
|
41
|
+
KEEP_FIRST = 100 # entries retained from the start of a long transcript
|
|
42
|
+
KEEP_LAST = 40 # entries retained from the end of a long transcript
|
|
43
|
+
ERROR_SCAN_WINDOW = 2000 # leading characters of an output searched for failure words
|
|
44
|
+
|
|
45
|
+
EDIT_MARKERS = ("apply_patch", "*** Begin Patch", "edit_file", "create_file", "str_replace", "write_file")
|
|
46
|
+
CLAUDE_EDIT_TOOLS = frozenset({"Edit", "MultiEdit", "NotebookEdit", "Write"})
|
|
47
|
+
COMMON_EDIT_TOOLS = frozenset({"edit", "write", "apply_patch", "edit_file", "write_file", "str_replace", "search_replace"})
|
|
48
|
+
FAILURE_WORDS = ("error", "failed", "traceback")
|
|
49
|
+
INJECTED_TAGS = (
|
|
50
|
+
"environment_context", "user_instructions", "ENVIRONMENT", "system-reminder",
|
|
51
|
+
"permissions", "collaboration_mode", "recommended_plugins", "turn_context",
|
|
52
|
+
"user_info",
|
|
53
|
+
)
|
|
54
|
+
SKILL_MENTION_PATTERNS = (
|
|
55
|
+
re.compile(r"(?:^|/)skills/+([^/]+)/+"),
|
|
56
|
+
re.compile(r'"(?:skill|name|bundled_skill_id)"\s*:\s*"([^"]+)"'),
|
|
57
|
+
)
|
|
58
|
+
GIT_TIMEOUT_SECONDS = 10
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# Small text helpers
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def clip(text: str, limit: int) -> str:
|
|
66
|
+
"""Trim whitespace and cap the length, noting how much was dropped."""
|
|
67
|
+
text = text.strip()
|
|
68
|
+
if len(text) <= limit:
|
|
69
|
+
return text
|
|
70
|
+
return f"{text[:limit]} …[truncated {len(text) - limit} chars]"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def stringify(value) -> str:
|
|
74
|
+
"""Render a tool argument / output payload as text."""
|
|
75
|
+
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def flatten_text(content) -> str:
|
|
79
|
+
"""Join the textual parts of a message body (string or list of blocks)."""
|
|
80
|
+
if isinstance(content, str):
|
|
81
|
+
return content
|
|
82
|
+
if not isinstance(content, list):
|
|
83
|
+
return ""
|
|
84
|
+
pieces = []
|
|
85
|
+
for block in content:
|
|
86
|
+
if isinstance(block, str):
|
|
87
|
+
pieces.append(block)
|
|
88
|
+
elif isinstance(block, dict):
|
|
89
|
+
piece = block.get("text") or block.get("content") or ""
|
|
90
|
+
if isinstance(piece, str) and piece:
|
|
91
|
+
pieces.append(piece)
|
|
92
|
+
return "\n".join(pieces)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def is_harness_injection(text: str) -> bool:
|
|
96
|
+
"""True for text a harness injects on the user's behalf (system reminders etc.)."""
|
|
97
|
+
opening = text.lstrip()[:80]
|
|
98
|
+
return opening.startswith("<") and any(tag in opening for tag in INJECTED_TAGS)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def mentions_failure(text: str) -> bool:
|
|
102
|
+
window = text[:ERROR_SCAN_WINDOW].lower()
|
|
103
|
+
return any(word in window for word in FAILURE_WORDS)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def skill_mentions(text: str) -> set:
|
|
107
|
+
"""Names that look like installed skills inside one tool-call payload."""
|
|
108
|
+
text = text.replace("\\", "/")
|
|
109
|
+
found = set()
|
|
110
|
+
for pattern in SKILL_MENTION_PATTERNS:
|
|
111
|
+
found.update(pattern.findall(text))
|
|
112
|
+
return found
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def message_blocks(content) -> list:
|
|
116
|
+
"""Normalise a message body to a list of content blocks."""
|
|
117
|
+
return content if isinstance(content, list) else [{"type": "text", "text": content}]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def read_jsonl(path: Path) -> Iterator[dict]:
|
|
121
|
+
"""Stream the JSON objects of a JSONL file, one line at a time."""
|
|
122
|
+
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
|
123
|
+
for raw in handle:
|
|
124
|
+
try:
|
|
125
|
+
obj = json.loads(raw)
|
|
126
|
+
except ValueError:
|
|
127
|
+
continue
|
|
128
|
+
if isinstance(obj, dict):
|
|
129
|
+
yield obj
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def file_mtime(path: Path):
|
|
133
|
+
try:
|
|
134
|
+
return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
|
|
135
|
+
except OSError:
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def recent_files(paths, cutoff: datetime):
|
|
140
|
+
"""Pair each path with its mtime, drop stale ones, order newest first."""
|
|
141
|
+
hits = []
|
|
142
|
+
for path in paths:
|
|
143
|
+
stamp = file_mtime(path)
|
|
144
|
+
if stamp is not None and stamp >= cutoff:
|
|
145
|
+
hits.append((stamp, path))
|
|
146
|
+
hits.sort(key=lambda pair: pair[0], reverse=True)
|
|
147
|
+
return hits
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# Neutral event stream
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
STAMP = "stamp" # text = a timestamp seen in the log
|
|
155
|
+
USER_TURN = "user_turn" # one human turn happened
|
|
156
|
+
AGENT_TURN = "agent_turn" # one assistant turn happened
|
|
157
|
+
USER_SAID = "user_said" # text = transcript-worthy user message
|
|
158
|
+
AGENT_SAID = "agent_said" # text = transcript-worthy assistant message
|
|
159
|
+
TOOL_CALL = "tool_call" # name = tool, text = serialized arguments
|
|
160
|
+
TOOL_OUTPUT = "tool_output" # text = output, failed = harness flagged an error
|
|
161
|
+
SKILL_HINT = "skill_hint" # text = a skill name the harness stated explicitly
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class Event(NamedTuple):
|
|
165
|
+
kind: str
|
|
166
|
+
text: str = ""
|
|
167
|
+
name: str = ""
|
|
168
|
+
failed: bool = False
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class SkipSession(Exception):
|
|
172
|
+
"""Raised by a reader to say the session must be left out entirely."""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class EntryLog:
|
|
176
|
+
"""Ordered transcript entries; long logs keep a head and a tail only."""
|
|
177
|
+
|
|
178
|
+
def __init__(self):
|
|
179
|
+
self._front = []
|
|
180
|
+
self._back = None
|
|
181
|
+
self._seen = 0
|
|
182
|
+
|
|
183
|
+
def __len__(self):
|
|
184
|
+
return self._seen
|
|
185
|
+
|
|
186
|
+
def add(self, role: str, text: str):
|
|
187
|
+
self._seen += 1
|
|
188
|
+
if self._back is not None:
|
|
189
|
+
self._back.append((role, text))
|
|
190
|
+
return
|
|
191
|
+
self._front.append((role, text))
|
|
192
|
+
if len(self._front) > ENTRY_CEILING:
|
|
193
|
+
self._back = deque(self._front[-KEEP_LAST:], maxlen=KEEP_LAST)
|
|
194
|
+
del self._front[KEEP_FIRST:]
|
|
195
|
+
|
|
196
|
+
def entries(self):
|
|
197
|
+
if self._back is None:
|
|
198
|
+
return self._front
|
|
199
|
+
dropped = self._seen - KEEP_FIRST - KEEP_LAST
|
|
200
|
+
marker = ("note", f"[... {dropped} entries omitted ...]")
|
|
201
|
+
return [*self._front, marker, *self._back]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# Readers: one per harness, each translating a log file into Events
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
class Reader:
|
|
209
|
+
"""Base class. Subclasses describe where a harness keeps sessions and how to decode them."""
|
|
210
|
+
|
|
211
|
+
key = ""
|
|
212
|
+
restrict_skills = True # intersect detected skills with the known set
|
|
213
|
+
edit_tools = COMMON_EDIT_TOOLS # tool names that imply code edits
|
|
214
|
+
drop_when_silent = False # discard sessions that yield no transcript entries
|
|
215
|
+
|
|
216
|
+
def __init__(self, path: Path, include_subagents: bool = False):
|
|
217
|
+
self.path = path
|
|
218
|
+
self.include_subagents = include_subagents
|
|
219
|
+
|
|
220
|
+
# -- discovery ---------------------------------------------------------
|
|
221
|
+
@classmethod
|
|
222
|
+
def required_dir(cls, home: Path) -> Path:
|
|
223
|
+
raise NotImplementedError
|
|
224
|
+
|
|
225
|
+
@classmethod
|
|
226
|
+
def absence_message(cls, home: Path) -> str:
|
|
227
|
+
raise NotImplementedError
|
|
228
|
+
|
|
229
|
+
@classmethod
|
|
230
|
+
def locate(cls, home: Path, cutoff: datetime, include_subagents: bool = False):
|
|
231
|
+
raise NotImplementedError
|
|
232
|
+
|
|
233
|
+
# -- decoding ----------------------------------------------------------
|
|
234
|
+
def events(self) -> Iterator[Event]:
|
|
235
|
+
raise NotImplementedError
|
|
236
|
+
|
|
237
|
+
def build_meta(self, first_ts) -> dict:
|
|
238
|
+
raise NotImplementedError
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class ClaudeReader(Reader):
|
|
242
|
+
key = "claude"
|
|
243
|
+
restrict_skills = False
|
|
244
|
+
edit_tools = CLAUDE_EDIT_TOOLS
|
|
245
|
+
|
|
246
|
+
def __init__(self, path, include_subagents=False):
|
|
247
|
+
super().__init__(path, include_subagents)
|
|
248
|
+
self._meta = {}
|
|
249
|
+
self._sidechain = False
|
|
250
|
+
self._counted_messages = set()
|
|
251
|
+
|
|
252
|
+
@classmethod
|
|
253
|
+
def required_dir(cls, home):
|
|
254
|
+
return home / "projects"
|
|
255
|
+
|
|
256
|
+
@classmethod
|
|
257
|
+
def absence_message(cls, home):
|
|
258
|
+
return f"Claude Code project history not found at {home / 'projects'}"
|
|
259
|
+
|
|
260
|
+
@classmethod
|
|
261
|
+
def locate(cls, home, cutoff, include_subagents=False):
|
|
262
|
+
root = home / "projects"
|
|
263
|
+
if not root.is_dir():
|
|
264
|
+
return []
|
|
265
|
+
paths = list(root.glob("*/*.jsonl"))
|
|
266
|
+
if include_subagents:
|
|
267
|
+
paths += root.glob("*/*/subagents/*.jsonl")
|
|
268
|
+
return recent_files(paths, cutoff)
|
|
269
|
+
|
|
270
|
+
def _track_envelope(self, obj, ts):
|
|
271
|
+
agent = obj.get("agentId")
|
|
272
|
+
if not self._meta:
|
|
273
|
+
if not obj.get("sessionId"):
|
|
274
|
+
return
|
|
275
|
+
sid = obj["sessionId"]
|
|
276
|
+
self._meta = {
|
|
277
|
+
"id": f"{sid}-{agent}" if agent else sid,
|
|
278
|
+
"cwd": obj.get("cwd"),
|
|
279
|
+
"started_at": ts,
|
|
280
|
+
"originator": "claude-code",
|
|
281
|
+
"thread_source": "subagent" if obj.get("isSidechain") else None,
|
|
282
|
+
"cli_version": obj.get("version"),
|
|
283
|
+
"entrypoint": obj.get("entrypoint"),
|
|
284
|
+
}
|
|
285
|
+
return
|
|
286
|
+
meta = self._meta
|
|
287
|
+
for field, value in (("cwd", obj.get("cwd")), ("started_at", ts),
|
|
288
|
+
("cli_version", obj.get("version")),
|
|
289
|
+
("entrypoint", obj.get("entrypoint"))):
|
|
290
|
+
meta[field] = meta.get(field) or value
|
|
291
|
+
if agent and not meta["id"].endswith(f"-{agent}"):
|
|
292
|
+
meta["id"] = f"{obj.get('sessionId') or meta['id']}-{agent}"
|
|
293
|
+
|
|
294
|
+
def events(self):
|
|
295
|
+
for obj in read_jsonl(self.path):
|
|
296
|
+
ts = obj.get("timestamp")
|
|
297
|
+
if ts:
|
|
298
|
+
yield Event(STAMP, ts)
|
|
299
|
+
if obj.get("isSidechain"):
|
|
300
|
+
self._sidechain = True
|
|
301
|
+
if not self.include_subagents:
|
|
302
|
+
raise SkipSession
|
|
303
|
+
self._track_envelope(obj, ts)
|
|
304
|
+
|
|
305
|
+
message = obj.get("message")
|
|
306
|
+
if obj.get("type") not in ("user", "assistant") or not isinstance(message, dict):
|
|
307
|
+
continue
|
|
308
|
+
yield from self._decode_message(obj, message)
|
|
309
|
+
|
|
310
|
+
def _decode_message(self, obj, message):
|
|
311
|
+
role = message.get("role") or obj.get("type")
|
|
312
|
+
if role == "assistant":
|
|
313
|
+
msg_id = message.get("id") or obj.get("uuid")
|
|
314
|
+
if msg_id and msg_id not in self._counted_messages:
|
|
315
|
+
self._counted_messages.add(msg_id)
|
|
316
|
+
yield Event(AGENT_TURN)
|
|
317
|
+
human_spoke = False
|
|
318
|
+
for block in message_blocks(message.get("content")):
|
|
319
|
+
if not isinstance(block, dict):
|
|
320
|
+
continue
|
|
321
|
+
btype = block.get("type")
|
|
322
|
+
if btype == "text":
|
|
323
|
+
text = block.get("text")
|
|
324
|
+
if not isinstance(text, str) or not text or is_harness_injection(text):
|
|
325
|
+
continue
|
|
326
|
+
if role == "user":
|
|
327
|
+
human_spoke = True
|
|
328
|
+
yield Event(USER_SAID, text)
|
|
329
|
+
elif role == "assistant":
|
|
330
|
+
yield Event(AGENT_SAID, text)
|
|
331
|
+
elif btype == "tool_use":
|
|
332
|
+
name = str(block.get("name") or "unknown")
|
|
333
|
+
args = block.get("input") or {}
|
|
334
|
+
yield Event(TOOL_CALL, stringify(args), name)
|
|
335
|
+
if name == "Skill" and isinstance(args, dict) and args.get("skill"):
|
|
336
|
+
yield Event(SKILL_HINT, args["skill"])
|
|
337
|
+
elif btype == "tool_result":
|
|
338
|
+
yield Event(TOOL_OUTPUT, flatten_text(block.get("content")),
|
|
339
|
+
failed=bool(block.get("is_error")))
|
|
340
|
+
if role == "user" and human_spoke:
|
|
341
|
+
yield Event(USER_TURN)
|
|
342
|
+
|
|
343
|
+
def build_meta(self, first_ts):
|
|
344
|
+
if not self._meta:
|
|
345
|
+
return {
|
|
346
|
+
"id": self.path.stem,
|
|
347
|
+
"cwd": None,
|
|
348
|
+
"started_at": first_ts,
|
|
349
|
+
"originator": "claude-code",
|
|
350
|
+
"thread_source": "subagent" if self._sidechain else None,
|
|
351
|
+
}
|
|
352
|
+
if self._sidechain:
|
|
353
|
+
self._meta["thread_source"] = "subagent"
|
|
354
|
+
return self._meta
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class CodexReader(Reader):
|
|
358
|
+
key = "codex"
|
|
359
|
+
restrict_skills = False
|
|
360
|
+
edit_tools = frozenset()
|
|
361
|
+
_CALL_TYPES = ("function_call", "custom_tool_call", "local_shell_call")
|
|
362
|
+
_OUTPUT_TYPES = ("function_call_output", "custom_tool_call_output")
|
|
363
|
+
|
|
364
|
+
def __init__(self, path, include_subagents=False):
|
|
365
|
+
super().__init__(path, include_subagents)
|
|
366
|
+
self._meta = {}
|
|
367
|
+
|
|
368
|
+
@classmethod
|
|
369
|
+
def required_dir(cls, home):
|
|
370
|
+
return home
|
|
371
|
+
|
|
372
|
+
@classmethod
|
|
373
|
+
def absence_message(cls, home):
|
|
374
|
+
return f"Codex home not found at {home}"
|
|
375
|
+
|
|
376
|
+
@classmethod
|
|
377
|
+
def locate(cls, home, cutoff, include_subagents=False):
|
|
378
|
+
paths = []
|
|
379
|
+
for sub in ("sessions", "archived_sessions"):
|
|
380
|
+
folder = home / sub
|
|
381
|
+
if folder.is_dir():
|
|
382
|
+
paths.extend(folder.rglob("rollout-*.jsonl"))
|
|
383
|
+
return recent_files(paths, cutoff)
|
|
384
|
+
|
|
385
|
+
def _read_header(self, payload):
|
|
386
|
+
self._meta = {
|
|
387
|
+
"id": payload.get("id") or payload.get("session_id") or self.path.stem,
|
|
388
|
+
"cwd": payload.get("cwd"),
|
|
389
|
+
"started_at": payload.get("timestamp"),
|
|
390
|
+
"originator": payload.get("originator"),
|
|
391
|
+
"thread_source": payload.get("thread_source"),
|
|
392
|
+
"cli_version": payload.get("cli_version"),
|
|
393
|
+
}
|
|
394
|
+
origin = payload.get("source")
|
|
395
|
+
delegated = payload.get("thread_source") == "subagent" or (
|
|
396
|
+
isinstance(origin, dict) and "subagent" in origin
|
|
397
|
+
)
|
|
398
|
+
if delegated and not self.include_subagents:
|
|
399
|
+
raise SkipSession
|
|
400
|
+
|
|
401
|
+
def events(self):
|
|
402
|
+
for obj in read_jsonl(self.path):
|
|
403
|
+
payload = obj.get("payload") or {}
|
|
404
|
+
if not isinstance(payload, dict):
|
|
405
|
+
continue
|
|
406
|
+
if obj.get("timestamp"):
|
|
407
|
+
yield Event(STAMP, obj["timestamp"])
|
|
408
|
+
record = obj.get("type")
|
|
409
|
+
inner = payload.get("type")
|
|
410
|
+
if record == "session_meta":
|
|
411
|
+
self._read_header(payload)
|
|
412
|
+
elif record == "event_msg":
|
|
413
|
+
if inner == "user_message":
|
|
414
|
+
yield Event(USER_TURN)
|
|
415
|
+
elif inner == "agent_message":
|
|
416
|
+
yield Event(AGENT_TURN)
|
|
417
|
+
elif record == "response_item":
|
|
418
|
+
yield from self._decode_item(payload, inner)
|
|
419
|
+
|
|
420
|
+
def _decode_item(self, payload, inner):
|
|
421
|
+
if inner == "message":
|
|
422
|
+
text = flatten_text(payload.get("content"))
|
|
423
|
+
role = payload.get("role")
|
|
424
|
+
if not text:
|
|
425
|
+
return
|
|
426
|
+
if role == "user" and not is_harness_injection(text):
|
|
427
|
+
yield Event(USER_SAID, text)
|
|
428
|
+
elif role == "assistant":
|
|
429
|
+
yield Event(AGENT_SAID, text)
|
|
430
|
+
elif inner in self._CALL_TYPES:
|
|
431
|
+
args = payload.get("arguments") or payload.get("input") or ""
|
|
432
|
+
yield Event(TOOL_CALL, stringify(args), payload.get("name") or inner)
|
|
433
|
+
elif inner in self._OUTPUT_TYPES:
|
|
434
|
+
yield Event(TOOL_OUTPUT, stringify(payload.get("output") or ""))
|
|
435
|
+
|
|
436
|
+
def build_meta(self, first_ts):
|
|
437
|
+
return self._meta or {"id": self.path.stem, "cwd": None, "started_at": first_ts}
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class PiReader(Reader):
|
|
441
|
+
key = "pi"
|
|
442
|
+
|
|
443
|
+
def __init__(self, path, include_subagents=False):
|
|
444
|
+
super().__init__(path, include_subagents)
|
|
445
|
+
self._meta = {}
|
|
446
|
+
|
|
447
|
+
@classmethod
|
|
448
|
+
def required_dir(cls, home):
|
|
449
|
+
return home / "sessions"
|
|
450
|
+
|
|
451
|
+
@classmethod
|
|
452
|
+
def absence_message(cls, home):
|
|
453
|
+
return f"Pi session home not found at {home / 'sessions'}"
|
|
454
|
+
|
|
455
|
+
@classmethod
|
|
456
|
+
def locate(cls, home, cutoff, include_subagents=False):
|
|
457
|
+
root = home / "sessions"
|
|
458
|
+
return recent_files(root.glob("*/*.jsonl"), cutoff) if root.is_dir() else []
|
|
459
|
+
|
|
460
|
+
def _read_header(self, obj):
|
|
461
|
+
ts = obj.get("timestamp")
|
|
462
|
+
if not self._meta:
|
|
463
|
+
self._meta = {
|
|
464
|
+
"id": obj.get("id") or self.path.stem,
|
|
465
|
+
"cwd": obj.get("cwd"),
|
|
466
|
+
"started_at": ts,
|
|
467
|
+
"originator": "pi",
|
|
468
|
+
"thread_source": None,
|
|
469
|
+
}
|
|
470
|
+
else:
|
|
471
|
+
self._meta["cwd"] = self._meta.get("cwd") or obj.get("cwd")
|
|
472
|
+
self._meta["started_at"] = self._meta.get("started_at") or ts
|
|
473
|
+
|
|
474
|
+
def events(self):
|
|
475
|
+
for obj in read_jsonl(self.path):
|
|
476
|
+
if obj.get("timestamp"):
|
|
477
|
+
yield Event(STAMP, obj["timestamp"])
|
|
478
|
+
kind = obj.get("type")
|
|
479
|
+
if kind == "session":
|
|
480
|
+
self._read_header(obj)
|
|
481
|
+
elif kind == "message" and isinstance(obj.get("message"), dict):
|
|
482
|
+
yield from self._decode_message(obj["message"])
|
|
483
|
+
|
|
484
|
+
def _decode_message(self, message):
|
|
485
|
+
role = message.get("role")
|
|
486
|
+
blocks = message_blocks(message.get("content"))
|
|
487
|
+
if role == "toolResult":
|
|
488
|
+
message_failed = bool(message.get("isError"))
|
|
489
|
+
for block in blocks:
|
|
490
|
+
if not isinstance(block, dict):
|
|
491
|
+
continue
|
|
492
|
+
text = block.get("text") or ""
|
|
493
|
+
if isinstance(text, str) and text:
|
|
494
|
+
yield Event(TOOL_OUTPUT, text, failed=message_failed or bool(block.get("isError")))
|
|
495
|
+
return
|
|
496
|
+
if role == "assistant":
|
|
497
|
+
yield Event(AGENT_TURN)
|
|
498
|
+
human_spoke = False
|
|
499
|
+
for block in blocks:
|
|
500
|
+
if not isinstance(block, dict):
|
|
501
|
+
continue
|
|
502
|
+
btype = block.get("type")
|
|
503
|
+
if btype == "text":
|
|
504
|
+
text = block.get("text")
|
|
505
|
+
if not isinstance(text, str) or not text or is_harness_injection(text):
|
|
506
|
+
continue
|
|
507
|
+
if role == "user":
|
|
508
|
+
human_spoke = True
|
|
509
|
+
yield Event(USER_SAID, text)
|
|
510
|
+
elif role == "assistant":
|
|
511
|
+
yield Event(AGENT_SAID, text)
|
|
512
|
+
elif btype == "toolCall":
|
|
513
|
+
yield Event(TOOL_CALL, stringify(block.get("arguments") or {}),
|
|
514
|
+
str(block.get("name") or "unknown"))
|
|
515
|
+
if role == "user" and human_spoke:
|
|
516
|
+
yield Event(USER_TURN)
|
|
517
|
+
|
|
518
|
+
def build_meta(self, first_ts):
|
|
519
|
+
return self._meta or {
|
|
520
|
+
"id": self.path.stem,
|
|
521
|
+
"cwd": None,
|
|
522
|
+
"started_at": first_ts,
|
|
523
|
+
"originator": "pi",
|
|
524
|
+
"thread_source": None,
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def openai_style_calls(message) -> list:
|
|
529
|
+
"""(tool name, argument text) pairs from an OpenAI-style `tool_calls` list."""
|
|
530
|
+
pairs = []
|
|
531
|
+
for call in message.get("tool_calls") or []:
|
|
532
|
+
if not isinstance(call, dict):
|
|
533
|
+
continue
|
|
534
|
+
function = call.get("function") or {}
|
|
535
|
+
name = call.get("name") or function.get("name") or "unknown"
|
|
536
|
+
args = call.get("arguments")
|
|
537
|
+
if args is None:
|
|
538
|
+
args = function.get("arguments")
|
|
539
|
+
pairs.append((str(name), stringify(args)))
|
|
540
|
+
return pairs
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def chat_events(role: str, message: dict) -> Iterator[Event]:
|
|
544
|
+
"""Events for one chat-completions style message; role is user/assistant/result."""
|
|
545
|
+
text = flatten_text(message.get("content"))
|
|
546
|
+
if role == "user":
|
|
547
|
+
if text and not is_harness_injection(text):
|
|
548
|
+
yield Event(USER_TURN)
|
|
549
|
+
yield Event(USER_SAID, text)
|
|
550
|
+
elif role == "assistant":
|
|
551
|
+
if text:
|
|
552
|
+
yield Event(AGENT_TURN)
|
|
553
|
+
yield Event(AGENT_SAID, text)
|
|
554
|
+
for name, args in openai_style_calls(message):
|
|
555
|
+
yield Event(TOOL_CALL, args, name)
|
|
556
|
+
elif role == "result" and text:
|
|
557
|
+
yield Event(TOOL_OUTPUT, text)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
class GrokReader(Reader):
|
|
561
|
+
key = "grok"
|
|
562
|
+
_IGNORED_TYPES = ("system", "reasoning", "backend_tool_call")
|
|
563
|
+
_ROLE_OF_TYPE = {"user": "user", "assistant": "assistant", "tool_result": "result"}
|
|
564
|
+
|
|
565
|
+
@classmethod
|
|
566
|
+
def required_dir(cls, home):
|
|
567
|
+
return home / "sessions"
|
|
568
|
+
|
|
569
|
+
@classmethod
|
|
570
|
+
def absence_message(cls, home):
|
|
571
|
+
return f"Grok Build session home not found at {home / 'sessions'}"
|
|
572
|
+
|
|
573
|
+
@classmethod
|
|
574
|
+
def locate(cls, home, cutoff, include_subagents=False):
|
|
575
|
+
root = home / "sessions"
|
|
576
|
+
return recent_files(root.glob("*/*/chat_history.jsonl"), cutoff) if root.is_dir() else []
|
|
577
|
+
|
|
578
|
+
def events(self):
|
|
579
|
+
for obj in read_jsonl(self.path):
|
|
580
|
+
if obj.get("type") in self._IGNORED_TYPES or obj.get("synthetic_reason"):
|
|
581
|
+
continue
|
|
582
|
+
role = self._ROLE_OF_TYPE.get(obj.get("type"))
|
|
583
|
+
if role:
|
|
584
|
+
yield from chat_events(role, obj)
|
|
585
|
+
|
|
586
|
+
def build_meta(self, first_ts):
|
|
587
|
+
return {
|
|
588
|
+
"id": self.path.parent.name,
|
|
589
|
+
"cwd": unquote(self.path.parent.parent.name),
|
|
590
|
+
"started_at": None,
|
|
591
|
+
"originator": "grok",
|
|
592
|
+
"thread_source": None,
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
class ZcodeReader(Reader):
|
|
597
|
+
key = "zcode"
|
|
598
|
+
drop_when_silent = True
|
|
599
|
+
_ROLE_OF_ROLE = {"user": "user", "assistant": "assistant", "tool": "result"}
|
|
600
|
+
|
|
601
|
+
@classmethod
|
|
602
|
+
def required_dir(cls, home):
|
|
603
|
+
return home / "cli" / "rollout"
|
|
604
|
+
|
|
605
|
+
@classmethod
|
|
606
|
+
def absence_message(cls, home):
|
|
607
|
+
return f"ZCode model-io rollouts not found at {home / 'cli' / 'rollout'}"
|
|
608
|
+
|
|
609
|
+
@classmethod
|
|
610
|
+
def locate(cls, home, cutoff, include_subagents=False):
|
|
611
|
+
root = home / "cli" / "rollout"
|
|
612
|
+
return recent_files(root.glob("model-io-*.jsonl"), cutoff) if root.is_dir() else []
|
|
613
|
+
|
|
614
|
+
def events(self):
|
|
615
|
+
# Each line is a full request dump; only the final non-empty message
|
|
616
|
+
# list describes the session as it ended, so keep just that one.
|
|
617
|
+
latest = None
|
|
618
|
+
for obj in read_jsonl(self.path):
|
|
619
|
+
body = (obj.get("request") or {}).get("body") or {}
|
|
620
|
+
batch = body.get("messages")
|
|
621
|
+
if isinstance(batch, list) and batch:
|
|
622
|
+
latest = batch
|
|
623
|
+
if not latest:
|
|
624
|
+
raise SkipSession
|
|
625
|
+
for message in latest:
|
|
626
|
+
if isinstance(message, dict):
|
|
627
|
+
role = self._ROLE_OF_ROLE.get(message.get("role"))
|
|
628
|
+
if role:
|
|
629
|
+
yield from chat_events(role, message)
|
|
630
|
+
|
|
631
|
+
def build_meta(self, first_ts):
|
|
632
|
+
stem = self.path.stem
|
|
633
|
+
prefix = "model-io-"
|
|
634
|
+
return {
|
|
635
|
+
"id": stem[len(prefix):] if stem.startswith(prefix) else stem,
|
|
636
|
+
"cwd": None,
|
|
637
|
+
"started_at": None,
|
|
638
|
+
"originator": "zcode",
|
|
639
|
+
"thread_source": None,
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
READERS = {cls.key: cls for cls in (ClaudeReader, CodexReader, PiReader, GrokReader, ZcodeReader)}
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
# ---------------------------------------------------------------------------
|
|
647
|
+
# Digest: the single place where events become statistics and transcript
|
|
648
|
+
# ---------------------------------------------------------------------------
|
|
649
|
+
|
|
650
|
+
def digest_session(reader: Reader, known_skills):
|
|
651
|
+
"""Consume a reader's events -> (meta, stats, entries, skills) or None to skip."""
|
|
652
|
+
known = set(known_skills)
|
|
653
|
+
tally = Counter()
|
|
654
|
+
calls_seen = set()
|
|
655
|
+
tools_used = set()
|
|
656
|
+
skills = set()
|
|
657
|
+
log = EntryLog()
|
|
658
|
+
first_ts = last_ts = None
|
|
659
|
+
edit_marker_hit = False
|
|
660
|
+
|
|
661
|
+
try:
|
|
662
|
+
for ev in reader.events():
|
|
663
|
+
kind = ev.kind
|
|
664
|
+
if kind == STAMP:
|
|
665
|
+
first_ts = first_ts or ev.text
|
|
666
|
+
last_ts = ev.text
|
|
667
|
+
elif kind == USER_TURN:
|
|
668
|
+
tally["user_turns"] += 1
|
|
669
|
+
elif kind == AGENT_TURN:
|
|
670
|
+
tally["assistant_turns"] += 1
|
|
671
|
+
elif kind == USER_SAID:
|
|
672
|
+
log.add("user", clip(ev.text, CHAT_CLIP))
|
|
673
|
+
elif kind == AGENT_SAID:
|
|
674
|
+
log.add("assistant", clip(ev.text, CHAT_CLIP))
|
|
675
|
+
elif kind == TOOL_CALL:
|
|
676
|
+
tally["tool_calls"] += 1
|
|
677
|
+
signature = (ev.name, ev.text)
|
|
678
|
+
if signature in calls_seen:
|
|
679
|
+
tally["repeated_tool_calls"] += 1
|
|
680
|
+
calls_seen.add(signature)
|
|
681
|
+
tools_used.add(ev.name)
|
|
682
|
+
skills |= skill_mentions(ev.text)
|
|
683
|
+
edit_marker_hit = edit_marker_hit or any(m in ev.text for m in EDIT_MARKERS)
|
|
684
|
+
log.add(f"tool:{ev.name}", clip(ev.text, TOOL_CLIP))
|
|
685
|
+
elif kind == TOOL_OUTPUT:
|
|
686
|
+
if ev.failed or mentions_failure(ev.text):
|
|
687
|
+
tally["error_outputs"] += 1
|
|
688
|
+
log.add("output", clip(ev.text, TOOL_CLIP))
|
|
689
|
+
elif kind == SKILL_HINT:
|
|
690
|
+
skills.add(ev.text)
|
|
691
|
+
except (SkipSession, OSError):
|
|
692
|
+
return None
|
|
693
|
+
|
|
694
|
+
if reader.drop_when_silent and not len(log):
|
|
695
|
+
return None
|
|
696
|
+
|
|
697
|
+
stats = {
|
|
698
|
+
name: tally[name]
|
|
699
|
+
for name in ("user_turns", "assistant_turns", "tool_calls",
|
|
700
|
+
"repeated_tool_calls", "error_outputs")
|
|
701
|
+
}
|
|
702
|
+
stats["first_ts"] = first_ts
|
|
703
|
+
stats["last_ts"] = last_ts
|
|
704
|
+
stats["has_code_edits"] = bool(tools_used & reader.edit_tools) or edit_marker_hit
|
|
705
|
+
if reader.restrict_skills:
|
|
706
|
+
skills &= known
|
|
707
|
+
return reader.build_meta(first_ts), stats, log.entries(), sorted(skills)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
# Stable module-level entry points (other scripts import these).
|
|
711
|
+
|
|
712
|
+
def find_claude_session_files(claude_home: Path, cutoff: datetime, include_subagents: bool):
|
|
713
|
+
return ClaudeReader.locate(claude_home, cutoff, include_subagents)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def parse_claude_session(path: Path, skill_names, include_subagents: bool):
|
|
717
|
+
return digest_session(ClaudeReader(path, include_subagents), skill_names)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def find_codex_session_files(codex_home: Path, cutoff: datetime):
|
|
721
|
+
return CodexReader.locate(codex_home, cutoff)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def parse_codex_session(path: Path, skill_names, include_subagents: bool):
|
|
725
|
+
return digest_session(CodexReader(path, include_subagents), skill_names)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def find_pi_session_files(pi_home: Path, cutoff: datetime):
|
|
729
|
+
return PiReader.locate(pi_home, cutoff)
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def parse_pi_session(path: Path, skill_names, include_subagents: bool):
|
|
733
|
+
return digest_session(PiReader(path, include_subagents), skill_names)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def find_grok_session_files(grok_home: Path, cutoff: datetime):
|
|
737
|
+
return GrokReader.locate(grok_home, cutoff)
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def parse_grok_session(path: Path, skill_names, include_subagents: bool):
|
|
741
|
+
return digest_session(GrokReader(path, include_subagents), skill_names)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def find_zcode_session_files(zcode_home: Path, cutoff: datetime):
|
|
745
|
+
return ZcodeReader.locate(zcode_home, cutoff)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def parse_zcode_session(path: Path, skill_names, include_subagents: bool):
|
|
749
|
+
return digest_session(ZcodeReader(path, include_subagents), skill_names)
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
# ---------------------------------------------------------------------------
|
|
753
|
+
# Repositories and skills
|
|
754
|
+
# ---------------------------------------------------------------------------
|
|
755
|
+
|
|
756
|
+
def git_toplevel(directory=None):
|
|
757
|
+
"""Repository root containing `directory` (or the cwd), or None."""
|
|
758
|
+
command = ["git"] + (["-C", str(directory)] if directory else []) + ["rev-parse", "--show-toplevel"]
|
|
759
|
+
try:
|
|
760
|
+
done = subprocess.run(command, capture_output=True, text=True, timeout=GIT_TIMEOUT_SECONDS)
|
|
761
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
762
|
+
return None
|
|
763
|
+
top = done.stdout.strip()
|
|
764
|
+
return Path(top).resolve() if done.returncode == 0 and top else None
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def resolve_project_roots(explicit):
|
|
768
|
+
"""Turn --repo values into distinct absolute paths (default: git root of cwd)."""
|
|
769
|
+
if not explicit:
|
|
770
|
+
return [git_toplevel() or Path.cwd().resolve()]
|
|
771
|
+
roots = []
|
|
772
|
+
for value in explicit:
|
|
773
|
+
root = Path(value).expanduser().resolve()
|
|
774
|
+
if root not in roots:
|
|
775
|
+
roots.append(root)
|
|
776
|
+
return roots
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def cwd_belongs_to(cwd, repo: Path) -> bool:
|
|
780
|
+
"""Does a session's recorded working directory belong to `repo`?
|
|
781
|
+
|
|
782
|
+
Accepts a cwd nested under the repo root, and also any cwd whose last
|
|
783
|
+
component (or any component) equals the repo's directory name; that
|
|
784
|
+
catches worktrees stored elsewhere and sessions copied from another
|
|
785
|
+
machine. Name matching can be generous when unrelated projects share a
|
|
786
|
+
folder name, which is acceptable for a report.
|
|
787
|
+
"""
|
|
788
|
+
if not cwd:
|
|
789
|
+
return False
|
|
790
|
+
location = Path(cwd)
|
|
791
|
+
try:
|
|
792
|
+
location.resolve().relative_to(repo)
|
|
793
|
+
return True
|
|
794
|
+
except (ValueError, OSError):
|
|
795
|
+
pass
|
|
796
|
+
return location.name == repo.name or repo.name in location.parts
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def session_matches_repos(cwd, repos) -> bool:
|
|
800
|
+
return any(cwd_belongs_to(cwd, repo) for repo in repos)
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def repos_seen_in(sessions):
|
|
804
|
+
"""Git roots of every existing working directory the sessions recorded."""
|
|
805
|
+
roots = []
|
|
806
|
+
for session in sessions:
|
|
807
|
+
cwd = session["meta"].get("cwd")
|
|
808
|
+
if not cwd:
|
|
809
|
+
continue
|
|
810
|
+
folder = Path(cwd).expanduser()
|
|
811
|
+
if not folder.is_dir():
|
|
812
|
+
continue
|
|
813
|
+
top = git_toplevel(folder)
|
|
814
|
+
if top is not None and top not in roots:
|
|
815
|
+
roots.append(top)
|
|
816
|
+
return roots
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def skill_search_roots(repos, codex_home, extra_dirs, include_global, pi_home, grok_home, zcode_home):
|
|
820
|
+
roots = []
|
|
821
|
+
for repo in repos:
|
|
822
|
+
roots += [repo / ".agents" / "skills", repo / ".claude" / "skills", repo / ".codex" / "skills"]
|
|
823
|
+
if include_global:
|
|
824
|
+
roots += [codex_home / "skills", Path.home() / ".agents" / "skills", Path.home() / ".claude" / "skills"]
|
|
825
|
+
roots += [Path(home) / "skills" for home in (pi_home, grok_home, zcode_home) if home is not None]
|
|
826
|
+
roots += [Path(extra).expanduser() for extra in extra_dirs]
|
|
827
|
+
return roots
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def describe_skill(manifest: Path):
|
|
831
|
+
try:
|
|
832
|
+
body = manifest.read_text(errors="replace")
|
|
833
|
+
info = manifest.stat()
|
|
834
|
+
except OSError:
|
|
835
|
+
return None
|
|
836
|
+
found = re.search(r"^description:\s*(.+)$", body, re.MULTILINE)
|
|
837
|
+
summary = found.group(1).strip().strip("\"'")[:300] if found else ""
|
|
838
|
+
return {
|
|
839
|
+
"name": manifest.parent.name,
|
|
840
|
+
"path": str(manifest),
|
|
841
|
+
"description": summary,
|
|
842
|
+
"bytes": info.st_size,
|
|
843
|
+
"modified_at": datetime.fromtimestamp(info.st_mtime, tz=timezone.utc).isoformat(),
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def discover_skills(repos, codex_home: Path, extra_dirs, include_global: bool,
|
|
848
|
+
pi_home: Path = None, grok_home: Path = None, zcode_home: Path = None):
|
|
849
|
+
"""Map skill name -> descriptor; the first root that provides a name wins."""
|
|
850
|
+
if isinstance(repos, Path):
|
|
851
|
+
repos = [repos]
|
|
852
|
+
catalogue = {}
|
|
853
|
+
for root in skill_search_roots(repos, codex_home, extra_dirs, include_global,
|
|
854
|
+
pi_home, grok_home, zcode_home):
|
|
855
|
+
if not root.is_dir():
|
|
856
|
+
continue
|
|
857
|
+
for manifest in sorted(root.glob("*/SKILL.md")):
|
|
858
|
+
if manifest.parent.name in catalogue:
|
|
859
|
+
continue
|
|
860
|
+
entry = describe_skill(manifest)
|
|
861
|
+
if entry is not None:
|
|
862
|
+
catalogue[entry["name"]] = entry
|
|
863
|
+
return catalogue
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def detect_skills_from_entries(entries, skill_names):
|
|
867
|
+
"""Skills referenced by tool-call entries (chat text is deliberately ignored)."""
|
|
868
|
+
haystack = "\n".join(
|
|
869
|
+
text for role, text in entries if role == "skill" or role.startswith("tool:")
|
|
870
|
+
).replace("\\", "/")
|
|
871
|
+
hits = set()
|
|
872
|
+
for name in skill_names:
|
|
873
|
+
signs = (
|
|
874
|
+
f"skills/{name}/",
|
|
875
|
+
f"{name}/SKILL.md",
|
|
876
|
+
f'"skill": "{name}"',
|
|
877
|
+
f'"name": "{name}"',
|
|
878
|
+
f'"bundled_skill_id": "{name}"',
|
|
879
|
+
)
|
|
880
|
+
if any(sign in haystack for sign in signs):
|
|
881
|
+
hits.add(name)
|
|
882
|
+
return hits
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
# ---------------------------------------------------------------------------
|
|
886
|
+
# Sampling
|
|
887
|
+
# ---------------------------------------------------------------------------
|
|
888
|
+
|
|
889
|
+
def sample_newest(sessions, limit, **_ignored):
|
|
890
|
+
"""Keys of the newest `limit` distinct sessions, skills disregarded.
|
|
891
|
+
|
|
892
|
+
`sessions` is ordered newest first. One logical session can appear
|
|
893
|
+
several times (a resumed session spread across files), so distinctness
|
|
894
|
+
is judged by the size of the key set rather than by position.
|
|
895
|
+
"""
|
|
896
|
+
chosen = set()
|
|
897
|
+
for item in sessions:
|
|
898
|
+
if len(chosen) >= limit:
|
|
899
|
+
break
|
|
900
|
+
chosen.add(item["_key"])
|
|
901
|
+
return chosen
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def sample_by_skill(sessions, limit, per_skill, no_skill, skill_names):
|
|
905
|
+
"""Fill the sample per skill first, then top up with skill-less sessions."""
|
|
906
|
+
chosen = set()
|
|
907
|
+
quota_used = {name: 0 for name in skill_names}
|
|
908
|
+
for item in sessions:
|
|
909
|
+
if len(chosen) >= limit:
|
|
910
|
+
break
|
|
911
|
+
for name in item["skills_used"]:
|
|
912
|
+
if quota_used.get(name, 0) < per_skill:
|
|
913
|
+
quota_used[name] = quota_used.get(name, 0) + 1
|
|
914
|
+
chosen.add(item["_key"])
|
|
915
|
+
break
|
|
916
|
+
plain_taken = 0
|
|
917
|
+
for item in sessions:
|
|
918
|
+
if len(chosen) >= limit or plain_taken >= no_skill:
|
|
919
|
+
break
|
|
920
|
+
if not item["skills_used"] and item["_key"] not in chosen:
|
|
921
|
+
chosen.add(item["_key"])
|
|
922
|
+
plain_taken += 1
|
|
923
|
+
return chosen
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
# ---------------------------------------------------------------------------
|
|
927
|
+
# Output
|
|
928
|
+
# ---------------------------------------------------------------------------
|
|
929
|
+
|
|
930
|
+
def render_transcript(meta, stats, skills_used, entries) -> str:
|
|
931
|
+
header = [
|
|
932
|
+
f"# Session {meta.get('id')}",
|
|
933
|
+
f"- cwd: {meta.get('cwd')}",
|
|
934
|
+
f"- started: {meta.get('started_at') or stats.get('first_ts')}",
|
|
935
|
+
f"- skills detected: {', '.join(skills_used) or '(none)'}",
|
|
936
|
+
f"- stats: {stats['user_turns']} user turns, {stats['assistant_turns']} assistant turns, "
|
|
937
|
+
f"{stats['tool_calls']} tool calls ({stats['repeated_tool_calls']} repeated), "
|
|
938
|
+
f"{stats['error_outputs']} error-ish outputs, code edits: {stats['has_code_edits']}",
|
|
939
|
+
"",
|
|
940
|
+
"## Condensed transcript",
|
|
941
|
+
"",
|
|
942
|
+
]
|
|
943
|
+
body = []
|
|
944
|
+
for role, text in entries:
|
|
945
|
+
body += [f"[{role}] {text}", ""]
|
|
946
|
+
return "\n".join(header + body)
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def parse_args():
|
|
950
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
951
|
+
ap.add_argument("--harness", choices=("auto", "all", *READERS), default="auto",
|
|
952
|
+
help="session source (default: auto; scans every locally available source)")
|
|
953
|
+
ap.add_argument("--claude-home", default=os.environ.get("CLAUDE_CONFIG_DIR", "~/.claude"),
|
|
954
|
+
help="Claude Code config directory (default: CLAUDE_CONFIG_DIR or ~/.claude)")
|
|
955
|
+
ap.add_argument("--codex-home", default=os.environ.get("CODEX_HOME", "~/.codex"))
|
|
956
|
+
ap.add_argument("--pi-home", default="~/.pi/agent", help="Pi agent home (default: ~/.pi/agent)")
|
|
957
|
+
ap.add_argument("--grok-home", default="~/.grok", help="Grok Build home (default: ~/.grok)")
|
|
958
|
+
ap.add_argument("--zcode-home", default="~/.zcode", help="ZCode home (default: ~/.zcode)")
|
|
959
|
+
ap.add_argument("--repo", action="append", default=[],
|
|
960
|
+
help="project to include (repeatable; default: git root of cwd, else cwd)")
|
|
961
|
+
ap.add_argument("--all-conversations", action="store_true",
|
|
962
|
+
help="score conversations from every project represented in local history")
|
|
963
|
+
ap.add_argument("--include-global-skills", action="store_true",
|
|
964
|
+
help="also discover skills outside the repo (~/.codex/skills, ~/.agents/skills, "
|
|
965
|
+
"~/.claude/skills, ~/.pi/agent/skills, ~/.grok/skills, ~/.zcode/skills)")
|
|
966
|
+
ap.add_argument("--days", type=int, default=45, help="only consider sessions modified in the last N days")
|
|
967
|
+
# This tool grades instruction files rather than skills, so by default it
|
|
968
|
+
# wants a representative slice of recent work, not a skill-balanced one.
|
|
969
|
+
ap.add_argument("--sample", choices=("newest", "skill-stratified"), default="newest",
|
|
970
|
+
help="newest: newest N sessions, ignoring skills entirely (default); "
|
|
971
|
+
"skill-stratified: per-skill/no-skill sampling")
|
|
972
|
+
ap.add_argument("--max-sessions", type=int, default=12, help="max sessions to sample for scoring")
|
|
973
|
+
ap.add_argument("--per-skill", type=int, default=3, help="max sampled sessions per skill")
|
|
974
|
+
ap.add_argument("--no-skill", type=int, default=4, help="max sampled sessions that used no skill")
|
|
975
|
+
ap.add_argument("--skills-dir", action="append", default=[], help="extra skills directory to scan (repeatable)")
|
|
976
|
+
ap.add_argument("--include-subagents", action="store_true", help="include subagent/child sessions")
|
|
977
|
+
ap.add_argument("--out", default="./doctor-md-agents-report")
|
|
978
|
+
return ap.parse_args()
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
class Scan:
|
|
982
|
+
"""Running totals while the selected sources are read."""
|
|
983
|
+
|
|
984
|
+
def __init__(self):
|
|
985
|
+
self.sessions = []
|
|
986
|
+
self.sources = {}
|
|
987
|
+
self.files_seen = 0
|
|
988
|
+
self.in_scope = 0
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def scan_source(reader_cls, home, args, cutoff, skills, repos, scan: Scan):
|
|
992
|
+
files = reader_cls.locate(home, cutoff, args.include_subagents)
|
|
993
|
+
scan.sources[reader_cls.key] = {"home": str(home), "records_in_window": len(files)}
|
|
994
|
+
scan.files_seen += len(files)
|
|
995
|
+
for mtime, path in files:
|
|
996
|
+
parsed = digest_session(reader_cls(path, args.include_subagents), skills.keys())
|
|
997
|
+
if parsed is None:
|
|
998
|
+
continue
|
|
999
|
+
meta, stats, entries, used = parsed
|
|
1000
|
+
if not args.all_conversations and not session_matches_repos(meta.get("cwd"), repos):
|
|
1001
|
+
continue
|
|
1002
|
+
scan.in_scope += 1
|
|
1003
|
+
if stats["assistant_turns"] < 1 or stats["tool_calls"] < 1:
|
|
1004
|
+
continue
|
|
1005
|
+
scan.sessions.append({
|
|
1006
|
+
"harness": reader_cls.key,
|
|
1007
|
+
"meta": meta,
|
|
1008
|
+
"stats": stats,
|
|
1009
|
+
"skills_used": used,
|
|
1010
|
+
"file": str(path),
|
|
1011
|
+
"modified_at": mtime.isoformat(),
|
|
1012
|
+
"_entries": entries,
|
|
1013
|
+
})
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def main():
|
|
1017
|
+
args = parse_args()
|
|
1018
|
+
if args.all_conversations and args.repo:
|
|
1019
|
+
print("error: --all-conversations cannot be combined with --repo", file=sys.stderr)
|
|
1020
|
+
sys.exit(2)
|
|
1021
|
+
|
|
1022
|
+
homes = {
|
|
1023
|
+
"claude": Path(args.claude_home).expanduser(),
|
|
1024
|
+
"codex": Path(args.codex_home).expanduser(),
|
|
1025
|
+
"pi": Path(args.pi_home).expanduser(),
|
|
1026
|
+
"grok": Path(args.grok_home).expanduser(),
|
|
1027
|
+
"zcode": Path(args.zcode_home).expanduser(),
|
|
1028
|
+
}
|
|
1029
|
+
out_dir = Path(args.out).expanduser()
|
|
1030
|
+
transcripts_dir = out_dir / "transcripts"
|
|
1031
|
+
transcripts_dir.mkdir(parents=True, exist_ok=True)
|
|
1032
|
+
|
|
1033
|
+
def find_skills(repo_list):
|
|
1034
|
+
return discover_skills(repo_list, homes["codex"], args.skills_dir, args.include_global_skills,
|
|
1035
|
+
pi_home=homes["pi"], grok_home=homes["grok"], zcode_home=homes["zcode"])
|
|
1036
|
+
|
|
1037
|
+
repos = [] if args.all_conversations else resolve_project_roots(args.repo)
|
|
1038
|
+
skills = find_skills(repos)
|
|
1039
|
+
cutoff = datetime.now(timezone.utc) - timedelta(days=args.days)
|
|
1040
|
+
|
|
1041
|
+
scan = Scan()
|
|
1042
|
+
for key, reader_cls in READERS.items():
|
|
1043
|
+
home = homes[key]
|
|
1044
|
+
wanted = args.harness in ("auto", "all", key)
|
|
1045
|
+
if wanted and reader_cls.required_dir(home).is_dir():
|
|
1046
|
+
scan_source(reader_cls, home, args, cutoff, skills, repos, scan)
|
|
1047
|
+
elif args.harness == key:
|
|
1048
|
+
print(f"error: {reader_cls.absence_message(home)}", file=sys.stderr)
|
|
1049
|
+
sys.exit(1)
|
|
1050
|
+
|
|
1051
|
+
if not scan.sources:
|
|
1052
|
+
print("error: no supported session source found (Claude Code, Codex, Pi, Grok, or ZCode)",
|
|
1053
|
+
file=sys.stderr)
|
|
1054
|
+
sys.exit(1)
|
|
1055
|
+
|
|
1056
|
+
sessions = scan.sessions
|
|
1057
|
+
if args.all_conversations:
|
|
1058
|
+
repos = repos_seen_in(sessions)
|
|
1059
|
+
skills = find_skills(repos)
|
|
1060
|
+
installed = set(skills)
|
|
1061
|
+
for session in sessions:
|
|
1062
|
+
extra = detect_skills_from_entries(session["_entries"], installed)
|
|
1063
|
+
session["skills_used"] = sorted((set(session["skills_used"]) | extra) & installed)
|
|
1064
|
+
|
|
1065
|
+
sessions.sort(key=lambda item: item["modified_at"], reverse=True)
|
|
1066
|
+
for session in sessions:
|
|
1067
|
+
session["_key"] = f"{session['harness']}:{session['meta']['id']}"
|
|
1068
|
+
|
|
1069
|
+
if args.sample == "newest":
|
|
1070
|
+
chosen = sample_newest(sessions, args.max_sessions)
|
|
1071
|
+
else:
|
|
1072
|
+
chosen = sample_by_skill(sessions, args.max_sessions, args.per_skill, args.no_skill, skills)
|
|
1073
|
+
|
|
1074
|
+
for session in sessions:
|
|
1075
|
+
session["sampled"] = session["_key"] in chosen
|
|
1076
|
+
if session["sampled"]:
|
|
1077
|
+
target = transcripts_dir / f"{session['harness']}-{session['meta']['id']}.md"
|
|
1078
|
+
target.write_text(render_transcript(session["meta"], session["stats"],
|
|
1079
|
+
session["skills_used"], session["_entries"]))
|
|
1080
|
+
session["transcript_path"] = str(target)
|
|
1081
|
+
del session["_entries"]
|
|
1082
|
+
del session["_key"]
|
|
1083
|
+
|
|
1084
|
+
usage = {name: 0 for name in skills}
|
|
1085
|
+
for session in sessions:
|
|
1086
|
+
for name in session["skills_used"]:
|
|
1087
|
+
usage[name] += 1
|
|
1088
|
+
|
|
1089
|
+
if args.all_conversations:
|
|
1090
|
+
scope_kind, scope_label = "all", "all-conversations"
|
|
1091
|
+
else:
|
|
1092
|
+
scope_kind = "projects"
|
|
1093
|
+
scope_label = repos[0].name if len(repos) == 1 else "multiple-projects"
|
|
1094
|
+
|
|
1095
|
+
active = scan.sources
|
|
1096
|
+
inventory = {
|
|
1097
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
1098
|
+
"harness": next(iter(active)) if len(active) == 1 else "mixed",
|
|
1099
|
+
"sources": active,
|
|
1100
|
+
**{f"{key}_home": (str(homes[key]) if key in active else None) for key in READERS},
|
|
1101
|
+
"conversation_scope": scope_kind,
|
|
1102
|
+
"repo": str(repos[0]) if len(repos) == 1 else None,
|
|
1103
|
+
"repos": [str(repo) for repo in repos],
|
|
1104
|
+
"repo_name": scope_label,
|
|
1105
|
+
"repo_names": [repo.name for repo in repos],
|
|
1106
|
+
"window_days": args.days,
|
|
1107
|
+
"skills": sorted(skills.values(), key=lambda entry: entry["name"]),
|
|
1108
|
+
"skill_usage": usage,
|
|
1109
|
+
"stats": {
|
|
1110
|
+
"session_files_in_window": scan.files_seen,
|
|
1111
|
+
"session_records_in_window": scan.files_seen,
|
|
1112
|
+
"sessions_in_repo": scan.in_scope,
|
|
1113
|
+
"sessions_in_scope": scan.in_scope,
|
|
1114
|
+
"sessions_considered": len(sessions),
|
|
1115
|
+
"sessions_sampled": len(chosen),
|
|
1116
|
+
"skills_found": len(skills),
|
|
1117
|
+
"skills_used": sum(1 for count in usage.values() if count > 0),
|
|
1118
|
+
},
|
|
1119
|
+
"sessions": sessions,
|
|
1120
|
+
}
|
|
1121
|
+
(out_dir / "inventory.json").write_text(json.dumps(inventory, indent=2))
|
|
1122
|
+
|
|
1123
|
+
totals = inventory["stats"]
|
|
1124
|
+
scope_text = "all conversations" if args.all_conversations else ", ".join(str(r) for r in repos)
|
|
1125
|
+
print(f"scope: {scope_text}")
|
|
1126
|
+
print(f"sources: {', '.join(active)}")
|
|
1127
|
+
print(f"skills found: {totals['skills_found']} ({totals['skills_used']} used in window)")
|
|
1128
|
+
print(f"sessions in window: {totals['session_records_in_window']} records, "
|
|
1129
|
+
f"{totals['sessions_in_scope']} in scope, {totals['sessions_considered']} scoreable")
|
|
1130
|
+
print(f"sessions sampled: {totals['sessions_sampled']} -> {transcripts_dir}")
|
|
1131
|
+
print(f"inventory: {out_dir / 'inventory.json'}")
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
if __name__ == "__main__":
|
|
1135
|
+
main()
|