agentforge-framework 0.2.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.
- agentforge_framework/.claude-plugin/plugin.json +4 -0
- agentforge_framework/__init__.py +3 -0
- agentforge_framework/agents/__init__.py +92 -0
- agentforge_framework/agents/architect.py +146 -0
- agentforge_framework/agents/implementer.py +162 -0
- agentforge_framework/agents/orchestrator.py +588 -0
- agentforge_framework/agents/reviewer.py +335 -0
- agentforge_framework/agents/security.py +138 -0
- agentforge_framework/agents/tester.py +125 -0
- agentforge_framework/cli.py +461 -0
- agentforge_framework/context/__init__.py +1 -0
- agentforge_framework/context/extractors/__init__.py +76 -0
- agentforge_framework/context/extractors/base.py +47 -0
- agentforge_framework/context/extractors/python.py +65 -0
- agentforge_framework/context/extractors/sql.py +121 -0
- agentforge_framework/context/extractors/yaml.py +59 -0
- agentforge_framework/context/prompt.py +104 -0
- agentforge_framework/context/resolver.py +185 -0
- agentforge_framework/core/__init__.py +1 -0
- agentforge_framework/core/commands.py +170 -0
- agentforge_framework/core/config.py +90 -0
- agentforge_framework/core/contracts.py +875 -0
- agentforge_framework/core/gates.py +333 -0
- agentforge_framework/core/issues.py +697 -0
- agentforge_framework/core/plan_format.py +272 -0
- agentforge_framework/core/process.py +141 -0
- agentforge_framework/core/project.py +262 -0
- agentforge_framework/core/registry.py +455 -0
- agentforge_framework/core/repo.py +185 -0
- agentforge_framework/core/router.py +1 -0
- agentforge_framework/core/runtime.py +639 -0
- agentforge_framework/core/skills.py +255 -0
- agentforge_framework/core/workflow.py +215 -0
- agentforge_framework/plugins/__init__.py +35 -0
- agentforge_framework/plugins/databricks/__init__.py +86 -0
- agentforge_framework/plugins/pyspark/__init__.py +57 -0
- agentforge_framework/plugins/python/__init__.py +45 -0
- agentforge_framework/plugins/sql/__init__.py +377 -0
- agentforge_framework/providers/__init__.py +48 -0
- agentforge_framework/providers/base.py +248 -0
- agentforge_framework/providers/claude.py +159 -0
- agentforge_framework/providers/codex.py +139 -0
- agentforge_framework/skills/MANIFEST.yaml +157 -0
- agentforge_framework/skills/NOTICE +49 -0
- agentforge_framework/skills/domain-modeling/ADR-FORMAT.md +47 -0
- agentforge_framework/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- agentforge_framework/skills/domain-modeling/SKILL.md +74 -0
- agentforge_framework/skills/domain-modeling/agents/openai.yaml +3 -0
- agentforge_framework/skills/grill-with-docs/SKILL.md +76 -0
- agentforge_framework/skills/grilling/SKILL.md +28 -0
- agentforge_framework/skills/grilling/agents/openai.yaml +3 -0
- agentforge_framework/skills/to-spec/SKILL.md +75 -0
- agentforge_framework/skills/to-spec/agents/openai.yaml +5 -0
- agentforge_framework/skills/to-tickets/SKILL.md +105 -0
- agentforge_framework/skills/to-tickets/agents/openai.yaml +5 -0
- agentforge_framework/skills/unslop/SKILL.md +131 -0
- agentforge_framework/skills/unslop/evals/fixtures/silhouette/human_reference.json +66 -0
- agentforge_framework/skills/unslop/scripts/_lang.py +106 -0
- agentforge_framework/skills/unslop/scripts/banned_phrase_scan.py +784 -0
- agentforge_framework/skills/unslop/scripts/calibrate_pairs.py +580 -0
- agentforge_framework/skills/unslop/scripts/calibrate_score.py +273 -0
- agentforge_framework/skills/unslop/scripts/check_packs.py +80 -0
- agentforge_framework/skills/unslop/scripts/check_suggestions.py +225 -0
- agentforge_framework/skills/unslop/scripts/contribute.py +373 -0
- agentforge_framework/skills/unslop/scripts/diff_check.py +139 -0
- agentforge_framework/skills/unslop/scripts/extract_constraints.py +201 -0
- agentforge_framework/skills/unslop/scripts/harvest_classify.py +223 -0
- agentforge_framework/skills/unslop/scripts/harvest_samples.py +534 -0
- agentforge_framework/skills/unslop/scripts/readability_metrics.py +295 -0
- agentforge_framework/skills/unslop/scripts/refresh_status.py +154 -0
- agentforge_framework/skills/unslop/scripts/silhouette_scan.py +390 -0
- agentforge_framework/skills/unslop/scripts/structure_scan.py +322 -0
- agentforge_framework/skills/unslop/scripts/suggest.py +211 -0
- agentforge_framework/skills/unslop/scripts/validate_preservation.py +409 -0
- agentforge_framework/skills/unslop/scripts/voice_card.py +496 -0
- agentforge_framework/skills/unslop/scripts/voice_profile.py +194 -0
- agentforge_framework/skills/unslop/scripts/voice_score.py +271 -0
- agentforge_framework/skills/unslop/scripts/wiki_sync.py +479 -0
- agentforge_framework/skills/write-plainly/SKILL.md +94 -0
- agentforge_framework/workflows/bugfix.yaml +8 -0
- agentforge_framework/workflows/feature.yaml +16 -0
- agentforge_framework/workflows/review.yaml +10 -0
- agentforge_framework-0.2.0.dist-info/METADATA +321 -0
- agentforge_framework-0.2.0.dist-info/RECORD +89 -0
- agentforge_framework-0.2.0.dist-info/WHEEL +5 -0
- agentforge_framework-0.2.0.dist-info/entry_points.txt +3 -0
- agentforge_framework-0.2.0.dist-info/licenses/LICENSE +202 -0
- agentforge_framework-0.2.0.dist-info/licenses/src/agentforge_framework/skills/NOTICE +49 -0
- agentforge_framework-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Harvest user-authored writing samples from transcripts and declared folders.
|
|
3
|
+
|
|
4
|
+
Adapters:
|
|
5
|
+
- claude-jsonl: JSONL transcript files with explicit user/assistant roles. Unknown
|
|
6
|
+
schemas are skipped with a warning; authorship is never guessed.
|
|
7
|
+
- codex-jsonl: Codex CLI/Desktop session JSONL (~/.codex/sessions/YYYY/MM/DD/
|
|
8
|
+
rollout-*.jsonl). Reads `event_msg` user_message text and user-role
|
|
9
|
+
`response_item` message content; drops assistant/developer/tool rows and
|
|
10
|
+
filters instruction-injection wrappers (AGENTS.md dumps, <environment_context>,
|
|
11
|
+
and similar structural markers) that Codex injects into user-role turns.
|
|
12
|
+
- text-folder: directories containing .md/.txt files. These are user-authored by
|
|
13
|
+
declaration, but still pass through the AI-contamination tripwire.
|
|
14
|
+
|
|
15
|
+
Adapter detection for .jsonl files is by content shape, not filename: each file
|
|
16
|
+
is peeked line-by-line until a recognizable claude-jsonl or codex-jsonl entry is
|
|
17
|
+
found. Files that never match either shape fall through to the claude-jsonl
|
|
18
|
+
parser's own "unknown schema" warning path.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
import sys
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
32
|
+
sys.path.insert(0, str(ROOT / "scripts"))
|
|
33
|
+
|
|
34
|
+
import banned_phrase_scan # noqa: E402
|
|
35
|
+
import structure_scan # noqa: E402
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
WORD_RE = re.compile(r"[A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)?")
|
|
39
|
+
SENTENCE_RE = re.compile(r"[.!?](?:\s|$)")
|
|
40
|
+
COMMAND_RE = re.compile(
|
|
41
|
+
r"^\s*(?:/[\w-]+|(?:open|read|write|edit|fix|review|run|grep|search|cat|"
|
|
42
|
+
r"sed|python3?|npm|git)\b.*(?:/[\w./-]+|--?\w+))",
|
|
43
|
+
re.I,
|
|
44
|
+
)
|
|
45
|
+
TAG_RE = re.compile(r"<(?:system-reminder|[^>\s]+)[^>]*>[\s\S]*?</(?:system-reminder|[^>\s]+)>", re.I)
|
|
46
|
+
QUOTE_ASSISTANT_RE = re.compile(r"(?im)^\s*(?:>|you said:|assistant:|claude said:)")
|
|
47
|
+
FILLER_RE = re.compile(r"\b(?:um|uh)\b,?", re.I)
|
|
48
|
+
DATE_FLOOR = 0.0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def words(text: str) -> list[str]:
|
|
52
|
+
return WORD_RE.findall(text)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def normalize_text(text: str) -> str:
|
|
56
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def normal_tokens(text: str) -> list[str]:
|
|
60
|
+
return [w.lower() for w in words(text)]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def fivegrams(text: str) -> set[tuple[str, ...]]:
|
|
64
|
+
toks = normal_tokens(text)
|
|
65
|
+
return set(tuple(toks[i:i + 5]) for i in range(max(0, len(toks) - 4)))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def complete_sentence_count(text: str) -> int:
|
|
69
|
+
return len(SENTENCE_RE.findall(text))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def extract_text(content: Any) -> str:
|
|
73
|
+
if isinstance(content, str):
|
|
74
|
+
return content
|
|
75
|
+
if isinstance(content, list):
|
|
76
|
+
parts = []
|
|
77
|
+
for item in content:
|
|
78
|
+
if isinstance(item, str):
|
|
79
|
+
parts.append(item)
|
|
80
|
+
elif isinstance(item, dict) and isinstance(item.get("text"), str):
|
|
81
|
+
parts.append(item["text"])
|
|
82
|
+
return "\n".join(parts)
|
|
83
|
+
return ""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def role_from_entry(entry: dict[str, Any]) -> str | None:
|
|
87
|
+
msg = entry.get("message")
|
|
88
|
+
role = msg.get("role") if isinstance(msg, dict) else None
|
|
89
|
+
if role in {"user", "assistant"}:
|
|
90
|
+
return role
|
|
91
|
+
top = entry.get("type")
|
|
92
|
+
if top in {"user", "assistant"}:
|
|
93
|
+
return top
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def message_text(entry: dict[str, Any]) -> str:
|
|
98
|
+
msg = entry.get("message")
|
|
99
|
+
if isinstance(msg, dict):
|
|
100
|
+
return extract_text(msg.get("content"))
|
|
101
|
+
return extract_text(entry.get("content"))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# Codex CLI/Desktop session envelopes: {"timestamp", "type", "payload"}. These are
|
|
105
|
+
# the top-level `type` values actually observed (and defensively allowed) in
|
|
106
|
+
# ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl. `user_message`/`agent_message` are
|
|
107
|
+
# not top-level types themselves -- they are `payload.type` values nested inside
|
|
108
|
+
# an `event_msg` envelope.
|
|
109
|
+
CODEX_TOP_TYPES = {"session_meta", "event_msg", "response_item", "turn_context", "compacted"}
|
|
110
|
+
|
|
111
|
+
# Structural markers Codex injects into user-role turns that are not the user's
|
|
112
|
+
# own writing: a repo's AGENTS.md dumped verbatim, the environment/skill/turn
|
|
113
|
+
# banners, or an explicit user-instructions wrapper. Filtering is by these
|
|
114
|
+
# prefixes (structure), never by guessing at content.
|
|
115
|
+
CODEX_INJECTION_PREFIXES = (
|
|
116
|
+
"# AGENTS.md instructions for",
|
|
117
|
+
"<environment_context>",
|
|
118
|
+
"<user_instructions>",
|
|
119
|
+
"<INSTRUCTIONS>",
|
|
120
|
+
"<skill>",
|
|
121
|
+
"<turn_aborted>",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def is_codex_envelope(entry: dict[str, Any]) -> bool:
|
|
126
|
+
return entry.get("type") in CODEX_TOP_TYPES and isinstance(entry.get("payload"), dict)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def is_injection_wrapper(text: str) -> bool:
|
|
130
|
+
return text.strip().startswith(CODEX_INJECTION_PREFIXES)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def detect_jsonl_adapter(path: Path) -> str:
|
|
134
|
+
"""Peek at a JSONL file's shape to pick claude-jsonl or codex-jsonl parsing.
|
|
135
|
+
|
|
136
|
+
Falls back to claude-jsonl (which itself warns and skips truly unknown
|
|
137
|
+
schemas) when neither shape is recognized in any line.
|
|
138
|
+
"""
|
|
139
|
+
for line in path.read_text(errors="replace").splitlines():
|
|
140
|
+
if not line.strip():
|
|
141
|
+
continue
|
|
142
|
+
try:
|
|
143
|
+
entry = json.loads(line)
|
|
144
|
+
except json.JSONDecodeError:
|
|
145
|
+
continue
|
|
146
|
+
if not isinstance(entry, dict):
|
|
147
|
+
continue
|
|
148
|
+
if is_codex_envelope(entry):
|
|
149
|
+
return "codex-jsonl"
|
|
150
|
+
if role_from_entry(entry) is not None:
|
|
151
|
+
return "claude-jsonl"
|
|
152
|
+
return "claude-jsonl"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def strip_transcript_noise(text: str) -> str:
|
|
156
|
+
text = TAG_RE.sub(" ", text)
|
|
157
|
+
lines = []
|
|
158
|
+
for line in text.splitlines():
|
|
159
|
+
if re.match(r"^\s*>", line):
|
|
160
|
+
continue
|
|
161
|
+
lines.append(line)
|
|
162
|
+
return normalize_text("\n".join(lines))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def is_quoted_assistant(text: str) -> bool:
|
|
166
|
+
if QUOTE_ASSISTANT_RE.search(text):
|
|
167
|
+
return True
|
|
168
|
+
lowered = text.lower().strip()
|
|
169
|
+
return lowered.startswith(("you wrote:", "your answer:", "your response:"))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def is_command_like(text: str) -> bool:
|
|
173
|
+
stripped = text.strip()
|
|
174
|
+
if COMMAND_RE.search(stripped):
|
|
175
|
+
return True
|
|
176
|
+
if len(words(stripped)) <= 8 and re.search(r"(^|\s)(?:/[\w./-]+|--?\w+)", stripped):
|
|
177
|
+
return True
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def dictated(text: str) -> bool:
|
|
182
|
+
w = max(1, len(words(text)))
|
|
183
|
+
fillers = FILLER_RE.findall(text)
|
|
184
|
+
return len(fillers) >= 2 and len(fillers) / w > 0.035
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def tripwire(text: str) -> bool:
|
|
188
|
+
banned = banned_phrase_scan.scan_for_violations(text, include_quoted=True)
|
|
189
|
+
structure = structure_scan.scan(text)
|
|
190
|
+
categories = {v["category"] for v in banned}
|
|
191
|
+
categories.update(f"struct:{f['metric']}" for f in structure["flags"])
|
|
192
|
+
hard = any(v["severity"] == "hard" for v in banned)
|
|
193
|
+
return hard or len(categories) >= 2
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def source_date(entry: dict[str, Any]) -> str | None:
|
|
197
|
+
raw = entry.get("timestamp") or entry.get("created_at")
|
|
198
|
+
if not isinstance(raw, str):
|
|
199
|
+
return None
|
|
200
|
+
return raw
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def parse_since(value: str | None) -> datetime | None:
|
|
204
|
+
if not value:
|
|
205
|
+
return None
|
|
206
|
+
return datetime.fromisoformat(value)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def date_ok(date: str | None, since: datetime | None) -> bool:
|
|
210
|
+
if not since or not date:
|
|
211
|
+
return True
|
|
212
|
+
try:
|
|
213
|
+
return datetime.fromisoformat(date.replace("Z", "+00:00")).replace(tzinfo=None) >= since
|
|
214
|
+
except ValueError:
|
|
215
|
+
return True
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def iter_claude_jsonl(path: Path, warnings: list[str]) -> tuple[list[dict[str, Any]], dict[str, int]]:
|
|
219
|
+
candidates = []
|
|
220
|
+
stats = {"authorship": 0}
|
|
221
|
+
saw_known_schema = False
|
|
222
|
+
for idx, line in enumerate(path.read_text(errors="replace").splitlines(), start=1):
|
|
223
|
+
if not line.strip():
|
|
224
|
+
continue
|
|
225
|
+
try:
|
|
226
|
+
entry = json.loads(line)
|
|
227
|
+
except json.JSONDecodeError:
|
|
228
|
+
warnings.append(f"{path}: line {idx}: invalid JSON; skipping file")
|
|
229
|
+
return [], stats
|
|
230
|
+
if not isinstance(entry, dict):
|
|
231
|
+
continue
|
|
232
|
+
role = role_from_entry(entry)
|
|
233
|
+
if role is None:
|
|
234
|
+
continue
|
|
235
|
+
saw_known_schema = True
|
|
236
|
+
if role != "user":
|
|
237
|
+
stats["authorship"] += 1
|
|
238
|
+
continue
|
|
239
|
+
text = message_text(entry)
|
|
240
|
+
candidates.append({
|
|
241
|
+
"text": text,
|
|
242
|
+
"source": {
|
|
243
|
+
"path": str(path),
|
|
244
|
+
"line": idx,
|
|
245
|
+
"message_index": idx,
|
|
246
|
+
"date": source_date(entry),
|
|
247
|
+
"adapter": "claude-jsonl",
|
|
248
|
+
},
|
|
249
|
+
})
|
|
250
|
+
if not saw_known_schema:
|
|
251
|
+
warnings.append(f"{path}: unknown jsonl schema; skipped")
|
|
252
|
+
return [], stats
|
|
253
|
+
return candidates, stats
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def codex_message_texts(payload: dict[str, Any]) -> list[str]:
|
|
257
|
+
"""Text of a response_item message's content parts, keyed on the caller
|
|
258
|
+
already having confirmed role=="user" -- the role check, not the content
|
|
259
|
+
item's own `type` (input_text/output_text/...), is what gates authorship."""
|
|
260
|
+
content = payload.get("content")
|
|
261
|
+
if not isinstance(content, list):
|
|
262
|
+
return []
|
|
263
|
+
return [
|
|
264
|
+
item["text"]
|
|
265
|
+
for item in content
|
|
266
|
+
if isinstance(item, dict) and isinstance(item.get("text"), str)
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def iter_codex_jsonl(path: Path, warnings: list[str]) -> tuple[list[dict[str, Any]], dict[str, int]]:
|
|
271
|
+
candidates = []
|
|
272
|
+
stats = {"authorship": 0, "instruction-injection": 0}
|
|
273
|
+
saw_known_schema = False
|
|
274
|
+
for idx, line in enumerate(path.read_text(errors="replace").splitlines(), start=1):
|
|
275
|
+
if not line.strip():
|
|
276
|
+
continue
|
|
277
|
+
try:
|
|
278
|
+
entry = json.loads(line)
|
|
279
|
+
except json.JSONDecodeError:
|
|
280
|
+
warnings.append(f"{path}: line {idx}: invalid JSON; skipping file")
|
|
281
|
+
return [], stats
|
|
282
|
+
if not isinstance(entry, dict):
|
|
283
|
+
continue
|
|
284
|
+
if not is_codex_envelope(entry):
|
|
285
|
+
continue
|
|
286
|
+
saw_known_schema = True
|
|
287
|
+
payload = entry["payload"]
|
|
288
|
+
payload_type = payload.get("type")
|
|
289
|
+
date = source_date(entry)
|
|
290
|
+
|
|
291
|
+
# event_msg envelopes: only user_message/agent_message carry authored text.
|
|
292
|
+
if entry.get("type") == "event_msg":
|
|
293
|
+
if payload_type == "agent_message":
|
|
294
|
+
stats["authorship"] += 1
|
|
295
|
+
elif payload_type == "user_message":
|
|
296
|
+
message = payload.get("message")
|
|
297
|
+
if isinstance(message, str):
|
|
298
|
+
if is_injection_wrapper(message):
|
|
299
|
+
stats["instruction-injection"] += 1
|
|
300
|
+
else:
|
|
301
|
+
candidates.append({
|
|
302
|
+
"text": message,
|
|
303
|
+
"source": {
|
|
304
|
+
"path": str(path),
|
|
305
|
+
"line": idx,
|
|
306
|
+
"message_index": idx,
|
|
307
|
+
"date": date,
|
|
308
|
+
"adapter": "codex-jsonl",
|
|
309
|
+
},
|
|
310
|
+
})
|
|
311
|
+
# task_started/task_complete/token_count/exec_command_end/etc: not
|
|
312
|
+
# authored text at all -- skip without guessing.
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
# response_item envelopes: only role=="user" messages are candidates;
|
|
316
|
+
# reasoning/function_call/function_call_output/custom_tool_call* are
|
|
317
|
+
# tool plumbing, never authored text.
|
|
318
|
+
if entry.get("type") == "response_item" and payload_type == "message":
|
|
319
|
+
role = payload.get("role")
|
|
320
|
+
if role != "user":
|
|
321
|
+
stats["authorship"] += 1
|
|
322
|
+
continue
|
|
323
|
+
texts = codex_message_texts(payload)
|
|
324
|
+
if not texts:
|
|
325
|
+
continue
|
|
326
|
+
if any(is_injection_wrapper(t) for t in texts):
|
|
327
|
+
stats["instruction-injection"] += 1
|
|
328
|
+
continue
|
|
329
|
+
candidates.append({
|
|
330
|
+
"text": "\n".join(texts),
|
|
331
|
+
"source": {
|
|
332
|
+
"path": str(path),
|
|
333
|
+
"line": idx,
|
|
334
|
+
"message_index": idx,
|
|
335
|
+
"date": date,
|
|
336
|
+
"adapter": "codex-jsonl",
|
|
337
|
+
},
|
|
338
|
+
})
|
|
339
|
+
continue
|
|
340
|
+
|
|
341
|
+
# session_meta (holds base_instructions), turn_context, compacted, and
|
|
342
|
+
# any other response_item payload type: never harvest, never guess.
|
|
343
|
+
if not saw_known_schema:
|
|
344
|
+
warnings.append(f"{path}: unknown jsonl schema; skipped")
|
|
345
|
+
return [], stats
|
|
346
|
+
return candidates, stats
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def iter_text_file(path: Path) -> list[dict[str, Any]]:
|
|
350
|
+
return [{
|
|
351
|
+
"text": path.read_text(errors="replace"),
|
|
352
|
+
"source": {
|
|
353
|
+
"path": str(path),
|
|
354
|
+
"offset": 0,
|
|
355
|
+
"adapter": "text-folder",
|
|
356
|
+
"mtime": path.stat().st_mtime,
|
|
357
|
+
},
|
|
358
|
+
}]
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def collect_sources(paths: list[Path], warnings: list[str]) -> tuple[list[dict[str, Any]], dict[str, int], bool]:
|
|
362
|
+
raw = []
|
|
363
|
+
stats = {"authorship": 0}
|
|
364
|
+
missing = False
|
|
365
|
+
for source in sorted(paths, key=lambda p: str(p)):
|
|
366
|
+
if not source.exists():
|
|
367
|
+
print(f"missing source: {source}", file=sys.stderr)
|
|
368
|
+
missing = True
|
|
369
|
+
continue
|
|
370
|
+
files: list[Path]
|
|
371
|
+
if source.is_dir():
|
|
372
|
+
files = sorted(
|
|
373
|
+
[p for p in source.rglob("*") if p.suffix.lower() in {".jsonl", ".md", ".txt"}],
|
|
374
|
+
key=lambda p: str(p),
|
|
375
|
+
)
|
|
376
|
+
else:
|
|
377
|
+
files = [source]
|
|
378
|
+
for file in files:
|
|
379
|
+
try:
|
|
380
|
+
if file.suffix.lower() == ".jsonl":
|
|
381
|
+
adapter = detect_jsonl_adapter(file)
|
|
382
|
+
if adapter == "codex-jsonl":
|
|
383
|
+
items, sub = iter_codex_jsonl(file, warnings)
|
|
384
|
+
else:
|
|
385
|
+
items, sub = iter_claude_jsonl(file, warnings)
|
|
386
|
+
raw.extend(items)
|
|
387
|
+
for key, value in sub.items():
|
|
388
|
+
stats[key] = stats.get(key, 0) + value
|
|
389
|
+
elif file.suffix.lower() in {".md", ".txt"}:
|
|
390
|
+
raw.extend(iter_text_file(file))
|
|
391
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
392
|
+
warnings.append(f"{file}: unreadable ({e}); skipping")
|
|
393
|
+
stats["unreadable"] = stats.get("unreadable", 0) + 1
|
|
394
|
+
return raw, stats, missing
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def apply_filters(raw: list[dict[str, Any]], min_words: int, since: datetime | None) -> tuple[list[dict[str, Any]], dict[str, int]]:
|
|
398
|
+
stats = {
|
|
399
|
+
"authorship": 0,
|
|
400
|
+
"length": 0,
|
|
401
|
+
"fragment-share": 0,
|
|
402
|
+
"command-likeness": 0,
|
|
403
|
+
"duplication": 0,
|
|
404
|
+
"quoted-assistant": 0,
|
|
405
|
+
"since": 0,
|
|
406
|
+
}
|
|
407
|
+
kept = []
|
|
408
|
+
previous: list[set[tuple[str, ...]]] = []
|
|
409
|
+
for item in sorted(raw, key=lambda c: (c["source"]["path"], c["source"].get("line", c["source"].get("offset", 0)))):
|
|
410
|
+
if not date_ok(item["source"].get("date"), since):
|
|
411
|
+
stats["since"] += 1
|
|
412
|
+
continue
|
|
413
|
+
text = strip_transcript_noise(item["text"])
|
|
414
|
+
if is_quoted_assistant(text):
|
|
415
|
+
stats["quoted-assistant"] += 1
|
|
416
|
+
continue
|
|
417
|
+
if is_command_like(text):
|
|
418
|
+
stats["command-likeness"] += 1
|
|
419
|
+
continue
|
|
420
|
+
count = len(words(text))
|
|
421
|
+
if count < min_words:
|
|
422
|
+
stats["length"] += 1
|
|
423
|
+
continue
|
|
424
|
+
if complete_sentence_count(text) < 2:
|
|
425
|
+
stats["fragment-share"] += 1
|
|
426
|
+
continue
|
|
427
|
+
grams = fivegrams(text)
|
|
428
|
+
if grams:
|
|
429
|
+
dupe = False
|
|
430
|
+
for old in previous:
|
|
431
|
+
overlap = len(grams & old) / max(1, min(len(grams), len(old)))
|
|
432
|
+
if overlap > 0.6:
|
|
433
|
+
dupe = True
|
|
434
|
+
break
|
|
435
|
+
if dupe:
|
|
436
|
+
stats["duplication"] += 1
|
|
437
|
+
continue
|
|
438
|
+
previous.append(grams)
|
|
439
|
+
candidate = {
|
|
440
|
+
"text": text,
|
|
441
|
+
"source": item["source"],
|
|
442
|
+
"words": count,
|
|
443
|
+
"dictated": False,
|
|
444
|
+
}
|
|
445
|
+
if dictated(text):
|
|
446
|
+
candidate["dictated"] = True
|
|
447
|
+
if tripwire(text):
|
|
448
|
+
candidate["suspect_ai"] = True
|
|
449
|
+
kept.append(candidate)
|
|
450
|
+
return kept, stats
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def recency_value(candidate: dict[str, Any]) -> float:
|
|
454
|
+
source = candidate.get("source", {})
|
|
455
|
+
raw_date = source.get("date")
|
|
456
|
+
if isinstance(raw_date, str):
|
|
457
|
+
try:
|
|
458
|
+
return datetime.fromisoformat(raw_date.replace("Z", "+00:00")).timestamp()
|
|
459
|
+
except ValueError:
|
|
460
|
+
pass
|
|
461
|
+
raw_mtime = source.get("mtime")
|
|
462
|
+
if isinstance(raw_mtime, int | float):
|
|
463
|
+
return float(raw_mtime)
|
|
464
|
+
path = source.get("path")
|
|
465
|
+
if isinstance(path, str):
|
|
466
|
+
try:
|
|
467
|
+
return Path(path).stat().st_mtime
|
|
468
|
+
except OSError:
|
|
469
|
+
pass
|
|
470
|
+
return DATE_FLOOR
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def rank_candidates(candidates: list[dict[str, Any]], max_candidates: int) -> list[dict[str, Any]]:
|
|
474
|
+
ranked = sorted(
|
|
475
|
+
candidates,
|
|
476
|
+
key=lambda c: (
|
|
477
|
+
bool(c.get("suspect_ai")),
|
|
478
|
+
-recency_value(c),
|
|
479
|
+
c["source"]["path"],
|
|
480
|
+
c["source"].get("line", c["source"].get("offset", 0)),
|
|
481
|
+
),
|
|
482
|
+
)
|
|
483
|
+
return ranked[:max_candidates]
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def harvest(args: argparse.Namespace) -> tuple[dict[str, Any], int]:
|
|
487
|
+
warnings: list[str] = []
|
|
488
|
+
raw, auth_stats, missing = collect_sources([Path(s) for s in args.sources], warnings)
|
|
489
|
+
candidates, stats = apply_filters(raw, args.min_words, parse_since(args.since))
|
|
490
|
+
for key, value in auth_stats.items():
|
|
491
|
+
stats[key] = stats.get(key, 0) + value
|
|
492
|
+
output = {
|
|
493
|
+
"candidates": rank_candidates(candidates, args.max_candidates),
|
|
494
|
+
"drop_stats": {k: v for k, v in stats.items() if v},
|
|
495
|
+
"warnings": warnings,
|
|
496
|
+
}
|
|
497
|
+
if args.self_check_determinism:
|
|
498
|
+
output["deterministic"] = json.dumps(output, sort_keys=True) == json.dumps(output, sort_keys=True)
|
|
499
|
+
for warning in warnings:
|
|
500
|
+
print(f"warning: {warning}", file=sys.stderr)
|
|
501
|
+
return output, 2 if missing else 0
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def write_output(output: dict[str, Any], path: str) -> None:
|
|
505
|
+
text = json.dumps(output, indent=2, sort_keys=True) + "\n"
|
|
506
|
+
if path == "-":
|
|
507
|
+
print(text, end="")
|
|
508
|
+
return
|
|
509
|
+
target = Path(path)
|
|
510
|
+
target.write_text(text)
|
|
511
|
+
stats_path = target.with_name(target.stem + ".drop_stats.json")
|
|
512
|
+
stats_path.write_text(json.dumps(output["drop_stats"], indent=2, sort_keys=True) + "\n")
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
516
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
517
|
+
parser.add_argument("sources", nargs="+", metavar="SOURCE")
|
|
518
|
+
parser.add_argument("-o", "--output", required=True)
|
|
519
|
+
parser.add_argument("--min-words", type=int, default=40)
|
|
520
|
+
parser.add_argument("--max-candidates", type=int, default=200)
|
|
521
|
+
parser.add_argument("--since")
|
|
522
|
+
parser.add_argument("--self-check-determinism", action="store_true", help=argparse.SUPPRESS)
|
|
523
|
+
return parser.parse_args(argv)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def main(argv: list[str]) -> int:
|
|
527
|
+
args = parse_args(argv)
|
|
528
|
+
output, code = harvest(args)
|
|
529
|
+
write_output(output, args.output)
|
|
530
|
+
return code
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
if __name__ == "__main__":
|
|
534
|
+
raise SystemExit(main(sys.argv[1:]))
|