@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,781 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Mine the raw session logs for hard evidence that the user had to correct
|
|
3
|
+
the agent -- the whole window, not a sample.
|
|
4
|
+
|
|
5
|
+
Sources: Claude Code JSONL (`<claude-home>/projects/*/*.jsonl`, required)
|
|
6
|
+
and, best-effort, Codex rollout JSONL (`<codex-home>/{sessions,
|
|
7
|
+
archived_sessions}/**/rollout-*.jsonl`) when that layout is present. When
|
|
8
|
+
Codex is not found, `sources` in the output is `["claude"]` only.
|
|
9
|
+
|
|
10
|
+
Detection is a case-insensitive regex over the user's own words, seeded with
|
|
11
|
+
both Spanish and English phrasings. The patterns intentionally over-match --
|
|
12
|
+
a missed correction can never be recovered later, a false positive costs one
|
|
13
|
+
human read of the report. Session logs also carry harness-injected content
|
|
14
|
+
disguised as a "user" turn (Claude Code's local-command wrappers, Codex's
|
|
15
|
+
own "# AGENTS.md instructions for ..." context dump) -- those are stripped
|
|
16
|
+
before matching, or the correction count would be dominated by the user's
|
|
17
|
+
own instruction file being echoed back at them.
|
|
18
|
+
|
|
19
|
+
Clustering is a rough, deterministic, non-model grouping by shared salient
|
|
20
|
+
tokens -- a starting point for a human to merge, split and rename, not an
|
|
21
|
+
answer.
|
|
22
|
+
|
|
23
|
+
Not every "user" turn in a session log was typed by a human. Sub-agents,
|
|
24
|
+
audit harnesses, judges and other skills all write turns the log records as
|
|
25
|
+
"user", and on a real corpus they can dwarf the genuine corrections: one
|
|
26
|
+
run found 688 pattern-matched "corrections" of which 686 were a single
|
|
27
|
+
agent-written prompt template, repeated verbatim across hundreds of
|
|
28
|
+
sessions. Three filters drop these by shape, never by naming a specific
|
|
29
|
+
tool or skill, so this script stays independent of whatever else runs on
|
|
30
|
+
this machine:
|
|
31
|
+
|
|
32
|
+
1. --max-turn-chars a user turn longer than this cannot be a typed
|
|
33
|
+
correction; dropped before pattern matching.
|
|
34
|
+
2. --template-threshold a candidate turn (one that already matched a
|
|
35
|
+
correction pattern) whose normalized text repeats
|
|
36
|
+
across this many or more DISTINCT sessions is a
|
|
37
|
+
template, not a correction -- every occurrence is
|
|
38
|
+
dropped. This needs two passes: collect every
|
|
39
|
+
candidate first, then decide per normalized
|
|
40
|
+
prefix, because the same short text repeated many
|
|
41
|
+
times within one session is not a template. The
|
|
42
|
+
grouping key is only the first --template-prefix-
|
|
43
|
+
chars of the folded text (default 160): a
|
|
44
|
+
template's identity is its opening, not whatever
|
|
45
|
+
variable payload (an id, a JSON blob) follows it --
|
|
46
|
+
a key long enough to reach into that payload makes
|
|
47
|
+
every instance hash differently and defeats the
|
|
48
|
+
filter on exactly the turns it exists to catch.
|
|
49
|
+
3. self-session sentinel a turn containing SELF_SESSION_SENTINEL is this
|
|
50
|
+
skill's own prior-run judging prompt, not a user
|
|
51
|
+
turn; its whole session is skipped, since the
|
|
52
|
+
session's other turns are this skill talking to
|
|
53
|
+
itself, not a human correcting an agent.
|
|
54
|
+
|
|
55
|
+
Emits:
|
|
56
|
+
<out>/corrections.json - every detected correction (session, timestamp,
|
|
57
|
+
matched pattern, text, preceding assistant
|
|
58
|
+
context) plus keyword clusters.
|
|
59
|
+
|
|
60
|
+
Python 3.9+, stdlib only. Reads session logs, writes only under --out,
|
|
61
|
+
makes no network call, and never modifies a session file.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
import argparse
|
|
65
|
+
import hashlib
|
|
66
|
+
import json
|
|
67
|
+
import os
|
|
68
|
+
import re
|
|
69
|
+
import sys
|
|
70
|
+
from collections import Counter
|
|
71
|
+
from datetime import datetime, timedelta, timezone
|
|
72
|
+
from pathlib import Path
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Correction patterns
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
APOS = "['’ʼ]" # straight and curly apostrophes -- real logs carry both
|
|
79
|
+
|
|
80
|
+
SEED_PATTERNS = [
|
|
81
|
+
# -- Spanish --
|
|
82
|
+
("no, usa", r"\bno,\s*usa\b"),
|
|
83
|
+
("no uses", r"\bno\s+uses\b"),
|
|
84
|
+
("no me has", r"\bno\s+me\s+has\b"),
|
|
85
|
+
("no hagas", r"\bno\s+hagas\b"),
|
|
86
|
+
("te dije", r"\bte\s+dije\b"),
|
|
87
|
+
("ya te dije", r"\bya\s+te\s+dije\b"),
|
|
88
|
+
("otra vez", r"\botra\s+vez\b"),
|
|
89
|
+
("vuelve a", r"\bvuelve\s+a\b"),
|
|
90
|
+
("siempre usa/haz/pon", r"\bsiempre\s+(usa|haz|pon)\b"),
|
|
91
|
+
("nunca", r"\bnunca\b"),
|
|
92
|
+
("sin co-authored/atribución", r"\bsin\s+(co-authored|atribuci[oó]n)\b"),
|
|
93
|
+
("está mal", r"\best[aá]\s+mal\b"),
|
|
94
|
+
("eso no", r"\beso\s+no\b"),
|
|
95
|
+
("no es así", r"\bno\s+es\s+as[ií]\b"),
|
|
96
|
+
("deberías haber", r"\bdeber[ií]as\s+haber\b"),
|
|
97
|
+
("acuérdate", r"\bacu[eé]rdate\b"),
|
|
98
|
+
("recuerda que", r"\brecuerda\s+que\b"),
|
|
99
|
+
("mal,", r"\bmal\s*,"),
|
|
100
|
+
("perdona no/pero", r"\bperdona,?\s+(no|pero)\b"),
|
|
101
|
+
# -- English --
|
|
102
|
+
("no, use", r"\bno,\s*use\b"),
|
|
103
|
+
("don't use", rf"\bdon{APOS}t\s+use\b"),
|
|
104
|
+
("do not use", r"\bdo\s+not\s+use\b"),
|
|
105
|
+
("you didn't", rf"\byou\s+didn{APOS}t\b"),
|
|
106
|
+
("you did not", r"\byou\s+did\s+not\b"),
|
|
107
|
+
("i told you", r"\bi\s+told\s+you\b"),
|
|
108
|
+
("again,", r"\bagain\s*,"),
|
|
109
|
+
("always use", r"\balways\s+use\b"),
|
|
110
|
+
("never use", r"\bnever\s+use\b"),
|
|
111
|
+
("that's wrong", rf"\bthat{APOS}s\s+wrong\b"),
|
|
112
|
+
("that is wrong", r"\bthat\s+is\s+wrong\b"),
|
|
113
|
+
("not like that", r"\bnot\s+like\s+that\b"),
|
|
114
|
+
("you should have", r"\byou\s+should\s+have\b"),
|
|
115
|
+
("remember to", r"\bremember\s+to\b"),
|
|
116
|
+
("stop using", r"\bstop\s+using\b"),
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
SKIP_EXACT = {"[Request interrupted by user]"}
|
|
120
|
+
SKIP_PREFIXES = ("<local-command-caveat>", "<command-name>", "<local-command-stdout>")
|
|
121
|
+
|
|
122
|
+
# A turn carrying this marker is a prompt this skill itself sent to a judge
|
|
123
|
+
# (SKILL.md Step 4), not a human correcting an agent. Defined once here so
|
|
124
|
+
# every future run recognizes its own prior-run prompts in the logs and
|
|
125
|
+
# refuses to mine them.
|
|
126
|
+
SELF_SESSION_SENTINEL = "<!-- doctor-md-agents:self-session -->"
|
|
127
|
+
|
|
128
|
+
# Default grouping-key length for template detection (overridable via
|
|
129
|
+
# --template-prefix-chars). Deliberately short: a template's identity is
|
|
130
|
+
# its opening line(s), not whatever variable payload -- an id, a JSON blob
|
|
131
|
+
# -- follows. A key long enough to reach into that payload makes every
|
|
132
|
+
# instance of the same template hash differently and defeats the filter.
|
|
133
|
+
TEMPLATE_PREFIX_CHARS = 160
|
|
134
|
+
TEMPLATE_REPORT_PREFIX_CHARS = 160
|
|
135
|
+
|
|
136
|
+
PASTED_CONTENT_RE = re.compile(r"<pasted_content\b[^>]*>.*?</pasted_content>", re.DOTALL | re.IGNORECASE)
|
|
137
|
+
|
|
138
|
+
# Codex injects the project's own AGENTS.md/CLAUDE.md text into a fake
|
|
139
|
+
# "user" turn at session start (and after context compaction). Left
|
|
140
|
+
# unfiltered, every correction pattern that also appears in the user's own
|
|
141
|
+
# instruction file (many do -- "nunca", "siempre", "no uses"...) would fire
|
|
142
|
+
# on every single session.
|
|
143
|
+
CODEX_INJECTED_RE = re.compile(r"^#\s+(?:AGENTS|CLAUDE)\.md\s+instructions\s+for\b", re.IGNORECASE)
|
|
144
|
+
GENERIC_INJECTED_TAGS = (
|
|
145
|
+
"environment_context", "user_instructions", "system-reminder", "recommended_plugins",
|
|
146
|
+
"permissions", "collaboration_mode", "turn_context", "user_info", "skills_instructions",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
MAX_CORRECTION_TEXT_CHARS = 4000
|
|
150
|
+
|
|
151
|
+
# A short, intentionally modest stopword list (ES + EN) plus the seed
|
|
152
|
+
# patterns' own trigger words, so clusters key on the *subject* of the
|
|
153
|
+
# correction rather than on "siempre"/"nunca"/"usa" appearing in almost
|
|
154
|
+
# every hit.
|
|
155
|
+
STOPWORDS = {
|
|
156
|
+
"the", "and", "for", "that", "this", "with", "from", "have", "has", "had", "you", "your",
|
|
157
|
+
"yours", "are", "was", "were", "been", "being", "not", "but", "into", "onto", "they", "them",
|
|
158
|
+
"their", "what", "when", "where", "which", "who", "whom", "why", "how", "then", "than", "also",
|
|
159
|
+
"just", "like", "about", "again", "always", "never", "should", "would", "could", "please",
|
|
160
|
+
"some", "more", "most", "very", "only", "over", "under", "after", "before", "during", "while",
|
|
161
|
+
"because", "still", "even", "told", "didn", "did", "doesn", "does", "wrong", "remember", "stop",
|
|
162
|
+
"use", "uses", "used", "using",
|
|
163
|
+
"para", "esto", "esta", "estos", "estas", "pero", "como", "cuando", "donde", "cual", "cuales",
|
|
164
|
+
"quien", "quienes", "porque", "tambien", "mas", "menos", "muy", "solo", "sobre", "bajo",
|
|
165
|
+
"despues", "antes", "mientras", "todavia", "aun", "siempre", "nunca", "otra", "otro", "vez",
|
|
166
|
+
"dijiste", "dije", "hagas", "haces", "usar", "usa", "usas", "pon", "ponme", "deberia",
|
|
167
|
+
"deberias", "haber", "acuerdate", "recuerda", "perdona", "mal", "eres", "sera", "seria",
|
|
168
|
+
"tiene", "tienen", "tenia", "hace", "hizo", "haciendo", "vuelve",
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def compile_patterns(extra_file):
|
|
173
|
+
patterns = [(label, re.compile(regex, re.IGNORECASE)) for label, regex in SEED_PATTERNS]
|
|
174
|
+
if extra_file:
|
|
175
|
+
text = Path(extra_file).expanduser().read_text(encoding="utf-8", errors="replace")
|
|
176
|
+
for line in text.splitlines():
|
|
177
|
+
line = line.strip()
|
|
178
|
+
if not line or line.startswith("#"):
|
|
179
|
+
continue
|
|
180
|
+
try:
|
|
181
|
+
patterns.append((line, re.compile(line, re.IGNORECASE)))
|
|
182
|
+
except re.error as exc:
|
|
183
|
+
print(f"warning: skipping invalid pattern {line!r}: {exc}", file=sys.stderr)
|
|
184
|
+
return patterns
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def first_match(patterns, text):
|
|
188
|
+
for label, rx in patterns:
|
|
189
|
+
if rx.search(text):
|
|
190
|
+
return label
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
# Text extraction helpers
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def truncate(text, limit):
|
|
200
|
+
text = text.strip()
|
|
201
|
+
if len(text) <= limit:
|
|
202
|
+
return text
|
|
203
|
+
return text[:limit] + f" …[truncated {len(text) - limit} chars]"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def strip_pasted_content(text):
|
|
207
|
+
return PASTED_CONTENT_RE.sub("", text)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def normalize_prefix(text, length=TEMPLATE_PREFIX_CHARS):
|
|
211
|
+
"""Fold whitespace and take the first `length` chars -- the key used to
|
|
212
|
+
group repeated-template candidates regardless of which session wrote
|
|
213
|
+
them or how the surrounding whitespace happens to differ."""
|
|
214
|
+
return re.sub(r"\s+", " ", text).strip()[:length]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def turn_contains_sentinel(turn):
|
|
218
|
+
return SELF_SESSION_SENTINEL in (turn.get("text") or "") or SELF_SESSION_SENTINEL in (turn.get("condensed") or "")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def looks_injected_generic(text):
|
|
222
|
+
head = text.lstrip()[:80]
|
|
223
|
+
return head.startswith("<") and any(tag in head for tag in GENERIC_INJECTED_TAGS)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def clean_user_text(raw_text):
|
|
227
|
+
"""Apply the skip/strip rules; return None if the turn is not real."""
|
|
228
|
+
if raw_text is None:
|
|
229
|
+
return None
|
|
230
|
+
stripped_start = raw_text.lstrip()
|
|
231
|
+
if raw_text.strip() in SKIP_EXACT:
|
|
232
|
+
return None
|
|
233
|
+
if stripped_start.startswith(SKIP_PREFIXES):
|
|
234
|
+
return None
|
|
235
|
+
without_pasted = strip_pasted_content(raw_text)
|
|
236
|
+
if without_pasted.strip() == "":
|
|
237
|
+
return None
|
|
238
|
+
return without_pasted
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# Claude Code
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def claude_text_blocks(content):
|
|
247
|
+
"""Return (joined_text_or_None, tool_use_blocks, saw_only_tool_result)."""
|
|
248
|
+
if isinstance(content, str):
|
|
249
|
+
return content, [], False
|
|
250
|
+
if not isinstance(content, list):
|
|
251
|
+
return None, [], False
|
|
252
|
+
texts = []
|
|
253
|
+
tool_uses = []
|
|
254
|
+
saw_any_block = False
|
|
255
|
+
saw_non_tool_result = False
|
|
256
|
+
for block in content:
|
|
257
|
+
if not isinstance(block, dict):
|
|
258
|
+
continue
|
|
259
|
+
saw_any_block = True
|
|
260
|
+
btype = block.get("type")
|
|
261
|
+
if btype == "text":
|
|
262
|
+
saw_non_tool_result = True
|
|
263
|
+
t = block.get("text")
|
|
264
|
+
if isinstance(t, str) and t:
|
|
265
|
+
texts.append(t)
|
|
266
|
+
elif btype == "tool_use":
|
|
267
|
+
saw_non_tool_result = True
|
|
268
|
+
tool_uses.append(block)
|
|
269
|
+
elif btype == "tool_result":
|
|
270
|
+
pass # tool_result blocks never count as user text
|
|
271
|
+
else:
|
|
272
|
+
saw_non_tool_result = True
|
|
273
|
+
only_tool_result = saw_any_block and not saw_non_tool_result
|
|
274
|
+
text = "\n".join(texts) if texts else None
|
|
275
|
+
return text, tool_uses, only_tool_result
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def condense_tool_uses(tool_uses, limit):
|
|
279
|
+
parts = []
|
|
280
|
+
for block in tool_uses:
|
|
281
|
+
name = block.get("name") or "tool"
|
|
282
|
+
args = block.get("input") or {}
|
|
283
|
+
args_text = args if isinstance(args, str) else json.dumps(args, ensure_ascii=False)
|
|
284
|
+
parts.append(f"{name}({args_text[:60]})")
|
|
285
|
+
return truncate("; ".join(parts), limit)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def iter_claude_turns(path, max_context):
|
|
289
|
+
"""Yield raw turn dicts for one Claude Code session file."""
|
|
290
|
+
try:
|
|
291
|
+
fh = path.open("r", encoding="utf-8", errors="replace")
|
|
292
|
+
except OSError:
|
|
293
|
+
return
|
|
294
|
+
with fh:
|
|
295
|
+
for line_no, line in enumerate(fh):
|
|
296
|
+
try:
|
|
297
|
+
obj = json.loads(line)
|
|
298
|
+
except (json.JSONDecodeError, ValueError):
|
|
299
|
+
continue
|
|
300
|
+
rtype = obj.get("type")
|
|
301
|
+
if rtype not in ("user", "assistant"):
|
|
302
|
+
continue
|
|
303
|
+
if obj.get("isSidechain"):
|
|
304
|
+
continue
|
|
305
|
+
message = obj.get("message")
|
|
306
|
+
if not isinstance(message, dict):
|
|
307
|
+
continue
|
|
308
|
+
role = message.get("role") or rtype
|
|
309
|
+
content = message.get("content")
|
|
310
|
+
|
|
311
|
+
session_id = obj.get("sessionId")
|
|
312
|
+
if not session_id:
|
|
313
|
+
continue
|
|
314
|
+
uuid = obj.get("uuid") or f"{path}:{line_no}"
|
|
315
|
+
ts = obj.get("timestamp")
|
|
316
|
+
|
|
317
|
+
if role == "user":
|
|
318
|
+
if obj.get("isMeta"):
|
|
319
|
+
continue
|
|
320
|
+
raw_text, _tool_uses, only_tool_result = claude_text_blocks(content)
|
|
321
|
+
if only_tool_result or raw_text is None:
|
|
322
|
+
continue # tool_result-only record is not a user turn
|
|
323
|
+
text = clean_user_text(raw_text)
|
|
324
|
+
yield {
|
|
325
|
+
"session_id": session_id, "harness": "claude", "uuid": uuid,
|
|
326
|
+
"timestamp": ts, "cwd": obj.get("cwd"), "git_branch": obj.get("gitBranch"),
|
|
327
|
+
"role": "user", "text": text,
|
|
328
|
+
"condensed": truncate(text, max_context) if text else "",
|
|
329
|
+
}
|
|
330
|
+
else:
|
|
331
|
+
raw_text, tool_uses, _ = claude_text_blocks(content)
|
|
332
|
+
if raw_text:
|
|
333
|
+
condensed = truncate(raw_text, max_context)
|
|
334
|
+
elif tool_uses:
|
|
335
|
+
condensed = condense_tool_uses(tool_uses, max_context)
|
|
336
|
+
else:
|
|
337
|
+
condensed = ""
|
|
338
|
+
yield {
|
|
339
|
+
"session_id": session_id, "harness": "claude", "uuid": uuid,
|
|
340
|
+
"timestamp": ts, "cwd": obj.get("cwd"), "git_branch": obj.get("gitBranch"),
|
|
341
|
+
"role": "assistant", "text": None, "condensed": condensed,
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def find_claude_files(claude_home, cutoff):
|
|
346
|
+
projects = claude_home / "projects"
|
|
347
|
+
if not projects.is_dir():
|
|
348
|
+
return []
|
|
349
|
+
files = []
|
|
350
|
+
for path in projects.glob("*/*.jsonl"):
|
|
351
|
+
try:
|
|
352
|
+
mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
|
|
353
|
+
except OSError:
|
|
354
|
+
continue
|
|
355
|
+
if mtime >= cutoff:
|
|
356
|
+
files.append(path)
|
|
357
|
+
return sorted(files)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
# Codex (best-effort; shape confirmed against scripts/collect_sessions.py's
|
|
362
|
+
# parse_codex_session and against real ~/.codex/sessions rollout files)
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def codex_extract_text(content):
|
|
367
|
+
if isinstance(content, str):
|
|
368
|
+
return content
|
|
369
|
+
if not isinstance(content, list):
|
|
370
|
+
return None
|
|
371
|
+
parts = []
|
|
372
|
+
for block in content:
|
|
373
|
+
if isinstance(block, dict):
|
|
374
|
+
t = block.get("text")
|
|
375
|
+
if isinstance(t, str) and t:
|
|
376
|
+
parts.append(t)
|
|
377
|
+
return "\n".join(parts) if parts else None
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def looks_injected_codex(text):
|
|
381
|
+
if CODEX_INJECTED_RE.match(text.lstrip()):
|
|
382
|
+
return True
|
|
383
|
+
return looks_injected_generic(text)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def iter_codex_turns(path, max_context):
|
|
387
|
+
try:
|
|
388
|
+
fh = path.open("r", encoding="utf-8", errors="replace")
|
|
389
|
+
except OSError:
|
|
390
|
+
return
|
|
391
|
+
session_id = None
|
|
392
|
+
cwd = None
|
|
393
|
+
with fh:
|
|
394
|
+
for line_no, obj_line in enumerate(fh):
|
|
395
|
+
try:
|
|
396
|
+
obj = json.loads(obj_line)
|
|
397
|
+
except (json.JSONDecodeError, ValueError):
|
|
398
|
+
continue
|
|
399
|
+
rtype = obj.get("type")
|
|
400
|
+
payload = obj.get("payload")
|
|
401
|
+
if not isinstance(payload, dict):
|
|
402
|
+
continue
|
|
403
|
+
ts = obj.get("timestamp")
|
|
404
|
+
|
|
405
|
+
if rtype == "session_meta":
|
|
406
|
+
session_id = payload.get("id") or payload.get("session_id")
|
|
407
|
+
cwd = payload.get("cwd")
|
|
408
|
+
source = payload.get("source")
|
|
409
|
+
thread_source = payload.get("thread_source")
|
|
410
|
+
is_subagent = thread_source == "subagent" or (
|
|
411
|
+
isinstance(source, dict) and "subagent" in source
|
|
412
|
+
)
|
|
413
|
+
if is_subagent:
|
|
414
|
+
return
|
|
415
|
+
continue
|
|
416
|
+
|
|
417
|
+
if rtype != "response_item" or not session_id:
|
|
418
|
+
continue
|
|
419
|
+
|
|
420
|
+
ptype = payload.get("type")
|
|
421
|
+
uuid = payload.get("id") or f"{path}:{line_no}"
|
|
422
|
+
|
|
423
|
+
if ptype == "message" and payload.get("role") == "user":
|
|
424
|
+
raw_text = codex_extract_text(payload.get("content"))
|
|
425
|
+
if raw_text is None or looks_injected_codex(raw_text):
|
|
426
|
+
continue
|
|
427
|
+
text = clean_user_text(raw_text)
|
|
428
|
+
yield {
|
|
429
|
+
"session_id": session_id, "harness": "codex", "uuid": uuid,
|
|
430
|
+
"timestamp": ts, "cwd": cwd, "git_branch": None,
|
|
431
|
+
"role": "user", "text": text,
|
|
432
|
+
"condensed": truncate(text, max_context) if text else "",
|
|
433
|
+
}
|
|
434
|
+
elif ptype == "message" and payload.get("role") == "assistant":
|
|
435
|
+
raw_text = codex_extract_text(payload.get("content"))
|
|
436
|
+
condensed = truncate(raw_text, max_context) if raw_text else ""
|
|
437
|
+
yield {
|
|
438
|
+
"session_id": session_id, "harness": "codex", "uuid": uuid,
|
|
439
|
+
"timestamp": ts, "cwd": cwd, "git_branch": None,
|
|
440
|
+
"role": "assistant", "text": None, "condensed": condensed,
|
|
441
|
+
}
|
|
442
|
+
elif ptype in ("function_call", "custom_tool_call", "local_shell_call"):
|
|
443
|
+
name = payload.get("name") or ptype
|
|
444
|
+
args = payload.get("arguments") or payload.get("input") or ""
|
|
445
|
+
if not isinstance(args, str):
|
|
446
|
+
args = json.dumps(args, ensure_ascii=False)
|
|
447
|
+
condensed = truncate(f"{name}({args[:60]})", max_context)
|
|
448
|
+
yield {
|
|
449
|
+
"session_id": session_id, "harness": "codex", "uuid": uuid,
|
|
450
|
+
"timestamp": ts, "cwd": cwd, "git_branch": None,
|
|
451
|
+
"role": "assistant", "text": None, "condensed": condensed,
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def find_codex_files(codex_home, cutoff):
|
|
456
|
+
files = []
|
|
457
|
+
for sub in ("sessions", "archived_sessions"):
|
|
458
|
+
root = codex_home / sub
|
|
459
|
+
if not root.is_dir():
|
|
460
|
+
continue
|
|
461
|
+
for path in root.rglob("rollout-*.jsonl"):
|
|
462
|
+
try:
|
|
463
|
+
mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
|
|
464
|
+
except OSError:
|
|
465
|
+
continue
|
|
466
|
+
if mtime >= cutoff:
|
|
467
|
+
files.append(path)
|
|
468
|
+
return sorted(files)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# ---------------------------------------------------------------------------
|
|
472
|
+
# Timestamp / scope helpers
|
|
473
|
+
# ---------------------------------------------------------------------------
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def parse_ts(value):
|
|
477
|
+
if not value:
|
|
478
|
+
return None
|
|
479
|
+
text = str(value).strip()
|
|
480
|
+
if text.endswith("Z"):
|
|
481
|
+
text = text[:-1] + "+00:00"
|
|
482
|
+
try:
|
|
483
|
+
dt = datetime.fromisoformat(text)
|
|
484
|
+
except ValueError:
|
|
485
|
+
return None
|
|
486
|
+
if dt.tzinfo is None:
|
|
487
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
488
|
+
return dt.astimezone(timezone.utc)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def normalize_repo(repo_arg):
|
|
492
|
+
return os.path.normpath(os.path.expanduser(repo_arg))
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def cwd_in_scope(cwd, repo_norms):
|
|
496
|
+
if not cwd:
|
|
497
|
+
return False
|
|
498
|
+
cwd_norm = os.path.normpath(os.path.expanduser(cwd))
|
|
499
|
+
for repo_norm in repo_norms:
|
|
500
|
+
if cwd_norm == repo_norm or cwd_norm.startswith(repo_norm + os.sep):
|
|
501
|
+
return True
|
|
502
|
+
return False
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# ---------------------------------------------------------------------------
|
|
506
|
+
# Clustering
|
|
507
|
+
# ---------------------------------------------------------------------------
|
|
508
|
+
|
|
509
|
+
WORD_RE = re.compile(r"[^\W\d_]+", re.UNICODE)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def salient_tokens(text):
|
|
513
|
+
tokens = [t.lower() for t in WORD_RE.findall(text)]
|
|
514
|
+
return [t for t in tokens if len(t) >= 4 and t not in STOPWORDS]
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def build_clusters(corrections):
|
|
518
|
+
"""Deterministic, order-preserving greedy clustering by shared salient
|
|
519
|
+
tokens (>=2 shared tokens joins the first qualifying cluster). This is a
|
|
520
|
+
rough grouping meant for a human to merge/split/rename, not an answer."""
|
|
521
|
+
clusters = [] # each: {"token_set": set, "token_counts": Counter, "indexes": [], "sessions": set}
|
|
522
|
+
for index, corr in enumerate(corrections):
|
|
523
|
+
tokens = set(salient_tokens(corr["text"]))
|
|
524
|
+
target = None
|
|
525
|
+
if tokens:
|
|
526
|
+
for cluster in clusters:
|
|
527
|
+
if len(tokens & cluster["token_set"]) >= 2:
|
|
528
|
+
target = cluster
|
|
529
|
+
break
|
|
530
|
+
if target is None:
|
|
531
|
+
target = {"token_set": set(), "token_counts": Counter(), "indexes": [], "sessions": set()}
|
|
532
|
+
clusters.append(target)
|
|
533
|
+
target["token_set"] |= tokens
|
|
534
|
+
target["token_counts"].update(tokens)
|
|
535
|
+
target["indexes"].append(index)
|
|
536
|
+
target["sessions"].add(corr["session_id"])
|
|
537
|
+
|
|
538
|
+
out = []
|
|
539
|
+
for cluster in clusters:
|
|
540
|
+
counted = cluster["token_counts"]
|
|
541
|
+
ranked = sorted(counted.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
542
|
+
key_tokens = [t for t, _ in ranked[:3]]
|
|
543
|
+
out.append({
|
|
544
|
+
"key": " ".join(key_tokens) if key_tokens else "(no salient tokens)",
|
|
545
|
+
"count": len(cluster["indexes"]),
|
|
546
|
+
"sessions": sorted(cluster["sessions"]),
|
|
547
|
+
"correction_indexes": cluster["indexes"],
|
|
548
|
+
})
|
|
549
|
+
out.sort(key=lambda c: (-c["count"], c["key"]))
|
|
550
|
+
return out
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
# ---------------------------------------------------------------------------
|
|
554
|
+
# Main
|
|
555
|
+
# ---------------------------------------------------------------------------
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def parse_args(argv=None):
|
|
559
|
+
p = argparse.ArgumentParser(description=__doc__)
|
|
560
|
+
p.add_argument("--out", required=True)
|
|
561
|
+
p.add_argument("--days", type=int, default=90)
|
|
562
|
+
p.add_argument("--repo", action="append", default=[])
|
|
563
|
+
p.add_argument("--all-conversations", action="store_true")
|
|
564
|
+
p.add_argument("--claude-home", default=os.environ.get("CLAUDE_CONFIG_DIR", "~/.claude"))
|
|
565
|
+
p.add_argument("--codex-home", default="~/.codex")
|
|
566
|
+
p.add_argument("--patterns", default=None, help="extra newline-separated regex patterns")
|
|
567
|
+
p.add_argument("--max-context", type=int, default=400,
|
|
568
|
+
help="chars of preceding-assistant excerpt to keep (default: 400)")
|
|
569
|
+
p.add_argument("--max-turn-chars", type=int, default=1200,
|
|
570
|
+
help="a user turn longer than this cannot be a typed correction (default: 1200)")
|
|
571
|
+
p.add_argument("--template-threshold", type=int, default=3,
|
|
572
|
+
help="drop a candidate as a machine template when its normalized text "
|
|
573
|
+
"repeats across this many or more distinct sessions (default: 3)")
|
|
574
|
+
p.add_argument("--template-prefix-chars", type=int, default=TEMPLATE_PREFIX_CHARS,
|
|
575
|
+
help="chars of whitespace-folded text used as the template-matching key "
|
|
576
|
+
"(default: 160) -- a template's identity is its opening, not the "
|
|
577
|
+
"variable payload (an id, a JSON blob) that follows it")
|
|
578
|
+
return p.parse_args(argv)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def main(argv=None):
|
|
582
|
+
args = parse_args(argv)
|
|
583
|
+
if args.all_conversations and args.repo:
|
|
584
|
+
print("error: --all-conversations cannot be combined with --repo", file=sys.stderr)
|
|
585
|
+
sys.exit(2)
|
|
586
|
+
|
|
587
|
+
out_dir = Path(args.out).expanduser()
|
|
588
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
589
|
+
|
|
590
|
+
claude_home = Path(args.claude_home).expanduser()
|
|
591
|
+
codex_home = Path(args.codex_home).expanduser()
|
|
592
|
+
cutoff = datetime.now(timezone.utc) - timedelta(days=args.days)
|
|
593
|
+
repo_norms = [normalize_repo(r) for r in args.repo]
|
|
594
|
+
|
|
595
|
+
patterns = compile_patterns(args.patterns)
|
|
596
|
+
|
|
597
|
+
claude_files = find_claude_files(claude_home, cutoff)
|
|
598
|
+
if not claude_files and not (claude_home / "projects").is_dir():
|
|
599
|
+
print(f"error: Claude Code project history not found at {claude_home / 'projects'}",
|
|
600
|
+
file=sys.stderr)
|
|
601
|
+
sys.exit(1)
|
|
602
|
+
|
|
603
|
+
codex_files = find_codex_files(codex_home, cutoff)
|
|
604
|
+
sources = ["claude"]
|
|
605
|
+
if codex_files:
|
|
606
|
+
sources.append("codex")
|
|
607
|
+
|
|
608
|
+
session_files_scanned = len(claude_files) + len(codex_files)
|
|
609
|
+
|
|
610
|
+
# session_id -> {"turns": {turn_key: turn_dict}}
|
|
611
|
+
sessions = {}
|
|
612
|
+
for path in claude_files:
|
|
613
|
+
for turn in iter_claude_turns(path, args.max_context):
|
|
614
|
+
bucket = sessions.setdefault(turn["session_id"], {})
|
|
615
|
+
key = turn["uuid"]
|
|
616
|
+
bucket[key] = turn
|
|
617
|
+
for path in codex_files:
|
|
618
|
+
for turn in iter_codex_turns(path, args.max_context):
|
|
619
|
+
bucket = sessions.setdefault(turn["session_id"], {})
|
|
620
|
+
key = f"codex:{turn['uuid']}"
|
|
621
|
+
bucket[key] = turn
|
|
622
|
+
|
|
623
|
+
# Pass 1: walk every in-scope session and collect pattern-matched
|
|
624
|
+
# candidates. Nothing here is decided to be a real correction yet --
|
|
625
|
+
# the template filter (pass 2, below) needs every candidate gathered
|
|
626
|
+
# first, because "repeated across N distinct sessions" cannot be known
|
|
627
|
+
# from a single session's turns in isolation.
|
|
628
|
+
candidates = [] # list of correction dicts (pending template decision)
|
|
629
|
+
candidate_prefixes = [] # parallel list: normalize_prefix(text) per candidate
|
|
630
|
+
user_turns_total = 0
|
|
631
|
+
sessions_scanned = 0
|
|
632
|
+
sessions_in_scope = 0
|
|
633
|
+
turns_dropped_too_long = 0
|
|
634
|
+
turns_dropped_self_session = 0
|
|
635
|
+
sessions_skipped_self_session = 0
|
|
636
|
+
|
|
637
|
+
for session_id in sorted(sessions):
|
|
638
|
+
all_turns = list(sessions[session_id].values())
|
|
639
|
+
|
|
640
|
+
def sort_key(t):
|
|
641
|
+
ts = parse_ts(t["timestamp"])
|
|
642
|
+
return (ts is None, ts or datetime.min.replace(tzinfo=timezone.utc))
|
|
643
|
+
|
|
644
|
+
all_turns.sort(key=sort_key)
|
|
645
|
+
|
|
646
|
+
# A self-session is a property of the whole session, independent of
|
|
647
|
+
# which of its turns happen to fall inside --days: check the full,
|
|
648
|
+
# unwindowed turn list.
|
|
649
|
+
session_has_sentinel = any(turn_contains_sentinel(t) for t in all_turns)
|
|
650
|
+
|
|
651
|
+
# window filter at turn level
|
|
652
|
+
turns = [t for t in all_turns if parse_ts(t["timestamp"]) is None or parse_ts(t["timestamp"]) >= cutoff]
|
|
653
|
+
if not turns:
|
|
654
|
+
continue
|
|
655
|
+
sessions_scanned += 1
|
|
656
|
+
|
|
657
|
+
cwd = next((t["cwd"] for t in turns if t.get("cwd")), None)
|
|
658
|
+
in_scope = args.all_conversations or cwd_in_scope(cwd, repo_norms)
|
|
659
|
+
if not in_scope:
|
|
660
|
+
continue
|
|
661
|
+
sessions_in_scope += 1
|
|
662
|
+
|
|
663
|
+
git_branch = next((t["git_branch"] for t in turns if t.get("git_branch")), None)
|
|
664
|
+
|
|
665
|
+
if session_has_sentinel:
|
|
666
|
+
# This skill's own prior-run judging prompts, not a human
|
|
667
|
+
# correcting an agent -- the whole session is this skill
|
|
668
|
+
# talking to itself. Every real user turn in it still counts
|
|
669
|
+
# toward user_turns (it is a genuine turn in scope) and is
|
|
670
|
+
# fully accounted for by turns_dropped_self_session, so the
|
|
671
|
+
# arithmetic user_turns - dropped_* stays auditable.
|
|
672
|
+
session_user_turns = sum(1 for t in turns if t["role"] == "user" and t["text"])
|
|
673
|
+
user_turns_total += session_user_turns
|
|
674
|
+
turns_dropped_self_session += session_user_turns
|
|
675
|
+
sessions_skipped_self_session += 1
|
|
676
|
+
continue
|
|
677
|
+
|
|
678
|
+
for i, turn in enumerate(turns):
|
|
679
|
+
if turn["role"] != "user" or not turn["text"]:
|
|
680
|
+
continue
|
|
681
|
+
text = turn["text"]
|
|
682
|
+
user_turns_total += 1
|
|
683
|
+
|
|
684
|
+
if len(text) > args.max_turn_chars:
|
|
685
|
+
# Not a typed correction -- dropped before pattern matching.
|
|
686
|
+
turns_dropped_too_long += 1
|
|
687
|
+
continue
|
|
688
|
+
|
|
689
|
+
pattern = first_match(patterns, text)
|
|
690
|
+
if not pattern:
|
|
691
|
+
continue
|
|
692
|
+
preceding = ""
|
|
693
|
+
for j in range(i - 1, -1, -1):
|
|
694
|
+
if turns[j]["role"] == "assistant":
|
|
695
|
+
preceding = turns[j]["condensed"]
|
|
696
|
+
break
|
|
697
|
+
candidates.append({
|
|
698
|
+
"session_id": session_id,
|
|
699
|
+
"harness": turn["harness"],
|
|
700
|
+
"timestamp": turn["timestamp"],
|
|
701
|
+
"cwd": cwd,
|
|
702
|
+
"git_branch": git_branch,
|
|
703
|
+
"pattern": pattern,
|
|
704
|
+
"text": truncate(text, MAX_CORRECTION_TEXT_CHARS),
|
|
705
|
+
"preceding": preceding,
|
|
706
|
+
})
|
|
707
|
+
candidate_prefixes.append(normalize_prefix(text, length=args.template_prefix_chars))
|
|
708
|
+
|
|
709
|
+
# Pass 2: group candidates by normalized prefix and count DISTINCT
|
|
710
|
+
# sessions per group -- the same text repeated many times inside one
|
|
711
|
+
# session is not a template, only the same text spread across several
|
|
712
|
+
# sessions is.
|
|
713
|
+
prefix_sessions = {} # normalized prefix -> set of session ids
|
|
714
|
+
prefix_indexes = {} # normalized prefix -> [candidate indexes]
|
|
715
|
+
for idx, corr in enumerate(candidates):
|
|
716
|
+
prefix = candidate_prefixes[idx]
|
|
717
|
+
prefix_sessions.setdefault(prefix, set()).add(corr["session_id"])
|
|
718
|
+
prefix_indexes.setdefault(prefix, []).append(idx)
|
|
719
|
+
|
|
720
|
+
templated_indexes = set()
|
|
721
|
+
dropped_templates = []
|
|
722
|
+
for prefix, sess_set in prefix_sessions.items():
|
|
723
|
+
if len(sess_set) >= args.template_threshold:
|
|
724
|
+
templated_indexes.update(prefix_indexes[prefix])
|
|
725
|
+
dropped_templates.append({
|
|
726
|
+
"prefix": prefix[:TEMPLATE_REPORT_PREFIX_CHARS],
|
|
727
|
+
"sessions": len(sess_set),
|
|
728
|
+
"occurrences": len(prefix_indexes[prefix]),
|
|
729
|
+
})
|
|
730
|
+
dropped_templates.sort(key=lambda d: (-d["sessions"], -d["occurrences"]))
|
|
731
|
+
turns_dropped_templated = len(templated_indexes)
|
|
732
|
+
|
|
733
|
+
corrections = []
|
|
734
|
+
sessions_with_corrections = set()
|
|
735
|
+
for idx, corr in enumerate(candidates):
|
|
736
|
+
if idx in templated_indexes:
|
|
737
|
+
continue
|
|
738
|
+
corrections.append(corr)
|
|
739
|
+
sessions_with_corrections.add(corr["session_id"])
|
|
740
|
+
|
|
741
|
+
clusters = build_clusters(corrections)
|
|
742
|
+
|
|
743
|
+
report = {
|
|
744
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
745
|
+
"window_days": args.days,
|
|
746
|
+
"sources": sources,
|
|
747
|
+
"stats": {
|
|
748
|
+
"session_files_scanned": session_files_scanned,
|
|
749
|
+
"sessions_scanned": sessions_scanned,
|
|
750
|
+
"sessions_in_scope": sessions_in_scope,
|
|
751
|
+
"user_turns": user_turns_total,
|
|
752
|
+
"corrections_found": len(corrections),
|
|
753
|
+
"sessions_with_corrections": len(sessions_with_corrections),
|
|
754
|
+
"turns_dropped_too_long": turns_dropped_too_long,
|
|
755
|
+
"turns_dropped_templated": turns_dropped_templated,
|
|
756
|
+
"turns_dropped_self_session": turns_dropped_self_session,
|
|
757
|
+
"sessions_skipped_self_session": sessions_skipped_self_session,
|
|
758
|
+
},
|
|
759
|
+
"corrections": corrections,
|
|
760
|
+
"clusters": clusters,
|
|
761
|
+
"dropped_templates": dropped_templates,
|
|
762
|
+
}
|
|
763
|
+
(out_dir / "corrections.json").write_text(json.dumps(report, indent=2))
|
|
764
|
+
|
|
765
|
+
print(f"corrections.json: {out_dir / 'corrections.json'}")
|
|
766
|
+
print(f"sources: {', '.join(sources)}")
|
|
767
|
+
print(f"session files: {session_files_scanned} scanned")
|
|
768
|
+
print(f"sessions: {sessions_scanned} scanned, {sessions_in_scope} in scope")
|
|
769
|
+
print(f"user turns: {user_turns_total}")
|
|
770
|
+
print(f"corrections found: {len(corrections)} across {len(sessions_with_corrections)} sessions")
|
|
771
|
+
print(f"dropped too long: {turns_dropped_too_long}")
|
|
772
|
+
print(f"dropped templated: {turns_dropped_templated} ({len(dropped_templates)} template group(s))")
|
|
773
|
+
print(f"dropped self-session: {turns_dropped_self_session} turns "
|
|
774
|
+
f"across {sessions_skipped_self_session} session(s)")
|
|
775
|
+
print("top clusters:")
|
|
776
|
+
for cluster in clusters[:5]:
|
|
777
|
+
print(f" {cluster['count']:4d} {cluster['key']}")
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
if __name__ == "__main__":
|
|
781
|
+
main()
|